From 5f0d6553f61a5c0c74e50652dfabb2fb2ee399f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Wed, 25 Mar 2026 16:02:24 +0100 Subject: [PATCH] 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) --- .../src/metadata/generated/schema.graphql | 1 + .../src/metadata/generated/schema.ts | 2 +- .../src/metadata/generated/types.ts | 3 + .../src/generated-metadata/graphql.ts | 1 + .../modules/mention/hooks/useMentionSearch.ts | 15 +- .../SidePanelNewSidebarItemRecordSubPage.tsx | 51 +++-- .../hooks/useDefaultHomePagePath.ts | 38 ++-- .../hooks/useReadableObjectMetadataItems.ts | 21 ++ .../components/tabs/ObjectSettings.tsx | 16 ++ .../tabs/SettingsObjectSearchSection.tsx | 194 ++++++++++++++++++ .../SidePanelObjectFilterDropdown.tsx | 44 ++++ .../SidePanelObjectFilterDropdownContent.tsx | 149 ++++++++++++++ .../components/SidePanelSubViewWithSearch.tsx | 3 + .../SidePanelTopBarRightCornerIcon.tsx | 36 ++-- ...eSidePanelCloseAnimationCompleteCleanup.ts | 4 + .../side-panel/hooks/useSidePanelMenu.ts | 2 + .../hooks/useSidePanelSearchRecords.tsx | 51 ++--- .../sidePanelSearchObjectFilterState.ts | 6 + .../states/sidePanelShowHiddenObjectsState.ts | 6 + .../__mocks__/mockFlatObjectMetadatas.ts | 18 ++ .../search/__tests__/search.service.spec.ts | 18 ++ .../search/services/search.service.ts | 29 ++- ...t-metadata-editable-properties.constant.ts | 2 + .../dtos/update-object.input.ts | 5 + ...ObjectsWithChannelVisibilityConstraints.ts | 23 +++ packages/twenty-shared/src/constants/index.ts | 1 + 26 files changed, 641 insertions(+), 98 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-metadata/hooks/useReadableObjectMetadataItems.ts create mode 100644 packages/twenty-front/src/modules/settings/data-model/object-details/components/tabs/SettingsObjectSearchSection.tsx create mode 100644 packages/twenty-front/src/modules/side-panel/components/SidePanelObjectFilterDropdown.tsx create mode 100644 packages/twenty-front/src/modules/side-panel/components/SidePanelObjectFilterDropdownContent.tsx create mode 100644 packages/twenty-front/src/modules/side-panel/states/sidePanelSearchObjectFilterState.ts create mode 100644 packages/twenty-front/src/modules/side-panel/states/sidePanelShowHiddenObjectsState.ts create mode 100644 packages/twenty-shared/src/constants/ObjectsWithChannelVisibilityConstraints.ts diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index 744a9655af..30c2c8492b 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -4089,6 +4089,7 @@ input UpdateObjectPayload { labelIdentifierFieldMetadataId: UUID imageIdentifierFieldMetadataId: UUID isLabelSyncedWithName: Boolean + isSearchable: Boolean } input UpdateViewFieldInput { diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index 223aba8d0b..5597570cf2 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -6475,7 +6475,7 @@ export interface UpdateOneObjectInput {update: UpdateObjectPayload, /** The id of the object to update */ id: Scalars['UUID']} -export interface UpdateObjectPayload {labelSingular?: (Scalars['String'] | null),labelPlural?: (Scalars['String'] | null),nameSingular?: (Scalars['String'] | null),namePlural?: (Scalars['String'] | null),description?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),shortcut?: (Scalars['String'] | null),color?: (Scalars['String'] | null),isActive?: (Scalars['Boolean'] | null),labelIdentifierFieldMetadataId?: (Scalars['UUID'] | null),imageIdentifierFieldMetadataId?: (Scalars['UUID'] | null),isLabelSyncedWithName?: (Scalars['Boolean'] | null)} +export interface UpdateObjectPayload {labelSingular?: (Scalars['String'] | null),labelPlural?: (Scalars['String'] | null),nameSingular?: (Scalars['String'] | null),namePlural?: (Scalars['String'] | null),description?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),shortcut?: (Scalars['String'] | null),color?: (Scalars['String'] | null),isActive?: (Scalars['Boolean'] | null),labelIdentifierFieldMetadataId?: (Scalars['UUID'] | null),imageIdentifierFieldMetadataId?: (Scalars['UUID'] | null),isLabelSyncedWithName?: (Scalars['Boolean'] | null),isSearchable?: (Scalars['Boolean'] | null)} export interface UpdateViewFieldInput { /** The id of the view field to update */ diff --git a/packages/twenty-client-sdk/src/metadata/generated/types.ts b/packages/twenty-client-sdk/src/metadata/generated/types.ts index 2eacb10116..ada7e65f35 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/types.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/types.ts @@ -10303,6 +10303,9 @@ export default { "isLabelSyncedWithName": [ 6 ], + "isSearchable": [ + 6 + ], "__typename": [ 1 ] diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 101dee58dd..8878efcbf8 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -5326,6 +5326,7 @@ export type UpdateObjectPayload = { imageIdentifierFieldMetadataId?: InputMaybe; isActive?: InputMaybe; isLabelSyncedWithName?: InputMaybe; + isSearchable?: InputMaybe; labelIdentifierFieldMetadataId?: InputMaybe; labelPlural?: InputMaybe; labelSingular?: InputMaybe; diff --git a/packages/twenty-front/src/modules/mention/hooks/useMentionSearch.ts b/packages/twenty-front/src/modules/mention/hooks/useMentionSearch.ts index 9b54038cd8..2bb6eabbad 100644 --- a/packages/twenty-front/src/modules/mention/hooks/useMentionSearch.ts +++ b/packages/twenty-front/src/modules/mention/hooks/useMentionSearch.ts @@ -1,8 +1,8 @@ import { SEARCH_QUERY } from '@/command-menu/graphql/queries/search'; import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems'; +import { filterReadableActiveObjectMetadataItems } from '@/object-metadata/utils/filterReadableActiveObjectMetadataItems'; import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; -import { getObjectPermissionsFromMapByObjectMetadataId } from '@/settings/roles/role-permissions/objects-permissions/utils/getObjectPermissionsFromMapByObjectMetadataId'; import { useCallback, useMemo } from 'react'; import { type SearchQuery, @@ -19,15 +19,10 @@ export const useMentionSearch = () => { const searchableObjectMetadataItems = useMemo( () => - activeObjectMetadataItems.filter( - (item) => - !item.isSystem && - item.isSearchable && - getObjectPermissionsFromMapByObjectMetadataId({ - objectPermissionsByObjectMetadataId, - objectMetadataId: item.id, - }).canReadObjectRecords === true, - ), + filterReadableActiveObjectMetadataItems( + activeObjectMetadataItems, + objectPermissionsByObjectMetadataId, + ).filter((item) => !item.isSystem && item.isSearchable), [activeObjectMetadataItems, objectPermissionsByObjectMetadataId], ); diff --git a/packages/twenty-front/src/modules/navigation-menu-item/edit/side-panel/components/SidePanelNewSidebarItemRecordSubPage.tsx b/packages/twenty-front/src/modules/navigation-menu-item/edit/side-panel/components/SidePanelNewSidebarItemRecordSubPage.tsx index 937fae6749..8a3b253ae6 100644 --- a/packages/twenty-front/src/modules/navigation-menu-item/edit/side-panel/components/SidePanelNewSidebarItemRecordSubPage.tsx +++ b/packages/twenty-front/src/modules/navigation-menu-item/edit/side-panel/components/SidePanelNewSidebarItemRecordSubPage.tsx @@ -1,18 +1,19 @@ import { useLingui } from '@lingui/react/macro'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { isDefined } from 'twenty-shared/utils'; import { useDebounce } from 'use-debounce'; import { MAX_SEARCH_RESULTS } from '@/command-menu/constants/MaxSearchResults'; import { useDraftNavigationMenuItems } from '@/navigation-menu-item/edit/hooks/useDraftNavigationMenuItems'; import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; -import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; -import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; -import { getObjectPermissionsFromMapByObjectMetadataId } from '@/settings/roles/role-permissions/objects-permissions/utils/getObjectPermissionsFromMapByObjectMetadataId'; +import { useReadableObjectMetadataItems } from '@/object-metadata/hooks/useReadableObjectMetadataItems'; import { SidePanelAddToNavigationDroppable } from '@/side-panel/components/SidePanelAddToNavigationDroppable'; import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup'; import { SidePanelList } from '@/side-panel/components/SidePanelList'; +import { SidePanelObjectFilterDropdown } from '@/side-panel/components/SidePanelObjectFilterDropdown'; +import { sidePanelShowHiddenObjectsState } from '@/side-panel/states/sidePanelShowHiddenObjectsState'; import { SidePanelSubViewWithSearch } from '@/side-panel/components/SidePanelSubViewWithSearch'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; import { SidePanelNewSidebarItemRecordItem } from '@/navigation-menu-item/edit/side-panel/components/SidePanelNewSidebarItemRecordItem'; import { useQuery } from '@apollo/client/react'; import { SearchDocument } from '~/generated/graphql'; @@ -27,21 +28,30 @@ type SearchRecordBase = { export const SidePanelNewSidebarItemRecordSubPage = () => { const { t } = useLingui(); const { currentDraft } = useDraftNavigationMenuItems(); - const { objectMetadataItems } = useObjectMetadataItems(); const [recordSearchInput, setRecordSearchInput] = useState(''); const [deferredRecordSearchInput] = useDebounce(recordSearchInput, 300); const coreClient = useApolloCoreClient(); - const { objectPermissionsByObjectMetadataId } = useObjectPermissions(); + const { readableObjectMetadataItems } = useReadableObjectMetadataItems(); + const [selectedObjectNameSingular, setSelectedObjectNameSingular] = useState< + string | null + >(null); + const sidePanelShowHiddenObjects = useAtomStateValue( + sidePanelShowHiddenObjectsState, + ); - const nonReadableObjectMetadataItemsNameSingular = objectMetadataItems - .filter( - (objectMetadataItem) => - !getObjectPermissionsFromMapByObjectMetadataId({ - objectPermissionsByObjectMetadataId, - objectMetadataId: objectMetadataItem.id, - })?.canReadObjectRecords, - ) - .map((objectMetadataItem) => objectMetadataItem.nameSingular); + const includedObjectNameSingulars = useMemo(() => { + if (isDefined(selectedObjectNameSingular)) { + return [selectedObjectNameSingular]; + } + + return readableObjectMetadataItems + .filter((item) => sidePanelShowHiddenObjects || item.isSearchable) + .map((item) => item.nameSingular); + }, [ + readableObjectMetadataItems, + selectedObjectNameSingular, + sidePanelShowHiddenObjects, + ]); const { data: searchData, loading: recordSearchLoading } = useQuery( SearchDocument, @@ -50,10 +60,7 @@ export const SidePanelNewSidebarItemRecordSubPage = () => { variables: { searchInput: deferredRecordSearchInput ?? '', limit: MAX_SEARCH_RESULTS, - excludedObjectNameSingulars: [ - 'workspaceMember', - ...nonReadableObjectMetadataItemsNameSingular, - ], + includedObjectNameSingulars, }, }, ); @@ -84,6 +91,12 @@ export const SidePanelNewSidebarItemRecordSubPage = () => { searchPlaceholder={t`Search records...`} searchValue={recordSearchInput} onSearchChange={setRecordSearchInput} + rightElement={ + + } > {({ innerRef, droppableProps, placeholder }) => ( diff --git a/packages/twenty-front/src/modules/navigation/hooks/useDefaultHomePagePath.ts b/packages/twenty-front/src/modules/navigation/hooks/useDefaultHomePagePath.ts index ccf8d3bf0c..72802253f8 100644 --- a/packages/twenty-front/src/modules/navigation/hooks/useDefaultHomePagePath.ts +++ b/packages/twenty-front/src/modules/navigation/hooks/useDefaultHomePagePath.ts @@ -2,8 +2,8 @@ import { currentUserState } from '@/auth/states/currentUserState'; import { lastVisitedObjectMetadataItemIdState } from '@/navigation/states/lastVisitedObjectMetadataItemIdState'; import { type ObjectPathInfo } from '@/navigation/types/ObjectPathInfo'; import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems'; +import { filterReadableActiveObjectMetadataItems } from '@/object-metadata/utils/filterReadableActiveObjectMetadataItems'; import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; -import { getObjectPermissionsFromMapByObjectMetadataId } from '@/settings/roles/role-permissions/objects-permissions/utils/getObjectPermissionsFromMapByObjectMetadataId'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; import { viewsSelector } from '@/views/states/selectors/viewsSelector'; import isEmpty from 'lodash.isempty'; @@ -17,29 +17,26 @@ export const useDefaultHomePagePath = () => { const currentUser = useAtomStateValue(currentUserState); const { objectPermissionsByObjectMetadataId } = useObjectPermissions(); - const { alphaSortedActiveNonSystemObjectMetadataItems } = - useFilteredObjectMetadataItems(); + const { activeObjectMetadataItems } = useFilteredObjectMetadataItems(); - const readableAlphaSortedActiveNonSystemObjectMetadataItems = useMemo(() => { - return alphaSortedActiveNonSystemObjectMetadataItems.filter((item) => { - const objectPermissions = getObjectPermissionsFromMapByObjectMetadataId({ + const readableNonSystemObjectMetadataItems = useMemo( + () => + filterReadableActiveObjectMetadataItems( + activeObjectMetadataItems, objectPermissionsByObjectMetadataId, - objectMetadataId: item.id, - }); - return objectPermissions?.canReadObjectRecords; - }); - }, [ - alphaSortedActiveNonSystemObjectMetadataItems, - objectPermissionsByObjectMetadataId, - ]); + ) + .filter((item) => !item.isSystem) + .sort((a, b) => a.nameSingular.localeCompare(b.nameSingular)), + [activeObjectMetadataItems, objectPermissionsByObjectMetadataId], + ); const getActiveObjectMetadataItemMatchingId = useCallback( (objectMetadataId: string) => { - return readableAlphaSortedActiveNonSystemObjectMetadataItems.find( + return readableNonSystemObjectMetadataItems.find( (item) => item.id === objectMetadataId, ); }, - [readableAlphaSortedActiveNonSystemObjectMetadataItems], + [readableNonSystemObjectMetadataItems], ); const views = useAtomStateValue(viewsSelector); @@ -54,8 +51,7 @@ export const useDefaultHomePagePath = () => { ); const firstObjectPathInfo = useMemo(() => { - const [firstObjectMetadataItem] = - readableAlphaSortedActiveNonSystemObjectMetadataItems; + const [firstObjectMetadataItem] = readableNonSystemObjectMetadataItems; if (!isDefined(firstObjectMetadataItem)) { return null; @@ -64,7 +60,7 @@ export const useDefaultHomePagePath = () => { const view = getFirstView(firstObjectMetadataItem?.id); return { objectMetadataItem: firstObjectMetadataItem, view }; - }, [getFirstView, readableAlphaSortedActiveNonSystemObjectMetadataItems]); + }, [getFirstView, readableNonSystemObjectMetadataItems]); const getDefaultObjectPathInfo = useCallback(() => { const lastVisitedObjectMetadataItemId = store.get( @@ -97,7 +93,7 @@ export const useDefaultHomePagePath = () => { return AppPath.SignInUp; } - if (isEmpty(readableAlphaSortedActiveNonSystemObjectMetadataItems)) { + if (isEmpty(readableNonSystemObjectMetadataItems)) { return getSettingsPath(SettingsPath.ProfilePage); } @@ -118,7 +114,7 @@ export const useDefaultHomePagePath = () => { }, [ currentUser, getDefaultObjectPathInfo, - readableAlphaSortedActiveNonSystemObjectMetadataItems, + readableNonSystemObjectMetadataItems, ]); return { defaultHomePagePath }; diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useReadableObjectMetadataItems.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useReadableObjectMetadataItems.ts new file mode 100644 index 0000000000..ddf4577c13 --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/hooks/useReadableObjectMetadataItems.ts @@ -0,0 +1,21 @@ +import { useMemo } from 'react'; + +import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems'; +import { filterReadableActiveObjectMetadataItems } from '@/object-metadata/utils/filterReadableActiveObjectMetadataItems'; +import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; + +export const useReadableObjectMetadataItems = () => { + const { activeObjectMetadataItems } = useFilteredObjectMetadataItems(); + const { objectPermissionsByObjectMetadataId } = useObjectPermissions(); + + const readableObjectMetadataItems = useMemo( + () => + filterReadableActiveObjectMetadataItems( + activeObjectMetadataItems, + objectPermissionsByObjectMetadataId, + ), + [activeObjectMetadataItems, objectPermissionsByObjectMetadataId], + ); + + return { readableObjectMetadataItems }; +}; diff --git a/packages/twenty-front/src/modules/settings/data-model/object-details/components/tabs/ObjectSettings.tsx b/packages/twenty-front/src/modules/settings/data-model/object-details/components/tabs/ObjectSettings.tsx index ecfe43f5c1..c44fa2a9e6 100644 --- a/packages/twenty-front/src/modules/settings/data-model/object-details/components/tabs/ObjectSettings.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/object-details/components/tabs/ObjectSettings.tsx @@ -3,7 +3,9 @@ import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/Enriche import { useDeleteOneObjectMetadataItem } from '@/object-metadata/hooks/useDeleteOneObjectMetadataItem'; import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem'; import { isObjectMetadataReadOnly } from '@/object-record/read-only/utils/isObjectMetadataReadOnly'; +import { AdvancedSettingsWrapper } from '@/settings/components/AdvancedSettingsWrapper'; import { SettingsUpdateDataModelObjectAboutForm } from '@/settings/data-model/object-details/components/SettingsUpdateDataModelObjectAboutForm'; +import { SettingsObjectSearchSection } from '@/settings/data-model/object-details/components/tabs/SettingsObjectSearchSection'; import { SettingsDataModelObjectSettingsFormCard } from '@/settings/data-model/objects/forms/components/SettingsDataModelObjectSettingsFormCard'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal'; @@ -114,6 +116,20 @@ export const ObjectSettings = ({ /> + + +
+ + +
+
+
{!isReadOnly && (
diff --git a/packages/twenty-front/src/modules/settings/data-model/object-details/components/tabs/SettingsObjectSearchSection.tsx b/packages/twenty-front/src/modules/settings/data-model/object-details/components/tabs/SettingsObjectSearchSection.tsx new file mode 100644 index 0000000000..3e74e06bf5 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/data-model/object-details/components/tabs/SettingsObjectSearchSection.tsx @@ -0,0 +1,194 @@ +import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem'; +import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem'; +import { SEARCH_VECTOR_FIELD_NAME } from '@/object-record/constants/SearchVectorFieldName'; +import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle'; +import { SettingsObjectFieldDataType } from '@/settings/data-model/object-details/components/SettingsObjectFieldDataType'; +import { type SettingsFieldType } from '@/settings/data-model/types/SettingsFieldType'; +import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput'; +import { Table } from '@/ui/layout/table/components/Table'; +import { TableCell } from '@/ui/layout/table/components/TableCell'; +import { TableHeader } from '@/ui/layout/table/components/TableHeader'; +import { TableRow } from '@/ui/layout/table/components/TableRow'; +import { styled } from '@linaria/react'; +import { useLingui } from '@lingui/react/macro'; +import { useContext, useMemo, useState } from 'react'; + +import { IconEye, IconSearch, useIcons } from 'twenty-ui/display'; +import { Card } from 'twenty-ui/layout'; +import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants'; + +type SettingsObjectSearchSectionProps = { + objectMetadataItem: EnrichedObjectMetadataItem; + isReadOnly: boolean; +}; + +type IndexedFieldEntry = { + id: string; + label: string; + icon?: string | null; + weight: number; + fieldType: string; +}; + +const StyledSearchSectionContent = styled.div` + display: flex; + flex-direction: column; + gap: ${themeCssVariables.spacing[4]}; +`; + +const StyledNameLabel = styled.div` + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const INDEXED_FIELDS_GRID_TEMPLATE_COLUMNS = 'minmax(0, 1fr) 100px 148px'; + +// TODO: This is very DIRTY ; let's migrate searchVector to be proper tables +// Already tracked here: https://github.com/twentyhq/core-team-issues/issues/1428 +const extractIndexedFields = ( + objectMetadataItem: EnrichedObjectMetadataItem, +): IndexedFieldEntry[] => { + const searchVectorField = objectMetadataItem.fields.find( + (field) => field.name === SEARCH_VECTOR_FIELD_NAME, + ); + + const asExpression = ( + searchVectorField?.settings as { asExpression?: string } | null + )?.asExpression; + + if (!asExpression) { + return []; + } + + const columnNames = [ + ...new Set( + Array.from(asExpression.matchAll(/"([^"]+)"/g), (match) => match[1]), + ), + ]; + + const seenFieldIds = new Set(); + const entries: IndexedFieldEntry[] = []; + + for (const columnName of columnNames) { + const field = objectMetadataItem.fields.find( + (fieldItem) => + fieldItem.name === columnName || columnName.startsWith(fieldItem.name), + ); + + if ( + field && + field.name !== SEARCH_VECTOR_FIELD_NAME && + !seenFieldIds.has(field.id) + ) { + seenFieldIds.add(field.id); + + entries.push({ + id: field.id, + label: field.label, + icon: field.icon, + weight: 1, + fieldType: field.type, + }); + } + } + + return entries; +}; + +export const SettingsObjectSearchSection = ({ + objectMetadataItem, + isReadOnly, +}: SettingsObjectSearchSectionProps) => { + const { t } = useLingui(); + const { getIcon } = useIcons(); + const { theme } = useContext(ThemeContext); + const { updateOneObjectMetadataItem } = useUpdateOneObjectMetadataItem(); + + const [isSearchable, setIsSearchable] = useState( + objectMetadataItem.isSearchable, + ); + const [searchTerm, setSearchTerm] = useState(''); + + const indexedFields = useMemo( + () => extractIndexedFields(objectMetadataItem), + [objectMetadataItem], + ); + + const filteredIndexedFields = searchTerm + ? indexedFields.filter((entry) => + entry.label.toLowerCase().includes(searchTerm.toLowerCase()), + ) + : indexedFields; + + const handleToggleSearchable = async (value: boolean) => { + setIsSearchable(value); + await updateOneObjectMetadataItem({ + idToUpdate: objectMetadataItem.id, + updatePayload: { isSearchable: value }, + }); + }; + + return ( + + {!isReadOnly && ( + + + + )} + {indexedFields.length > 0 && ( + <> + + + + {t`Name`} + {t`Weight`} + {t`Data type`} + + {filteredIndexedFields.map((entry) => { + const FieldIcon = getIcon(entry.icon); + return ( + + + + {entry.label} + + {entry.weight} + + + + + ); + })} +
+ + )} +
+ ); +}; diff --git a/packages/twenty-front/src/modules/side-panel/components/SidePanelObjectFilterDropdown.tsx b/packages/twenty-front/src/modules/side-panel/components/SidePanelObjectFilterDropdown.tsx new file mode 100644 index 0000000000..d14a053c85 --- /dev/null +++ b/packages/twenty-front/src/modules/side-panel/components/SidePanelObjectFilterDropdown.tsx @@ -0,0 +1,44 @@ +import { useLingui } from '@lingui/react/macro'; +import { isDefined } from 'twenty-shared/utils'; +import { IconFilter } from 'twenty-ui/display'; +import { IconButton } from 'twenty-ui/input'; + +import { SidePanelObjectFilterDropdownContent } from '@/side-panel/components/SidePanelObjectFilterDropdownContent'; +import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; + +export const OBJECT_FILTER_DROPDOWN_ID = 'side-panel-object-filter-dropdown'; + +type SidePanelObjectFilterDropdownProps = { + selectedObjectNameSingular: string | null; + onSelectObject: (objectNameSingular: string | null) => void; +}; + +export const SidePanelObjectFilterDropdown = ({ + selectedObjectNameSingular, + onSelectObject, +}: SidePanelObjectFilterDropdownProps) => { + const { t } = useLingui(); + const isFilterActive = isDefined(selectedObjectNameSingular); + + return ( + + } + dropdownComponents={ + + } + /> + ); +}; diff --git a/packages/twenty-front/src/modules/side-panel/components/SidePanelObjectFilterDropdownContent.tsx b/packages/twenty-front/src/modules/side-panel/components/SidePanelObjectFilterDropdownContent.tsx new file mode 100644 index 0000000000..e6cebd8407 --- /dev/null +++ b/packages/twenty-front/src/modules/side-panel/components/SidePanelObjectFilterDropdownContent.tsx @@ -0,0 +1,149 @@ +import { useLingui } from '@lingui/react/macro'; +import { useState } from 'react'; +import { OBJECTS_WITH_CHANNEL_VISIBILITY_CONSTRAINTS } from 'twenty-shared/constants'; +import { IconCube, useIcons } from 'twenty-ui/display'; +import { MenuItemSelectAvatar, MenuItemToggle } from 'twenty-ui/navigation'; + +import { NavigationMenuItemStyleIcon } from '@/navigation-menu-item/display/components/NavigationMenuItemStyleIcon'; +import { useReadableObjectMetadataItems } from '@/object-metadata/hooks/useReadableObjectMetadataItems'; +import { getObjectColorWithFallback } from '@/object-metadata/utils/getObjectColorWithFallback'; +import { OBJECT_FILTER_DROPDOWN_ID } from '@/side-panel/components/SidePanelObjectFilterDropdown'; +import { sidePanelShowHiddenObjectsState } from '@/side-panel/states/sidePanelShowHiddenObjectsState'; +import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; +import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput'; +import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; +import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; +import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList'; +import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem'; +import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState'; +import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; +import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState'; + +const ALL_OBJECTS_ITEM_ID = 'all-objects'; + +type SidePanelObjectFilterDropdownContentProps = { + selectedObjectNameSingular: string | null; + onSelectObject: (objectNameSingular: string | null) => void; +}; + +export const SidePanelObjectFilterDropdownContent = ({ + selectedObjectNameSingular, + onSelectObject, +}: SidePanelObjectFilterDropdownContentProps) => { + const { t } = useLingui(); + const { getIcon } = useIcons(); + const [filterSearch, setFilterSearch] = useState(''); + const [sidePanelShowHiddenObjects, setSidePanelShowHiddenObjects] = + useAtomState(sidePanelShowHiddenObjectsState); + const { readableObjectMetadataItems } = useReadableObjectMetadataItems(); + const { closeDropdown } = useCloseDropdown(); + + const searchFilter = filterSearch.toLowerCase(); + + const displayedObjects = readableObjectMetadataItems.filter((item) => { + if ( + OBJECTS_WITH_CHANNEL_VISIBILITY_CONSTRAINTS.includes( + item.nameSingular as (typeof OBJECTS_WITH_CHANNEL_VISIBILITY_CONSTRAINTS)[number], + ) + ) { + return false; + } + + if (!sidePanelShowHiddenObjects && !item.isSearchable) { + return false; + } + + return item.labelPlural.toLowerCase().includes(searchFilter); + }); + + const handleSelect = (objectNameSingular: string | null) => { + onSelectObject(objectNameSingular); + closeDropdown(OBJECT_FILTER_DROPDOWN_ID); + }; + + const selectableItemIdArray = [ + ALL_OBJECTS_ITEM_ID, + ...displayedObjects.map((item) => item.nameSingular), + ]; + + const selectedItemId = useAtomComponentStateValue( + selectedItemIdComponentState, + OBJECT_FILTER_DROPDOWN_ID, + ); + + return ( + + {t`Object`} + setFilterSearch(event.target.value)} + autoFocus + /> + + + + handleSelect(null)} + > + + } + text={t`All objects`} + selected={selectedObjectNameSingular === null} + onClick={() => handleSelect(null)} + focused={selectedItemId === ALL_OBJECTS_ITEM_ID} + /> + + {displayedObjects.map((objectMetadataItem) => { + const ObjectIcon = getIcon(objectMetadataItem.icon); + const iconColor = getObjectColorWithFallback(objectMetadataItem); + + return ( + handleSelect(objectMetadataItem.nameSingular)} + > + + } + text={objectMetadataItem.labelPlural} + selected={ + selectedObjectNameSingular === + objectMetadataItem.nameSingular + } + onClick={() => handleSelect(objectMetadataItem.nameSingular)} + focused={selectedItemId === objectMetadataItem.nameSingular} + /> + + ); + })} + + + + + + setSidePanelShowHiddenObjects(!sidePanelShowHiddenObjects) + } + toggled={sidePanelShowHiddenObjects} + text={t`Show hidden objects`} + toggleSize="small" + /> + + + ); +}; diff --git a/packages/twenty-front/src/modules/side-panel/components/SidePanelSubViewWithSearch.tsx b/packages/twenty-front/src/modules/side-panel/components/SidePanelSubViewWithSearch.tsx index e9ac2e6408..45ed832ff4 100644 --- a/packages/twenty-front/src/modules/side-panel/components/SidePanelSubViewWithSearch.tsx +++ b/packages/twenty-front/src/modules/side-panel/components/SidePanelSubViewWithSearch.tsx @@ -53,6 +53,7 @@ type SidePanelSubViewWithSearchProps = { searchValue: string; onSearchChange: (value: string) => void; searchInputProps?: React.InputHTMLAttributes; + rightElement?: ReactNode; children?: ReactNode; }; @@ -61,6 +62,7 @@ export const SidePanelSubViewWithSearch = ({ searchValue, onSearchChange, searchInputProps, + rightElement, children, }: SidePanelSubViewWithSearchProps) => ( @@ -73,6 +75,7 @@ export const SidePanelSubViewWithSearch = ({ // oxlint-disable-next-line react/jsx-props-no-spreading {...searchInputProps} /> + {rightElement} {children != null && ( {children} diff --git a/packages/twenty-front/src/modules/side-panel/components/SidePanelTopBarRightCornerIcon.tsx b/packages/twenty-front/src/modules/side-panel/components/SidePanelTopBarRightCornerIcon.tsx index 09256089ba..3e7c1a36de 100644 --- a/packages/twenty-front/src/modules/side-panel/components/SidePanelTopBarRightCornerIcon.tsx +++ b/packages/twenty-front/src/modules/side-panel/components/SidePanelTopBarRightCornerIcon.tsx @@ -1,16 +1,17 @@ -import { useOpenAskAIPageInSidePanel } from '@/side-panel/hooks/useOpenAskAIPageInSidePanel'; +import { useSwitchToNewAIChat } from '@/ai/hooks/useSwitchToNewAIChat'; +import { SidePanelObjectFilterDropdown } from '@/side-panel/components/SidePanelObjectFilterDropdown'; import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState'; +import { sidePanelSearchObjectFilterState } from '@/side-panel/states/sidePanelSearchObjectFilterState'; +import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { styled } from '@linaria/react'; import { t } from '@lingui/core/macro'; import { SidePanelPages } from 'twenty-shared/types'; -import { IconEdit, IconSparkles } from 'twenty-ui/display'; +import { IconEdit } from 'twenty-ui/display'; import { IconButton } from 'twenty-ui/input'; import { useIsMobile } from 'twenty-ui/utilities'; import { FeatureFlagKey } from '~/generated-metadata/graphql'; - -import { useSwitchToNewAIChat } from '@/ai/hooks/useSwitchToNewAIChat'; import { themeCssVariables } from 'twenty-ui/theme-constants'; const StyledIconButtonContainer = styled.div` @@ -21,11 +22,19 @@ export const SidePanelTopBarRightCornerIcon = () => { const isMobile = useIsMobile(); const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED); const sidePanelPage = useAtomStateValue(sidePanelPageState); - const { openAskAIPage } = useOpenAskAIPageInSidePanel(); const { switchToNewChat } = useSwitchToNewAIChat(); + const [sidePanelSearchObjectFilter, setSidePanelSearchObjectFilter] = + useAtomState(sidePanelSearchObjectFilterState); - if (isMobile || !isAiEnabled) { - return null; + const isOnSearchPage = sidePanelPage === SidePanelPages.SearchRecords; + + if (isOnSearchPage) { + return ( + + ); } const isOnAskAIPage = [ @@ -33,17 +42,8 @@ export const SidePanelTopBarRightCornerIcon = () => { SidePanelPages.ViewPreviousAIChats, ].includes(sidePanelPage); - if (!isOnAskAIPage) { - return ( - - openAskAIPage({ resetNavigationStack: false })} - Icon={IconSparkles} - variant="tertiary" - size="small" - /> - - ); + if (isMobile || !isAiEnabled || !isOnAskAIPage) { + return null; } return ( diff --git a/packages/twenty-front/src/modules/side-panel/hooks/useSidePanelCloseAnimationCompleteCleanup.ts b/packages/twenty-front/src/modules/side-panel/hooks/useSidePanelCloseAnimationCompleteCleanup.ts index c6660de1c1..54ef3aae67 100644 --- a/packages/twenty-front/src/modules/side-panel/hooks/useSidePanelCloseAnimationCompleteCleanup.ts +++ b/packages/twenty-front/src/modules/side-panel/hooks/useSidePanelCloseAnimationCompleteCleanup.ts @@ -7,7 +7,9 @@ import { sidePanelNavigationMorphItemsByPageState } from '@/side-panel/states/si import { sidePanelNavigationStackState } from '@/side-panel/states/sidePanelNavigationStackState'; import { sidePanelPageInfoState } from '@/side-panel/states/sidePanelPageInfoState'; import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState'; +import { sidePanelSearchObjectFilterState } from '@/side-panel/states/sidePanelSearchObjectFilterState'; import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState'; +import { sidePanelShowHiddenObjectsState } from '@/side-panel/states/sidePanelShowHiddenObjectsState'; import { hasUserSelectedSidePanelListItemState } from '@/side-panel/states/hasUserSelectedSidePanelListItemState'; import { isSidePanelClosingState } from '@/side-panel/states/isSidePanelClosingState'; import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState'; @@ -104,6 +106,8 @@ export const useSidePanelCloseAnimationCompleteCleanup = () => { }); store.set(isSidePanelOpenedState.atom, false); store.set(sidePanelSearchState.atom, ''); + store.set(sidePanelSearchObjectFilterState.atom, null); + store.set(sidePanelShowHiddenObjectsState.atom, false); store.set(sidePanelNavigationMorphItemsByPageState.atom, new Map()); store.set(sidePanelNavigationStackState.atom, []); resetSelectedItem(); diff --git a/packages/twenty-front/src/modules/side-panel/hooks/useSidePanelMenu.ts b/packages/twenty-front/src/modules/side-panel/hooks/useSidePanelMenu.ts index 17be98a3e9..f2e3bce0b7 100644 --- a/packages/twenty-front/src/modules/side-panel/hooks/useSidePanelMenu.ts +++ b/packages/twenty-front/src/modules/side-panel/hooks/useSidePanelMenu.ts @@ -5,6 +5,7 @@ import { SIDE_PANEL_FOCUS_ID } from '@/side-panel/constants/SidePanelFocusId'; import { useNavigateSidePanel } from '@/side-panel/hooks/useNavigateSidePanel'; import { isSidePanelClosingState } from '@/side-panel/states/isSidePanelClosingState'; import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState'; +import { sidePanelSearchObjectFilterState } from '@/side-panel/states/sidePanelSearchObjectFilterState'; import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState'; import { useCloseAnyOpenDropdown } from '@/ui/layout/dropdown/hooks/useCloseAnyOpenDropdown'; import { emitSidePanelOpenEvent } from '@/ui/layout/side-panel/utils/emitSidePanelOpenEvent'; @@ -80,6 +81,7 @@ export const useSidePanelMenu = () => { const isSidePanelOpened = store.get(isSidePanelOpenedState.atom); store.set(sidePanelSearchState.atom, ''); + store.set(sidePanelSearchObjectFilterState.atom, null); if (isSidePanelOpened) { closeSidePanelMenu(); diff --git a/packages/twenty-front/src/modules/side-panel/pages/search/hooks/useSidePanelSearchRecords.tsx b/packages/twenty-front/src/modules/side-panel/pages/search/hooks/useSidePanelSearchRecords.tsx index b5a5d87bca..798c8d1aae 100644 --- a/packages/twenty-front/src/modules/side-panel/pages/search/hooks/useSidePanelSearchRecords.tsx +++ b/packages/twenty-front/src/modules/side-panel/pages/search/hooks/useSidePanelSearchRecords.tsx @@ -3,16 +3,17 @@ import { CommandLink } from '@/command-menu-item/display/components/CommandLink' import { CommandMenuItemScope } from '@/command-menu-item/types/CommandMenuItemScope'; import { CommandMenuItemType } from '@/command-menu-item/types/CommandMenuItemType'; import { MAX_SEARCH_RESULTS } from '@/command-menu/constants/MaxSearchResults'; +import { useReadableObjectMetadataItems } from '@/object-metadata/hooks/useReadableObjectMetadataItems'; import { useOpenRecordInSidePanel } from '@/side-panel/hooks/useOpenRecordInSidePanel'; +import { sidePanelSearchObjectFilterState } from '@/side-panel/states/sidePanelSearchObjectFilterState'; import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState'; +import { sidePanelShowHiddenObjectsState } from '@/side-panel/states/sidePanelShowHiddenObjectsState'; import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; -import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; import { CoreObjectNameSingular, AppPath } from 'twenty-shared/types'; -import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; -import { getObjectPermissionsFromMapByObjectMetadataId } from '@/settings/roles/role-permissions/objects-permissions/utils/getObjectPermissionsFromMapByObjectMetadataId'; import { t } from '@lingui/core/macro'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; import { useMemo } from 'react'; +import { isDefined } from 'twenty-shared/utils'; import { Avatar } from 'twenty-ui/display'; import { useDebounce } from 'use-debounce'; import { useQuery } from '@apollo/client/react'; @@ -20,34 +21,37 @@ import { SearchDocument } from '~/generated/graphql'; export const useSidePanelSearchRecords = () => { const sidePanelSearch = useAtomStateValue(sidePanelSearchState); + const sidePanelSearchObjectFilter = useAtomStateValue( + sidePanelSearchObjectFilterState, + ); + const sidePanelShowHiddenObjects = useAtomStateValue( + sidePanelShowHiddenObjectsState, + ); const coreClient = useApolloCoreClient(); const [deferredSidePanelSearch] = useDebounce(sidePanelSearch, 300); - const { objectPermissionsByObjectMetadataId } = useObjectPermissions(); - const { objectMetadataItems } = useObjectMetadataItems(); + const { readableObjectMetadataItems } = useReadableObjectMetadataItems(); - const nonReadableObjectMetadataItemsNameSingular = useMemo(() => { - return Object.values(objectMetadataItems) - .filter((objectMetadataItem) => { - const objectPermission = getObjectPermissionsFromMapByObjectMetadataId({ - objectPermissionsByObjectMetadataId, - objectMetadataId: objectMetadataItem.id, - }); + const includedObjectNameSingulars = useMemo(() => { + if (isDefined(sidePanelSearchObjectFilter)) { + return [sidePanelSearchObjectFilter]; + } - return !objectPermission?.canReadObjectRecords; - }) - .map((objectMetadataItem) => objectMetadataItem.nameSingular); - }, [objectMetadataItems, objectPermissionsByObjectMetadataId]); + return readableObjectMetadataItems + .filter((item) => sidePanelShowHiddenObjects || item.isSearchable) + .map((item) => item.nameSingular); + }, [ + readableObjectMetadataItems, + sidePanelSearchObjectFilter, + sidePanelShowHiddenObjects, + ]); const { data: searchData, loading } = useQuery(SearchDocument, { client: coreClient, variables: { searchInput: deferredSidePanelSearch ?? '', limit: MAX_SEARCH_RESULTS, - excludedObjectNameSingulars: [ - 'workspaceMember', - ...nonReadableObjectMetadataItemsNameSingular, - ], + includedObjectNameSingulars, }, }); @@ -77,7 +81,7 @@ export const useSidePanelSearchRecords = () => { ), shouldBeRegistered: () => true, description: - objectMetadataItems.find( + readableObjectMetadataItems.find( (item) => item.nameSingular === searchRecord.objectNameSingular, )?.labelSingular ?? searchRecord.objectNameSingular, }; @@ -92,7 +96,8 @@ export const useSidePanelSearchRecords = () => { component: ( { - searchRecord.objectNameSingular === 'task' + searchRecord.objectNameSingular === + CoreObjectNameSingular.Task ? openRecordInSidePanel({ recordId: searchRecord.recordId, objectNameSingular: CoreObjectNameSingular.Task, @@ -122,7 +127,7 @@ export const useSidePanelSearchRecords = () => { }; }, ); - }, [searchData, openRecordInSidePanel, objectMetadataItems]); + }, [searchData, openRecordInSidePanel, readableObjectMetadataItems]); return { loading, diff --git a/packages/twenty-front/src/modules/side-panel/states/sidePanelSearchObjectFilterState.ts b/packages/twenty-front/src/modules/side-panel/states/sidePanelSearchObjectFilterState.ts new file mode 100644 index 0000000000..990346c2bb --- /dev/null +++ b/packages/twenty-front/src/modules/side-panel/states/sidePanelSearchObjectFilterState.ts @@ -0,0 +1,6 @@ +import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; + +export const sidePanelSearchObjectFilterState = createAtomState({ + key: 'side-panel/sidePanelSearchObjectFilterState', + defaultValue: null, +}); diff --git a/packages/twenty-front/src/modules/side-panel/states/sidePanelShowHiddenObjectsState.ts b/packages/twenty-front/src/modules/side-panel/states/sidePanelShowHiddenObjectsState.ts new file mode 100644 index 0000000000..365d5e2d65 --- /dev/null +++ b/packages/twenty-front/src/modules/side-panel/states/sidePanelShowHiddenObjectsState.ts @@ -0,0 +1,6 @@ +import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; + +export const sidePanelShowHiddenObjectsState = createAtomState({ + key: 'side-panel/sidePanelShowHiddenObjectsState', + defaultValue: false, +}); diff --git a/packages/twenty-server/src/engine/core-modules/__mocks__/mockFlatObjectMetadatas.ts b/packages/twenty-server/src/engine/core-modules/__mocks__/mockFlatObjectMetadatas.ts index d16178a934..9cf7cfaaf9 100644 --- a/packages/twenty-server/src/engine/core-modules/__mocks__/mockFlatObjectMetadatas.ts +++ b/packages/twenty-server/src/engine/core-modules/__mocks__/mockFlatObjectMetadatas.ts @@ -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 = { diff --git a/packages/twenty-server/src/engine/core-modules/search/__tests__/search.service.spec.ts b/packages/twenty-server/src/engine/core-modules/search/__tests__/search.service.spec.ts index 4ecfeb719c..4aded65c77 100644 --- a/packages/twenty-server/src/engine/core-modules/search/__tests__/search.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/search/__tests__/search.service.spec.ts @@ -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', () => { diff --git a/packages/twenty-server/src/engine/core-modules/search/services/search.service.ts b/packages/twenty-server/src/engine/core-modules/search/services/search.service.ts index c561309e83..ed455fa345 100644 --- a/packages/twenty-server/src/engine/core-modules/search/services/search.service.ts +++ b/packages/twenty-server/src/engine/core-modules/search/services/search.service.ts @@ -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; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/constants/flat-object-metadata-editable-properties.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/constants/flat-object-metadata-editable-properties.constant.ts index 519e03e7cc..3e7f876d96 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/constants/flat-object-metadata-editable-properties.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/constants/flat-object-metadata-editable-properties.constant.ts @@ -7,6 +7,7 @@ export const FLAT_OBJECT_METADATA_EDITABLE_PROPERTIES = { 'icon', 'isActive', 'isLabelSyncedWithName', + 'isSearchable', 'labelPlural', 'labelSingular', 'namePlural', @@ -18,6 +19,7 @@ export const FLAT_OBJECT_METADATA_EDITABLE_PROPERTIES = { 'description', 'icon', 'isActive', + 'isSearchable', 'labelPlural', 'labelSingular', ], diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/dtos/update-object.input.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/dtos/update-object.input.ts index de34a4b666..2d08d3a715 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/dtos/update-object.input.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/dtos/update-object.input.ts @@ -76,6 +76,11 @@ export class UpdateObjectPayload { @IsOptional() @Field({ nullable: true }) isLabelSyncedWithName?: boolean; + + @IsBoolean() + @IsOptional() + @Field({ nullable: true }) + isSearchable?: boolean; } @InputType() diff --git a/packages/twenty-shared/src/constants/ObjectsWithChannelVisibilityConstraints.ts b/packages/twenty-shared/src/constants/ObjectsWithChannelVisibilityConstraints.ts new file mode 100644 index 0000000000..eec19efcb2 --- /dev/null +++ b/packages/twenty-shared/src/constants/ObjectsWithChannelVisibilityConstraints.ts @@ -0,0 +1,23 @@ +// TODO: These objects are tied to connected accounts and have channel-based visibility +// settings (MessageChannelVisibility / CalendarChannelVisibility) that control what data +// workspace members can see. The global search service does not yet enforce these visibility +// rules, so we exclude these objects from explicit search inclusion to prevent leaking +// restricted content (e.g. message subjects, calendar event titles). +// Once the search service properly joins channel tables and applies visibility filtering, +// this list can be removed. +export const OBJECTS_WITH_CHANNEL_VISIBILITY_CONSTRAINTS = [ + 'blocklist', + 'connectedAccount', + 'message', + 'messageThread', + 'messageChannel', + 'messageParticipant', + 'messageFolder', + 'messageChannelMessageAssociation', + 'messageChannelMessageAssociationMessageFolder', + 'messageThreadSubscriber', + 'calendarEvent', + 'calendarChannel', + 'calendarChannelEventAssociation', + 'calendarEventParticipant', +] as const; diff --git a/packages/twenty-shared/src/constants/index.ts b/packages/twenty-shared/src/constants/index.ts index 0597c6e3ac..3b06cd9ef7 100644 --- a/packages/twenty-shared/src/constants/index.ts +++ b/packages/twenty-shared/src/constants/index.ts @@ -32,6 +32,7 @@ export { LABEL_IDENTIFIER_FIELD_METADATA_TYPES } from './LabelIdentifierFieldMet export { MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES } from './MultiItemFieldDefaultMaxValues'; export { MULTI_ITEM_FIELD_MIN_MAX_VALUES } from './MultiItemFieldMinMaxValues'; export { MUTATION_MAX_MERGE_RECORDS } from './MutationMaxMergeRecords'; +export { OBJECTS_WITH_CHANNEL_VISIBILITY_CONSTRAINTS } from './ObjectsWithChannelVisibilityConstraints'; export { PermissionFlagType } from './PermissionFlagType'; export { PermissionsOnAllObjectRecords } from './PermissionsOnAllObjectRecords'; export { QUERY_DEFAULT_LIMIT_RECORDS } from './QueryDefaultLimitRecords';