Add object type filter dropdown to side panel record searches (#18912)

## Summary

- Adds an object type filter dropdown to the side panel, allowing users
to scope record search results to a specific object type (e.g. People,
Companies, Opportunities)
- The filter appears as a funnel icon next to the search input in both
the global search (SearchRecords page) and the "Pick a record" sidebar
flow
- Replaces the AI sparkles button on the search records page with the
filter icon
- Uses colored icons matching the navigation menu style
(NavigationMenuItemStyleIcon + getStandardObjectIconColor)

## Test plan

- [ ] Open the side panel, type something to enter SearchRecords mode,
verify the filter icon appears
- [ ] Click the filter icon, verify the dropdown opens with "Object"
header, search input, "All Objects" and individual object types with
colored icons
- [ ] Select an object type, verify only records of that type appear in
search results
- [ ] Select "All Objects", verify all record types appear again
- [ ] Verify the filter icon turns blue when a filter is active
- [ ] In layout customization mode, add a new sidebar item > Record,
verify the filter dropdown also works there
- [ ] Close and reopen the side panel, verify the filter resets to "All
Objects"
- [ ] Verify the AI sparkles button no longer appears on the command
menu root page
- [ ] Verify the AI edit icon still appears on Ask AI pages


Made with [Cursor](https://cursor.com)
This commit is contained in:
Félix Malfait
2026-03-25 16:02:24 +01:00
committed by GitHub
parent 13ea3ec75c
commit 5f0d6553f6
26 changed files with 641 additions and 98 deletions
@@ -84,6 +84,24 @@ export const mockFlatObjectMetadatas: FlatObjectMetadata[] = [
universalIdentifier: 'non-searchable-object-universal-id',
applicationId: workspaceId,
}),
getFlatObjectMetadataMock({
id: '20202020-6a7c-4e3f-9b2d-1d8f7a3e5c4b',
nameSingular: 'message',
namePlural: 'messages',
labelSingular: 'Message',
labelPlural: 'Messages',
description: 'Message',
icon: 'IconMessage',
isCustom: false,
isSystem: true,
isSearchable: false,
labelIdentifierFieldMetadataId: null,
imageIdentifierFieldMetadataId: null,
workspaceId,
fieldIds: [],
universalIdentifier: 'message-universal-id',
applicationId: workspaceId,
}),
];
export const mockFlatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata> = {
@@ -65,6 +65,24 @@ describe('SearchService', () => {
expect(objectMetadataItems).toEqual([mockFlatObjectMetadatas[1]]);
});
it('should allow non-searchable objects when explicitly included', () => {
const objectMetadataItems = service.filterObjectMetadataItems({
flatObjectMetadatas: mockFlatObjectMetadatas,
includedObjectNameSingulars: ['non-searchable-object'],
excludedObjectNameSingulars: [],
});
expect(objectMetadataItems).toEqual([mockFlatObjectMetadatas[3]]);
});
it('should block objects with channel visibility constraints even when explicitly included', () => {
const objectMetadataItems = service.filterObjectMetadataItems({
flatObjectMetadatas: mockFlatObjectMetadatas,
includedObjectNameSingulars: ['message'],
excludedObjectNameSingulars: [],
});
expect(objectMetadataItems).toEqual([]);
});
});
describe('getLabelIdentifierColumns', () => {
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { isNonEmptyString } from '@sniptt/guards';
import chunk from 'lodash.chunk';
import { OBJECTS_WITH_CHANNEL_VISIBILITY_CONSTRAINTS } from 'twenty-shared/constants';
import { FieldMetadataType, ObjectRecord } from 'twenty-shared/types';
import { getLogoUrlFromDomainName, isDefined } from 'twenty-shared/utils';
import { Brackets, type ObjectLiteral } from 'typeorm';
@@ -138,19 +139,35 @@ export class SearchService {
includedObjectNameSingulars: string[];
excludedObjectNameSingulars: string[];
}) {
const hasExplicitInclusion = includedObjectNameSingulars.length > 0;
return flatObjectMetadatas.filter(
({ nameSingular, isSearchable, isActive }) => {
if (!isSearchable) {
return false;
}
if (!isActive) {
return false;
}
if (excludedObjectNameSingulars.includes(nameSingular)) {
if (hasExplicitInclusion) {
if (
OBJECTS_WITH_CHANNEL_VISIBILITY_CONSTRAINTS.includes(
nameSingular as (typeof OBJECTS_WITH_CHANNEL_VISIBILITY_CONSTRAINTS)[number],
)
) {
return false;
}
return (
includedObjectNameSingulars.includes(nameSingular) &&
!excludedObjectNameSingulars.includes(nameSingular)
);
}
if (!isSearchable) {
return false;
}
if (includedObjectNameSingulars.length > 0) {
return includedObjectNameSingulars.includes(nameSingular);
if (excludedObjectNameSingulars.includes(nameSingular)) {
return false;
}
return true;