From 63c9af54f592e871321a9830c641ba6a46b0a480 Mon Sep 17 00:00:00 2001 From: Abdul Rahman <81605929+abdulrahmancodes@users.noreply.github.com> Date: Wed, 4 Jun 2025 18:37:52 +0530 Subject: [PATCH] feat: implement TS vector search filter (#12392) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #12427 This PR introduces a comprehensive search filter system that enhances the application's data filtering capabilities. At its core, the implementation leverages a custom useSearchFilter hook that manages search state and operations, providing a consistent search experience across different components. The search functionality is optimized for performance through debounced operations (500ms) and efficient state management using Recoil. Users can trigger search through keyboard shortcuts (Ctrl/Cmd + F) or UI interactions, with the system maintaining search state persistence and providing clear visual feedback. The implementation integrates seamlessly with the existing record filtering system, view bar components, and advanced filter system, while ensuring good performance through optimized re-renders and component state isolation. https://github.com/user-attachments/assets/12936189-fba8-44b3-a30c-d8cb6d6bd514 --------- Co-authored-by: Félix Malfait Co-authored-by: Félix Malfait Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: Marie <51697796+ijreilly@users.noreply.github.com> Co-authored-by: Charles Bochet Co-authored-by: Jordan Chalupka <9794216+jordan-chalupka@users.noreply.github.com> Co-authored-by: Charles Bochet Co-authored-by: Thomas Trompette Co-authored-by: Guillim Co-authored-by: Raphaël Bosi <71827178+bosiraphael@users.noreply.github.com> Co-authored-by: jaspass04 <147055860+jaspass04@users.noreply.github.com> Co-authored-by: martmull Co-authored-by: Thomas des Francs Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions Co-authored-by: Weiko Co-authored-by: Matt Dvertola <64113801+mdvertola@users.noreply.github.com> Co-authored-by: guillim Co-authored-by: Zeroday BYTE --- ...atFieldMetadataItemsAsFilterDefinitions.ts | 6 +- .../graphql/types/RecordGqlOperationFilter.ts | 5 ++ .../ObjectFilterDropdownFilterInput.tsx | 12 +++ .../utils/getInitialFilterValue.ts | 4 +- .../hooks/useRemoveRecordFilter.ts | 42 ++++++---- .../types/FilterableFieldType.ts | 2 + .../record-filter/types/RecordFilter.ts | 9 ++- .../isMatchingTSVectorFilter.test.ts | 61 ++++++++++++++ .../checkIfShouldComputeEmptinessFilter.ts | 7 +- .../turnRecordFilterIntoGqlOperationFilter.ts | 14 ++++ .../utils/getRecordFilterOperands.ts | 12 ++- .../utils/isMatchingTSVectorFilter.ts | 28 +++++++ .../utils/isRecordMatchingFilter.ts | 11 ++- .../utils/buildRecordInputFromFilter.ts | 17 ++++ .../utils/isSystemSearchVectorField.ts | 5 ++ .../utils/sanitizeRecordInput.ts | 5 ++ .../src/modules/views/components/ViewBar.tsx | 5 +- .../components/ViewBarFilterDropdown.tsx | 6 +- ...wBarFilterDropdownAdvancedFilterButton.tsx | 49 ++++++------ .../ViewBarFilterDropdownBottomMenu.tsx | 20 +++++ .../ViewBarFilterDropdownFieldSelectMenu.tsx | 52 +++++++----- ...iewBarFilterDropdownVectorSearchButton.tsx | 80 +++++++++++++++++++ ...ViewBarFilterDropdownVectorSearchInput.tsx | 47 +++++++++++ .../components/ViewBarRecordFilterEffect.tsx | 14 +--- .../ViewBarFilterBottomMenuItemIds.ts | 4 + .../views/constants/ViewFieldConstants.ts | 1 + ...urrentViewFiltersToCurrentRecordFilters.ts | 13 +-- ...eApplyViewFiltersToCurrentRecordFilters.ts | 15 +--- .../views/hooks/useMapViewFiltersToFilters.ts | 16 ++++ .../views/hooks/useOpenVectorSearchFilter.ts | 25 ++++++ ...blesFromActiveFieldsOfViewOrDefaultView.ts | 10 +-- .../useQueryVariablesFromView.ts} | 13 ++- .../useSetEditableFilterChipDropdownStates.ts | 23 +++++- ...ectorSearchInputValueFromExistingFilter.ts | 23 ++++++ ...rSearchFieldInRecordIndexContextOrThrow.ts | 13 +++ .../hooks/useVectorSearchFilterActions.ts | 55 +++++++++++++ .../views/hooks/useVectorSearchFilterState.ts | 17 ++++ .../states/vectorSearchInputComponentState.ts | 8 ++ .../modules/views/types/ViewFilterOperand.ts | 1 + .../getFilterableFieldsWithVectorSearch.ts | 19 +++++ .../views/utils/isVectorSearchFilter.ts | 6 ++ .../views/utils/mapViewFiltersToFilters.ts | 7 +- .../utils/compute-where-condition-parts.ts | 6 +- 43 files changed, 656 insertions(+), 132 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/isMatchingTSVectorFilter.test.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingTSVectorFilter.ts create mode 100644 packages/twenty-front/src/modules/object-record/utils/isSystemSearchVectorField.ts create mode 100644 packages/twenty-front/src/modules/views/components/ViewBarFilterDropdownBottomMenu.tsx create mode 100644 packages/twenty-front/src/modules/views/components/ViewBarFilterDropdownVectorSearchButton.tsx create mode 100644 packages/twenty-front/src/modules/views/components/ViewBarFilterDropdownVectorSearchInput.tsx create mode 100644 packages/twenty-front/src/modules/views/constants/ViewBarFilterBottomMenuItemIds.ts create mode 100644 packages/twenty-front/src/modules/views/constants/ViewFieldConstants.ts create mode 100644 packages/twenty-front/src/modules/views/hooks/useMapViewFiltersToFilters.ts create mode 100644 packages/twenty-front/src/modules/views/hooks/useOpenVectorSearchFilter.ts rename packages/twenty-front/src/modules/views/{utils/getQueryVariablesFromView.ts => hooks/useQueryVariablesFromView.ts} (85%) create mode 100644 packages/twenty-front/src/modules/views/hooks/useSetVectorSearchInputValueFromExistingFilter.ts create mode 100644 packages/twenty-front/src/modules/views/hooks/useVectorSearchFieldInRecordIndexContextOrThrow.ts create mode 100644 packages/twenty-front/src/modules/views/hooks/useVectorSearchFilterActions.ts create mode 100644 packages/twenty-front/src/modules/views/hooks/useVectorSearchFilterState.ts create mode 100644 packages/twenty-front/src/modules/views/states/vectorSearchInputComponentState.ts create mode 100644 packages/twenty-front/src/modules/views/utils/getFilterableFieldsWithVectorSearch.ts create mode 100644 packages/twenty-front/src/modules/views/utils/isVectorSearchFilter.ts diff --git a/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions.ts b/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions.ts index bde0675d4..efef632d8 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions.ts @@ -1,6 +1,6 @@ import { FieldMetadataType } from '~/generated-metadata/graphql'; -import { FilterableFieldType } from '@/object-record/record-filter/types/FilterableFieldType'; +import { FilterableAndTSVectorFieldType } from '@/object-record/record-filter/types/FilterableFieldType'; import { ObjectMetadataItem } from '../types/ObjectMetadataItem'; export const getRelationObjectMetadataNameSingular = ({ @@ -13,7 +13,7 @@ export const getRelationObjectMetadataNameSingular = ({ export const getFilterTypeFromFieldType = ( fieldType: FieldMetadataType, -): FilterableFieldType => { +): FilterableAndTSVectorFieldType => { switch (fieldType) { case FieldMetadataType.DATE_TIME: return 'DATE_TIME'; @@ -49,6 +49,8 @@ export const getFilterTypeFromFieldType = ( return 'RAW_JSON'; case FieldMetadataType.BOOLEAN: return 'BOOLEAN'; + case FieldMetadataType.TS_VECTOR: + return 'TS_VECTOR'; default: return 'TEXT'; } diff --git a/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationFilter.ts b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationFilter.ts index 28c0d6b45..78ce99b03 100644 --- a/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationFilter.ts +++ b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationFilter.ts @@ -142,6 +142,10 @@ export type RichTextV2Filter = { markdown?: RichTextV2LeafFilter; }; +export type TSVectorFilter = { + search: string; +}; + export type LeafFilter = | UUIDFilter | StringFilter @@ -158,6 +162,7 @@ export type LeafFilter = | ArrayFilter | RawJsonFilter | RichTextV2Filter + | TSVectorFilter | undefined; export type AndObjectRecordFilter = { diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterInput.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterInput.tsx index 38d7dd55e..a2cf93c49 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterInput.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterInput.tsx @@ -5,6 +5,7 @@ import { ObjectFilterDropdownRatingInput } from '@/object-record/object-filter-d import { ObjectFilterDropdownRecordSelect } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownRecordSelect'; import { ObjectFilterDropdownSearchInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownSearchInput'; import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; +import { ViewBarFilterDropdownVectorSearchInput } from '@/views/components/ViewBarFilterDropdownVectorSearchInput'; import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; import { getFilterTypeFromFieldType } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions'; @@ -55,6 +56,17 @@ export const ObjectFilterDropdownFilterInput = ({ ViewFilterOperand.IsRelative, ].includes(selectedOperandInDropdown); + const isVectorSearchFilter = + selectedOperandInDropdown === ViewFilterOperand.VectorSearch; + + if (isVectorSearchFilter && isDefined(filterDropdownId)) { + return ( + + ); + } + if (!isDefined(fieldMetadataItemUsedInDropdown)) { return null; } diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getInitialFilterValue.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getInitialFilterValue.ts index 95f689b21..ab6752897 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getInitialFilterValue.ts +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getInitialFilterValue.ts @@ -1,10 +1,10 @@ -import { FilterableFieldType } from '@/object-record/record-filter/types/FilterableFieldType'; +import { FilterableAndTSVectorFieldType } from '@/object-record/record-filter/types/FilterableFieldType'; import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand'; import { z } from 'zod'; export const getInitialFilterValue = ( - newType: FilterableFieldType, + newType: FilterableAndTSVectorFieldType, newOperand: RecordFilterOperand, oldValue?: string, oldDisplayValue?: string, diff --git a/packages/twenty-front/src/modules/object-record/record-filter/hooks/useRemoveRecordFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/hooks/useRemoveRecordFilter.ts index 0c0d20798..3ac0876c4 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/hooks/useRemoveRecordFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/hooks/useRemoveRecordFilter.ts @@ -1,7 +1,11 @@ import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; +import { VIEW_BAR_FILTER_DROPDOWN_ID } from '@/views/constants/ViewBarFilterDropdownId'; +import { vectorSearchInputComponentState } from '@/views/states/vectorSearchInputComponentState'; +import { isVectorSearchFilter } from '@/views/utils/isVectorSearchFilter'; import { useRecoilCallback } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; export const useRemoveRecordFilter = () => { const currentRecordFiltersCallbackState = useRecoilComponentCallbackStateV2( @@ -16,24 +20,34 @@ export const useRemoveRecordFilter = () => { currentRecordFiltersCallbackState, ); - const foundRecordFilterInCurrentRecordFilters = - currentRecordFilters.some( + const filterToRemove = currentRecordFilters.find( + (existingFilter) => existingFilter.id === recordFilterId, + ); + + if (!isDefined(filterToRemove)) { + return; + } + + if (isVectorSearchFilter(filterToRemove)) { + set( + vectorSearchInputComponentState.atomFamily({ + instanceId: VIEW_BAR_FILTER_DROPDOWN_ID, + }), + '', + ); + } + + set(currentRecordFiltersCallbackState, (currentRecordFilters) => { + const newCurrentRecordFilters = [...currentRecordFilters]; + + const indexOfFilterToRemove = newCurrentRecordFilters.findIndex( (existingFilter) => existingFilter.id === recordFilterId, ); - if (foundRecordFilterInCurrentRecordFilters) { - set(currentRecordFiltersCallbackState, (currentRecordFilters) => { - const newCurrentRecordFilters = [...currentRecordFilters]; + newCurrentRecordFilters.splice(indexOfFilterToRemove, 1); - const indexOfFilterToRemove = newCurrentRecordFilters.findIndex( - (existingFilter) => existingFilter.id === recordFilterId, - ); - - newCurrentRecordFilters.splice(indexOfFilterToRemove, 1); - - return newCurrentRecordFilters; - }); - } + return newCurrentRecordFilters; + }); }, [currentRecordFiltersCallbackState], ); diff --git a/packages/twenty-front/src/modules/object-record/record-filter/types/FilterableFieldType.ts b/packages/twenty-front/src/modules/object-record/record-filter/types/FilterableFieldType.ts index b54b72b80..2d6c2d7af 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/types/FilterableFieldType.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/types/FilterableFieldType.ts @@ -28,3 +28,5 @@ export type FilterableFieldType = PickLiteral< FieldType, FilterableFieldTypeBaseLiteral >; + +export type FilterableAndTSVectorFieldType = FilterableFieldType | 'TS_VECTOR'; diff --git a/packages/twenty-front/src/modules/object-record/record-filter/types/RecordFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/types/RecordFilter.ts index 88101fd17..93396f314 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/types/RecordFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/types/RecordFilter.ts @@ -1,4 +1,4 @@ -import { FilterableFieldType } from '@/object-record/record-filter/types/FilterableFieldType'; +import { FilterableAndTSVectorFieldType } from '@/object-record/record-filter/types/FilterableFieldType'; import { FILTER_OPERANDS_MAP } from '@/object-record/record-filter/utils/getRecordFilterOperands'; import { CompositeFieldSubFieldName } from '@/settings/data-model/types/CompositeFieldSubFieldName'; import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; @@ -8,7 +8,7 @@ export type RecordFilter = { fieldMetadataId: string; value: string; displayValue: string; - type: FilterableFieldType; + type: FilterableAndTSVectorFieldType; recordFilterGroupId?: string; displayAvatarUrl?: string; operand: ViewFilterOperand; @@ -17,5 +17,6 @@ export type RecordFilter = { subFieldName?: CompositeFieldSubFieldName | null | undefined; }; -export type RecordFilterToRecordInputOperand = - (typeof FILTER_OPERANDS_MAP)[T][number]; +export type RecordFilterToRecordInputOperand< + T extends FilterableAndTSVectorFieldType, +> = (typeof FILTER_OPERANDS_MAP)[T][number]; diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/isMatchingTSVectorFilter.test.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/isMatchingTSVectorFilter.test.ts new file mode 100644 index 000000000..c015bfc4e --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/isMatchingTSVectorFilter.test.ts @@ -0,0 +1,61 @@ +import { isMatchingTSVectorFilter } from '@/object-record/record-filter/utils/isMatchingTSVectorFilter'; + +describe('isMatchingTSVectorFilter', () => { + describe('search', () => { + it('value matches search filter', () => { + expect( + isMatchingTSVectorFilter({ + tsVectorFilter: { search: 'test' }, + value: 'test document', + }), + ).toBe(true); + }); + + it('value does not match search filter', () => { + expect( + isMatchingTSVectorFilter({ + tsVectorFilter: { search: 'missing' }, + value: 'test document', + }), + ).toBe(false); + }); + + it('search is case insensitive', () => { + expect( + isMatchingTSVectorFilter({ + tsVectorFilter: { search: 'TEST' }, + value: 'test document', + }), + ).toBe(true); + }); + + it('search matches partial words', () => { + expect( + isMatchingTSVectorFilter({ + tsVectorFilter: { search: 'doc' }, + value: 'test document', + }), + ).toBe(true); + }); + + it('search matches multiple words', () => { + expect( + isMatchingTSVectorFilter({ + tsVectorFilter: { search: 'test doc' }, + value: 'test document', + }), + ).toBe(true); + }); + }); + + describe('error handling', () => { + it('should throw error for unknown filter type', () => { + expect(() => + isMatchingTSVectorFilter({ + tsVectorFilter: { unknownFilter: 'test' } as any, + value: 'test document', + }), + ).toThrow('Unexpected value for ts_vector filter'); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/compute-record-gql-operation-filter/checkIfShouldComputeEmptinessFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/compute-record-gql-operation-filter/checkIfShouldComputeEmptinessFilter.ts index 0d06a990c..c385655c2 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/utils/compute-record-gql-operation-filter/checkIfShouldComputeEmptinessFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/compute-record-gql-operation-filter/checkIfShouldComputeEmptinessFilter.ts @@ -1,6 +1,6 @@ import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { getFilterTypeFromFieldType } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions'; -import { FilterableFieldType } from '@/object-record/record-filter/types/FilterableFieldType'; +import { FilterableAndTSVectorFieldType } from '@/object-record/record-filter/types/FilterableFieldType'; import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; import { isEmptinessOperand } from '@/object-record/record-filter/utils/isEmptinessOperand'; @@ -13,9 +13,8 @@ export const checkIfShouldComputeEmptinessFilter = ({ }) => { const isAnEmptinessOperand = isEmptinessOperand(recordFilter.operand); - const filterTypesThatHaveNoEmptinessOperand: FilterableFieldType[] = [ - 'BOOLEAN', - ]; + const filterTypesThatHaveNoEmptinessOperand: FilterableAndTSVectorFieldType[] = + ['BOOLEAN', 'TS_VECTOR']; const filterType = getFilterTypeFromFieldType( correspondingFieldMetadataItem.type, diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/compute-record-gql-operation-filter/turnRecordFilterIntoGqlOperationFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/compute-record-gql-operation-filter/turnRecordFilterIntoGqlOperationFilter.ts index 092992bea..d7993c42d 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/utils/compute-record-gql-operation-filter/turnRecordFilterIntoGqlOperationFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/compute-record-gql-operation-filter/turnRecordFilterIntoGqlOperationFilter.ts @@ -16,6 +16,7 @@ import { RelationFilter, SelectFilter, StringFilter, + TSVectorFilter, } from '@/object-record/graphql/types/RecordGqlOperationFilter'; import { Field } from '~/generated/graphql'; import { generateILikeFiltersForCompositeFields } from '~/utils/array/generateILikeFiltersForCompositeFields'; @@ -118,6 +119,19 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({ `Unknown operand ${recordFilter.operand} for ${filterType} filter`, ); } + case 'TS_VECTOR': + switch (recordFilter.operand) { + case RecordFilterOperand.VectorSearch: + return { + [correspondingFieldMetadataItem.name]: { + search: recordFilter.value, + } as TSVectorFilter, + }; + default: + throw new Error( + `Unknown operand ${recordFilter.operand} for ${filterType} filter`, + ); + } case 'RAW_JSON': switch (recordFilter.operand) { case RecordFilterOperand.Contains: diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/getRecordFilterOperands.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/getRecordFilterOperands.ts index 828e68462..c3d83330c 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/utils/getRecordFilterOperands.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/getRecordFilterOperands.ts @@ -1,13 +1,16 @@ import { isExpectedSubFieldName } from '@/object-record/object-filter-dropdown/utils/isExpectedSubFieldName'; import { isFilterOnActorSourceSubField } from '@/object-record/object-filter-dropdown/utils/isFilterOnActorSourceSubField'; -import { FilterableFieldType } from '@/object-record/record-filter/types/FilterableFieldType'; +import { + FilterableAndTSVectorFieldType, + FilterableFieldType, +} from '@/object-record/record-filter/types/FilterableFieldType'; import { CompositeFieldSubFieldName } from '@/settings/data-model/types/CompositeFieldSubFieldName'; import { ViewFilterOperand as RecordFilterOperand } from '@/views/types/ViewFilterOperand'; import { FieldMetadataType } from 'twenty-shared/types'; import { assertUnreachable } from 'twenty-shared/utils'; export type GetRecordFilterOperandsParams = { - filterType: FilterableFieldType; + filterType: FilterableAndTSVectorFieldType; subFieldName?: string | null | undefined; }; @@ -22,7 +25,7 @@ const relationOperands = [ ] as const; type FilterOperandMap = { - [K in FilterableFieldType]: readonly RecordFilterOperand[]; + [K in FilterableAndTSVectorFieldType]: readonly RecordFilterOperand[]; }; // TODO: we would need to refactor the typing of SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS first @@ -124,6 +127,7 @@ export const FILTER_OPERANDS_MAP = { ...emptyOperands, ], BOOLEAN: [RecordFilterOperand.Is], + TS_VECTOR: [RecordFilterOperand.VectorSearch], } as const satisfies FilterOperandMap; export const COMPOSITE_FIELD_FILTER_OPERANDS_MAP = { @@ -198,6 +202,8 @@ export const getRecordFilterOperands = ({ return FILTER_OPERANDS_MAP.ARRAY; case 'BOOLEAN': return FILTER_OPERANDS_MAP.BOOLEAN; + case 'TS_VECTOR': + return FILTER_OPERANDS_MAP.TS_VECTOR; default: assertUnreachable(filterType, `Unknown filter type ${filterType}`); } diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingTSVectorFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingTSVectorFilter.ts new file mode 100644 index 000000000..0e0a67e66 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingTSVectorFilter.ts @@ -0,0 +1,28 @@ +import { TSVectorFilter } from '@/object-record/graphql/types/RecordGqlOperationFilter'; + +export const isMatchingTSVectorFilter = ({ + tsVectorFilter, + value, +}: { + tsVectorFilter: TSVectorFilter; + value: string | undefined; +}) => { + // For optimistic updates where value is undefined, skip filtering + if (value === undefined) { + return true; + } + + switch (true) { + case tsVectorFilter.search !== undefined: { + const searchQuery = tsVectorFilter.search.toLowerCase(); + const searchValue = value.toLowerCase(); + const searchWords = searchQuery.split(/\s+/).filter(Boolean); + return searchWords.every((word) => searchValue.includes(word)); + } + default: { + throw new Error( + `Unexpected value for ts_vector filter : ${JSON.stringify(tsVectorFilter)}`, + ); + } + } +}; diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts index c0234e771..e8c2f0e3c 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts @@ -24,6 +24,7 @@ import { RichTextV2Filter, SelectFilter, StringFilter, + TSVectorFilter, UUIDFilter, } from '@/object-record/graphql/types/RecordGqlOperationFilter'; import { isMatchingArrayFilter } from '@/object-record/record-filter/utils/isMatchingArrayFilter'; @@ -37,6 +38,7 @@ import { isMatchingRawJsonFilter } from '@/object-record/record-filter/utils/isM import { isMatchingRichTextV2Filter } from '@/object-record/record-filter/utils/isMatchingRichTextV2Filter'; import { isMatchingSelectFilter } from '@/object-record/record-filter/utils/isMatchingSelectFilter'; import { isMatchingStringFilter } from '@/object-record/record-filter/utils/isMatchingStringFilter'; +import { isMatchingTSVectorFilter } from '@/object-record/record-filter/utils/isMatchingTSVectorFilter'; import { isMatchingUUIDFilter } from '@/object-record/record-filter/utils/isMatchingUUIDFilter'; import { isDefined } from 'twenty-shared/utils'; import { FieldMetadataType } from '~/generated-metadata/graphql'; @@ -374,10 +376,15 @@ export const isRecordMatchingFilter = ({ } throw new Error( - `Not implemented yet, use UUID filter instead on the corredponding "${filterKey}Id" field`, + `Not implemented yet, use UUID filter instead on the corresponding "${filterKey}Id" field`, ); } - + case FieldMetadataType.TS_VECTOR: { + return isMatchingTSVectorFilter({ + tsVectorFilter: filterValue as TSVectorFilter, + value: record[filterKey], + }); + } default: { throw new Error( `Not implemented yet for field type "${objectMetadataField.type}"`, diff --git a/packages/twenty-front/src/modules/object-record/record-table/utils/buildRecordInputFromFilter.ts b/packages/twenty-front/src/modules/object-record/record-table/utils/buildRecordInputFromFilter.ts index ed8022073..2ec4fd786 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/utils/buildRecordInputFromFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/utils/buildRecordInputFromFilter.ts @@ -66,6 +66,11 @@ export const buildValueFromFilter = ({ filter.operand as (typeof FILTER_OPERANDS_MAP)['BOOLEAN'][number], filter.value, ); + case 'TS_VECTOR': + return computeValueFromFilterTSVector( + filter.operand as (typeof FILTER_OPERANDS_MAP)['TS_VECTOR'][number], + filter.value, + ); case 'ARRAY': return computeValueFromFilterArray( filter.operand as (typeof FILTER_OPERANDS_MAP)['ARRAY'][number], @@ -296,3 +301,15 @@ const computeValueFromFilterRelation = ( assertUnreachable(operand); } }; + +const computeValueFromFilterTSVector = ( + operand: RecordFilterToRecordInputOperand<'TS_VECTOR'>, + value: string, +) => { + switch (operand) { + case ViewFilterOperand.VectorSearch: + return value; + default: + assertUnreachable(operand); + } +}; diff --git a/packages/twenty-front/src/modules/object-record/utils/isSystemSearchVectorField.ts b/packages/twenty-front/src/modules/object-record/utils/isSystemSearchVectorField.ts new file mode 100644 index 000000000..48c58f987 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/isSystemSearchVectorField.ts @@ -0,0 +1,5 @@ +import { SEARCH_VECTOR_FIELD_NAME } from '@/views/constants/ViewFieldConstants'; + +export const isSystemSearchVectorField = (fieldName: string): boolean => { + return fieldName === SEARCH_VECTOR_FIELD_NAME; +}; diff --git a/packages/twenty-front/src/modules/object-record/utils/sanitizeRecordInput.ts b/packages/twenty-front/src/modules/object-record/utils/sanitizeRecordInput.ts index 7201c1ebc..79d711eaf 100644 --- a/packages/twenty-front/src/modules/object-record/utils/sanitizeRecordInput.ts +++ b/packages/twenty-front/src/modules/object-record/utils/sanitizeRecordInput.ts @@ -1,5 +1,6 @@ import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { isSystemSearchVectorField } from '@/object-record/utils/isSystemSearchVectorField'; import { isDefined } from 'twenty-shared/utils'; import { RelationDefinitionType } from '~/generated-metadata/graphql'; import { FieldMetadataType } from '~/generated/graphql'; @@ -14,6 +15,10 @@ export const sanitizeRecordInput = ({ const filteredResultRecord = Object.fromEntries( Object.entries(recordInput) .map<[string, unknown] | undefined>(([fieldName, fieldValue]) => { + if (isSystemSearchVectorField(fieldName)) { + return undefined; + } + const fieldMetadataItem = objectMetadataItem.fields.find( (field) => field.name === fieldName, ); diff --git a/packages/twenty-front/src/modules/views/components/ViewBar.tsx b/packages/twenty-front/src/modules/views/components/ViewBar.tsx index 1517757f6..494d8ae31 100644 --- a/packages/twenty-front/src/modules/views/components/ViewBar.tsx +++ b/packages/twenty-front/src/modules/views/components/ViewBar.tsx @@ -2,14 +2,12 @@ import { ReactNode } from 'react'; import { useParams } from 'react-router-dom'; import { ObjectSortDropdownButton } from '@/object-record/object-sort-dropdown/components/ObjectSortDropdownButton'; - import { useIsPrefetchLoading } from '@/prefetch/hooks/useIsPrefetchLoading'; import { TopBar } from '@/ui/layout/top-bar/components/TopBar'; import { QueryParamsFiltersEffect } from '@/views/components/QueryParamsFiltersEffect'; import { ViewBarPageTitle } from '@/views/components/ViewBarPageTitle'; import { ViewBarSkeletonLoader } from '@/views/components/ViewBarSkeletonLoader'; import { ViewPickerDropdown } from '@/views/view-picker/components/ViewPickerDropdown'; - import { ViewsHotkeyScope } from '../types/ViewsHotkeyScope'; import { ObjectFilterDropdownComponentInstanceContext } from '@/object-record/object-filter-dropdown/states/contexts/ObjectFilterDropdownComponentInstanceContext'; @@ -24,7 +22,7 @@ import { VIEW_BAR_FILTER_DROPDOWN_ID } from '@/views/constants/ViewBarFilterDrop import { UpdateViewButtonGroup } from './UpdateViewButtonGroup'; import { ViewBarDetails } from './ViewBarDetails'; -export type ViewBarProps = { +type ViewBarProps = { viewBarId: string; className?: string; optionsDropdownButton: ReactNode; @@ -36,7 +34,6 @@ export const ViewBar = ({ optionsDropdownButton, }: ViewBarProps) => { const { objectNamePlural } = useParams(); - const loading = useIsPrefetchLoading(); if (!objectNamePlural) { diff --git a/packages/twenty-front/src/modules/views/components/ViewBarFilterDropdown.tsx b/packages/twenty-front/src/modules/views/components/ViewBarFilterDropdown.tsx index 6c0d1d3a1..0ebb66515 100644 --- a/packages/twenty-front/src/modules/views/components/ViewBarFilterDropdown.tsx +++ b/packages/twenty-front/src/modules/views/components/ViewBarFilterDropdown.tsx @@ -2,6 +2,7 @@ import { useResetFilterDropdown } from '@/object-record/object-filter-dropdown/h import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope'; import { VIEW_BAR_FILTER_DROPDOWN_ID } from '@/views/constants/ViewBarFilterDropdownId'; +import { useVectorSearchFilterActions } from '@/views/hooks/useVectorSearchFilterActions'; import { OPERAND_DROPDOWN_CLICK_OUTSIDE_ID } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownOperandDropdown'; import { objectFilterDropdownCurrentRecordFilterComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownCurrentRecordFilterComponentState'; @@ -20,7 +21,7 @@ export const ViewBarFilterDropdown = ({ hotkeyScope, }: ViewBarFilterDropdownProps) => { const { resetFilterDropdown } = useResetFilterDropdown(); - + const { removeEmptyVectorSearchFilter } = useVectorSearchFilterActions(); const { removeRecordFilter } = useRemoveRecordFilter(); const objectFilterDropdownCurrentRecordFilter = useRecoilComponentValueV2( @@ -37,10 +38,13 @@ export const ViewBarFilterDropdown = ({ recordFilterId: objectFilterDropdownCurrentRecordFilter.id, }); } + + removeEmptyVectorSearchFilter(); }; const handleDropdownClose = () => { resetFilterDropdown(); + removeEmptyVectorSearchFilter(); }; return ( diff --git a/packages/twenty-front/src/modules/views/components/ViewBarFilterDropdownAdvancedFilterButton.tsx b/packages/twenty-front/src/modules/views/components/ViewBarFilterDropdownAdvancedFilterButton.tsx index e41b90410..bfef615e8 100644 --- a/packages/twenty-front/src/modules/views/components/ViewBarFilterDropdownAdvancedFilterButton.tsx +++ b/packages/twenty-front/src/modules/views/components/ViewBarFilterDropdownAdvancedFilterButton.tsx @@ -3,6 +3,10 @@ import { availableFieldMetadataItemsForFilterFamilySelector } from '@/object-met import { useUpsertRecordFilterGroup } from '@/object-record/record-filter-group/hooks/useUpsertRecordFilterGroup'; import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState'; import { useUpsertRecordFilter } from '@/object-record/record-filter/hooks/useUpsertRecordFilter'; +import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem'; +import { isSelectedItemIdComponentFamilySelector } from '@/ui/layout/selectable-list/states/selectors/isSelectedItemIdComponentFamilySelector'; +import { useRecoilComponentFamilyValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValueV2'; +import { VIEW_BAR_FILTER_BOTTOM_MENU_ITEM_IDS } from '@/views/constants/ViewBarFilterBottomMenuItemIds'; import { VIEW_BAR_FILTER_DROPDOWN_ID } from '@/views/constants/ViewBarFilterDropdownId'; import { useSetRecordFilterUsedInAdvancedFilterDropdownRow } from '@/object-record/advanced-filter/hooks/useSetRecordFilterUsedInAdvancedFilterDropdownRow'; @@ -18,24 +22,10 @@ import { useRecoilValue } from 'recoil'; import { isDefined } from 'twenty-shared/utils'; import { Pill } from 'twenty-ui/components'; import { IconFilter } from 'twenty-ui/display'; -import { MenuItemLeftContent, StyledMenuItemBase } from 'twenty-ui/navigation'; +import { MenuItem } from 'twenty-ui/navigation'; import { v4 } from 'uuid'; -export const StyledContainer = styled.div` - align-items: center; - display: flex; - justify-content: space-between; - padding: ${({ theme }) => theme.spacing(1)}; - border-top: 1px solid ${({ theme }) => theme.border.color.light}; -`; - -export const StyledMenuItemSelect = styled(StyledMenuItemBase)` - &:hover { - background: ${({ theme }) => theme.background.transparent.light}; - } -`; - -export const StyledPill = styled(Pill)` +const StyledPill = styled(Pill)` background: ${({ theme }) => theme.color.blueAccent10}; color: ${({ theme }) => theme.color.blue}; `; @@ -45,6 +35,11 @@ export const ViewBarFilterDropdownAdvancedFilterButton = () => { const { t } = useLingui(); + const isSelected = useRecoilComponentFamilyValueV2( + isSelectedItemIdComponentFamilySelector, + VIEW_BAR_FILTER_BOTTOM_MENU_ITEM_IDS.ADVANCED_FILTER, + ); + const { openDropdown: openAdvancedFilterDropdown } = useDropdown( ADVANCED_FILTER_DROPDOWN_ID, ); @@ -130,13 +125,19 @@ export const ViewBarFilterDropdownAdvancedFilterButton = () => { }; return ( - - - - {advancedFilterQuerySubFilterCount > 0 && ( - - )} - - + + + {advancedFilterQuerySubFilterCount > 0 && ( + + )} + ); }; diff --git a/packages/twenty-front/src/modules/views/components/ViewBarFilterDropdownBottomMenu.tsx b/packages/twenty-front/src/modules/views/components/ViewBarFilterDropdownBottomMenu.tsx new file mode 100644 index 000000000..d9ca5a3e4 --- /dev/null +++ b/packages/twenty-front/src/modules/views/components/ViewBarFilterDropdownBottomMenu.tsx @@ -0,0 +1,20 @@ +import { ViewBarFilterDropdownAdvancedFilterButton } from '@/views/components/ViewBarFilterDropdownAdvancedFilterButton'; +import { ViewBarFilterDropdownVectorSearchButton } from '@/views/components/ViewBarFilterDropdownVectorSearchButton'; +import styled from '@emotion/styled'; + +const StyledContainer = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-between; + padding: ${({ theme }) => theme.spacing(1)}; +`; + +export const ViewBarFilterDropdownBottomMenu = () => { + return ( + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/views/components/ViewBarFilterDropdownFieldSelectMenu.tsx b/packages/twenty-front/src/modules/views/components/ViewBarFilterDropdownFieldSelectMenu.tsx index fad4f0476..c3e2980cb 100644 --- a/packages/twenty-front/src/modules/views/components/ViewBarFilterDropdownFieldSelectMenu.tsx +++ b/packages/twenty-front/src/modules/views/components/ViewBarFilterDropdownFieldSelectMenu.tsx @@ -1,4 +1,5 @@ import styled from '@emotion/styled'; +import React from 'react'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; @@ -12,8 +13,10 @@ import { useFilterDropdownSelectableFieldMetadataItems } from '@/object-record/o import { FiltersHotkeyScope } from '@/object-record/object-filter-dropdown/types/FiltersHotkeyScope'; import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2'; -import { ViewBarFilterDropdownAdvancedFilterButton } from '@/views/components/ViewBarFilterDropdownAdvancedFilterButton'; +import { ViewBarFilterDropdownBottomMenu } from '@/views/components/ViewBarFilterDropdownBottomMenu'; import { ViewBarFilterDropdownFieldSelectMenuItem } from '@/views/components/ViewBarFilterDropdownFieldSelectMenuItem'; + +import { VIEW_BAR_FILTER_BOTTOM_MENU_ITEM_IDS } from '@/views/constants/ViewBarFilterBottomMenuItemIds'; import { useLingui } from '@lingui/react/macro'; export const StyledInput = styled.input` @@ -58,12 +61,18 @@ export const ViewBarFilterDropdownFieldSelectMenu = () => { ...selectableHiddenFieldMetadataItems.map( (fieldMetadataItem) => fieldMetadataItem.id, ), + VIEW_BAR_FILTER_BOTTOM_MENU_ITEM_IDS.SEARCH, + VIEW_BAR_FILTER_BOTTOM_MENU_ITEM_IDS.ADVANCED_FILTER, ]; const shouldShowSeparator = selectableVisibleFieldMetadataItems.length > 0 && selectableHiddenFieldMetadataItems.length > 0; + const hasSelectableItems = + selectableVisibleFieldMetadataItems.length > 0 || + selectableHiddenFieldMetadataItems.length > 0; + const { t } = useLingui(); return ( @@ -81,25 +90,30 @@ export const ViewBarFilterDropdownFieldSelectMenu = () => { selectableItemIdArray={selectableFieldMetadataItemIds} selectableListInstanceId={FILTER_FIELD_LIST_ID} > - - {selectableVisibleFieldMetadataItems.map( - (visibleFieldMetadataItem) => ( - - ), - )} - {shouldShowSeparator && } - {selectableHiddenFieldMetadataItems.map((hiddenFieldMetadataItem) => ( - - ))} - + {hasSelectableItems && ( + + {selectableVisibleFieldMetadataItems.map( + (visibleFieldMetadataItem) => ( + + ), + )} + {shouldShowSeparator && } + {selectableHiddenFieldMetadataItems.map( + (hiddenFieldMetadataItem) => ( + + ), + )} + + )} + {hasSelectableItems && } + - ); }; diff --git a/packages/twenty-front/src/modules/views/components/ViewBarFilterDropdownVectorSearchButton.tsx b/packages/twenty-front/src/modules/views/components/ViewBarFilterDropdownVectorSearchButton.tsx new file mode 100644 index 000000000..a9c4d826e --- /dev/null +++ b/packages/twenty-front/src/modules/views/components/ViewBarFilterDropdownVectorSearchButton.tsx @@ -0,0 +1,80 @@ +import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem'; +import { isSelectedItemIdComponentFamilySelector } from '@/ui/layout/selectable-list/states/selectors/isSelectedItemIdComponentFamilySelector'; +import { useRecoilComponentFamilyValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValueV2'; +import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import styled from '@emotion/styled'; +import { useLingui } from '@lingui/react/macro'; +import { IconSearch } from 'twenty-ui/display'; +import { MenuItem } from 'twenty-ui/navigation'; + +import { VIEW_BAR_FILTER_BOTTOM_MENU_ITEM_IDS } from '@/views/constants/ViewBarFilterBottomMenuItemIds'; +import { VIEW_BAR_FILTER_DROPDOWN_ID } from '@/views/constants/ViewBarFilterDropdownId'; + +import { objectFilterDropdownSearchInputComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSearchInputComponentState'; +import { useOpenVectorSearchFilter } from '@/views/hooks/useOpenVectorSearchFilter'; +import { useSetVectorSearchInputValueFromExistingFilter } from '@/views/hooks/useSetVectorSearchInputValueFromExistingFilter'; +import { useVectorSearchFilterActions } from '@/views/hooks/useVectorSearchFilterActions'; +import { vectorSearchInputComponentState } from '@/views/states/vectorSearchInputComponentState'; + +const StyledSearchText = styled.span` + color: ${({ theme }) => theme.font.color.light}; + margin-left: ${({ theme }) => theme.spacing(1)}; +`; + +export const ViewBarFilterDropdownVectorSearchButton = () => { + const { t } = useLingui(); + const [, setVectorSearchInputValue] = useRecoilComponentStateV2( + vectorSearchInputComponentState, + VIEW_BAR_FILTER_DROPDOWN_ID, + ); + const { setVectorSearchInputValueFromExistingFilter } = + useSetVectorSearchInputValueFromExistingFilter(VIEW_BAR_FILTER_DROPDOWN_ID); + + const fieldSearchInputValue = useRecoilComponentValueV2( + objectFilterDropdownSearchInputComponentState, + VIEW_BAR_FILTER_DROPDOWN_ID, + ); + + const { applyVectorSearchFilter } = useVectorSearchFilterActions(); + const { openVectorSearchFilter } = useOpenVectorSearchFilter( + VIEW_BAR_FILTER_DROPDOWN_ID, + ); + + const isSelected = useRecoilComponentFamilyValueV2( + isSelectedItemIdComponentFamilySelector, + VIEW_BAR_FILTER_BOTTOM_MENU_ITEM_IDS.SEARCH, + ); + + const handleSearchClick = () => { + openVectorSearchFilter(); + + if (fieldSearchInputValue.length > 0) { + setVectorSearchInputValue(fieldSearchInputValue); + applyVectorSearchFilter(fieldSearchInputValue); + } else { + setVectorSearchInputValueFromExistingFilter(); + } + }; + + return ( + + + {t`Search`} + {fieldSearchInputValue && ( + {t`· ${fieldSearchInputValue}`} + )} + + } + /> + + ); +}; diff --git a/packages/twenty-front/src/modules/views/components/ViewBarFilterDropdownVectorSearchInput.tsx b/packages/twenty-front/src/modules/views/components/ViewBarFilterDropdownVectorSearchInput.tsx new file mode 100644 index 000000000..e3f89c06e --- /dev/null +++ b/packages/twenty-front/src/modules/views/components/ViewBarFilterDropdownVectorSearchInput.tsx @@ -0,0 +1,47 @@ +import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; +import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput'; +import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth'; +import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2'; +import { useVectorSearchFilterActions } from '@/views/hooks/useVectorSearchFilterActions'; +import { vectorSearchInputComponentState } from '@/views/states/vectorSearchInputComponentState'; +import { useLingui } from '@lingui/react/macro'; +import { useDebouncedCallback } from 'use-debounce'; + +export const ViewBarFilterDropdownVectorSearchInput = ({ + filterDropdownId, +}: { + filterDropdownId: string; +}) => { + const { t } = useLingui(); + const [vectorSearchInputValue, setVectorSearchInputValue] = + useRecoilComponentStateV2( + vectorSearchInputComponentState, + filterDropdownId, + ); + const { applyVectorSearchFilter } = useVectorSearchFilterActions(); + + const debouncedApplyVectorSearchFilter = useDebouncedCallback( + (value: string) => { + applyVectorSearchFilter(value); + }, + 500, + ); + + const handleSearchChange = (e: React.ChangeEvent) => { + const inputValue = e.target.value; + setVectorSearchInputValue(inputValue); + debouncedApplyVectorSearchFilter(inputValue); + }; + + return ( + + + + ); +}; diff --git a/packages/twenty-front/src/modules/views/components/ViewBarRecordFilterEffect.tsx b/packages/twenty-front/src/modules/views/components/ViewBarRecordFilterEffect.tsx index bf51b315e..89726f50f 100644 --- a/packages/twenty-front/src/modules/views/components/ViewBarRecordFilterEffect.tsx +++ b/packages/twenty-front/src/modules/views/components/ViewBarRecordFilterEffect.tsx @@ -1,13 +1,12 @@ import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState'; -import { useFilterableFieldMetadataItems } from '@/object-record/record-filter/hooks/useFilterableFieldMetadataItems'; import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; import { prefetchViewFromViewIdFamilySelector } from '@/prefetch/states/selector/prefetchViewFromViewIdFamilySelector'; import { useRecoilComponentFamilyStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyStateV2'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; +import { useMapViewFiltersToFilters } from '@/views/hooks/useMapViewFiltersToFilters'; import { hasInitializedCurrentRecordFiltersComponentFamilyState } from '@/views/states/hasInitializedCurrentRecordFiltersComponentFamilyState'; -import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters'; import { useEffect } from 'react'; import { useRecoilValue } from 'recoil'; import { isDefined } from 'twenty-shared/utils'; @@ -39,9 +38,7 @@ export const ViewBarRecordFilterEffect = () => { currentRecordFiltersComponentState, ); - const { filterableFieldMetadataItems } = useFilterableFieldMetadataItems( - objectMetadataItem.id, - ); + const { mapViewFiltersToRecordFilters } = useMapViewFiltersToFilters(); useEffect(() => { if (!hasInitializedCurrentRecordFilters && isDefined(currentView)) { @@ -50,10 +47,7 @@ export const ViewBarRecordFilterEffect = () => { } setCurrentRecordFilters( - mapViewFiltersToFilters( - currentView.viewFilters, - filterableFieldMetadataItems, - ), + mapViewFiltersToRecordFilters(currentView.viewFilters), ); setHasInitializedCurrentRecordFilters(true); @@ -61,7 +55,7 @@ export const ViewBarRecordFilterEffect = () => { }, [ currentViewId, setCurrentRecordFilters, - filterableFieldMetadataItems, + mapViewFiltersToRecordFilters, hasInitializedCurrentRecordFilters, setHasInitializedCurrentRecordFilters, currentView, diff --git a/packages/twenty-front/src/modules/views/constants/ViewBarFilterBottomMenuItemIds.ts b/packages/twenty-front/src/modules/views/constants/ViewBarFilterBottomMenuItemIds.ts new file mode 100644 index 000000000..a220807b2 --- /dev/null +++ b/packages/twenty-front/src/modules/views/constants/ViewBarFilterBottomMenuItemIds.ts @@ -0,0 +1,4 @@ +export const VIEW_BAR_FILTER_BOTTOM_MENU_ITEM_IDS = { + SEARCH: 'search-button', + ADVANCED_FILTER: 'advanced-filter-button', +}; diff --git a/packages/twenty-front/src/modules/views/constants/ViewFieldConstants.ts b/packages/twenty-front/src/modules/views/constants/ViewFieldConstants.ts new file mode 100644 index 000000000..44a7682cf --- /dev/null +++ b/packages/twenty-front/src/modules/views/constants/ViewFieldConstants.ts @@ -0,0 +1 @@ +export const SEARCH_VECTOR_FIELD_NAME = 'searchVector'; diff --git a/packages/twenty-front/src/modules/views/hooks/useApplyCurrentViewFiltersToCurrentRecordFilters.ts b/packages/twenty-front/src/modules/views/hooks/useApplyCurrentViewFiltersToCurrentRecordFilters.ts index c2477dd7c..bc585104f 100644 --- a/packages/twenty-front/src/modules/views/hooks/useApplyCurrentViewFiltersToCurrentRecordFilters.ts +++ b/packages/twenty-front/src/modules/views/hooks/useApplyCurrentViewFiltersToCurrentRecordFilters.ts @@ -1,12 +1,11 @@ import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState'; -import { useFilterableFieldMetadataItemsInRecordIndexContext } from '@/object-record/record-filter/hooks/useFilterableFieldMetadataItemsInRecordIndexContext'; import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; import { prefetchViewFromViewIdFamilySelector } from '@/prefetch/states/selector/prefetchViewFromViewIdFamilySelector'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; -import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters'; import { useRecoilCallback } from 'recoil'; import { isDefined } from 'twenty-shared/utils'; +import { useMapViewFiltersToFilters } from './useMapViewFiltersToFilters'; export const useApplyCurrentViewFiltersToCurrentRecordFilters = () => { const currentViewId = useRecoilComponentValueV2( @@ -17,8 +16,7 @@ export const useApplyCurrentViewFiltersToCurrentRecordFilters = () => { currentRecordFiltersComponentState, ); - const { filterableFieldMetadataItems } = - useFilterableFieldMetadataItemsInRecordIndexContext(); + const { mapViewFiltersToRecordFilters } = useMapViewFiltersToFilters(); const applyCurrentViewFiltersToCurrentRecordFilters = useRecoilCallback( ({ snapshot }) => @@ -33,14 +31,11 @@ export const useApplyCurrentViewFiltersToCurrentRecordFilters = () => { if (isDefined(currentView)) { setCurrentRecordFilters( - mapViewFiltersToFilters( - currentView.viewFilters, - filterableFieldMetadataItems, - ), + mapViewFiltersToRecordFilters(currentView.viewFilters), ); } }, - [currentViewId, filterableFieldMetadataItems, setCurrentRecordFilters], + [currentViewId, mapViewFiltersToRecordFilters, setCurrentRecordFilters], ); return { diff --git a/packages/twenty-front/src/modules/views/hooks/useApplyViewFiltersToCurrentRecordFilters.ts b/packages/twenty-front/src/modules/views/hooks/useApplyViewFiltersToCurrentRecordFilters.ts index 0bec3cb5e..09b6070b5 100644 --- a/packages/twenty-front/src/modules/views/hooks/useApplyViewFiltersToCurrentRecordFilters.ts +++ b/packages/twenty-front/src/modules/views/hooks/useApplyViewFiltersToCurrentRecordFilters.ts @@ -1,28 +1,19 @@ -import { useFilterableFieldMetadataItems } from '@/object-record/record-filter/hooks/useFilterableFieldMetadataItems'; import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; -import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; import { ViewFilter } from '@/views/types/ViewFilter'; -import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters'; +import { useMapViewFiltersToFilters } from './useMapViewFiltersToFilters'; export const useApplyViewFiltersToCurrentRecordFilters = () => { const setCurrentRecordFilters = useSetRecoilComponentStateV2( currentRecordFiltersComponentState, ); - const { objectMetadataItem } = useRecordIndexContextOrThrow(); - - const { filterableFieldMetadataItems } = useFilterableFieldMetadataItems( - objectMetadataItem.id, - ); + const { mapViewFiltersToRecordFilters } = useMapViewFiltersToFilters(); const applyViewFiltersToCurrentRecordFilters = ( viewFilters: ViewFilter[], ) => { - const recordFiltersToApply = mapViewFiltersToFilters( - viewFilters, - filterableFieldMetadataItems, - ); + const recordFiltersToApply = mapViewFiltersToRecordFilters(viewFilters); setCurrentRecordFilters(recordFiltersToApply); }; diff --git a/packages/twenty-front/src/modules/views/hooks/useMapViewFiltersToFilters.ts b/packages/twenty-front/src/modules/views/hooks/useMapViewFiltersToFilters.ts new file mode 100644 index 000000000..27717daf2 --- /dev/null +++ b/packages/twenty-front/src/modules/views/hooks/useMapViewFiltersToFilters.ts @@ -0,0 +1,16 @@ +import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; +import { ViewFilter } from '../types/ViewFilter'; +import { getFilterableFieldsWithVectorSearch } from '../utils/getFilterableFieldsWithVectorSearch'; +import { mapViewFiltersToFilters } from '../utils/mapViewFiltersToFilters'; + +export const useMapViewFiltersToFilters = () => { + const { objectMetadataItem } = useRecordIndexContextOrThrow(); + + const mapViewFiltersToRecordFilters = (viewFilters: ViewFilter[]) => { + const filterableFieldMetadataItems = + getFilterableFieldsWithVectorSearch(objectMetadataItem); + return mapViewFiltersToFilters(viewFilters, filterableFieldMetadataItems); + }; + + return { mapViewFiltersToRecordFilters }; +}; diff --git a/packages/twenty-front/src/modules/views/hooks/useOpenVectorSearchFilter.ts b/packages/twenty-front/src/modules/views/hooks/useOpenVectorSearchFilter.ts new file mode 100644 index 000000000..8eaff6f9a --- /dev/null +++ b/packages/twenty-front/src/modules/views/hooks/useOpenVectorSearchFilter.ts @@ -0,0 +1,25 @@ +import { objectFilterDropdownFilterIsSelectedComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownFilterIsSelectedComponentState'; +import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState'; +import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; +import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; + +export const useOpenVectorSearchFilter = (filterDropdownId?: string) => { + const setSelectedOperandInDropdown = useSetRecoilComponentStateV2( + selectedOperandInDropdownComponentState, + filterDropdownId, + ); + + const setObjectFilterDropdownFilterIsSelected = useSetRecoilComponentStateV2( + objectFilterDropdownFilterIsSelectedComponentState, + filterDropdownId, + ); + + const openVectorSearchFilter = () => { + setObjectFilterDropdownFilterIsSelected(true); + setSelectedOperandInDropdown(ViewFilterOperand.VectorSearch); + }; + + return { + openVectorSearchFilter, + }; +}; diff --git a/packages/twenty-front/src/modules/views/hooks/useQueryVariablesFromActiveFieldsOfViewOrDefaultView.ts b/packages/twenty-front/src/modules/views/hooks/useQueryVariablesFromActiveFieldsOfViewOrDefaultView.ts index 527f6b6b7..6a7d8b044 100644 --- a/packages/twenty-front/src/modules/views/hooks/useQueryVariablesFromActiveFieldsOfViewOrDefaultView.ts +++ b/packages/twenty-front/src/modules/views/hooks/useQueryVariablesFromActiveFieldsOfViewOrDefaultView.ts @@ -1,8 +1,7 @@ -import { useActiveFieldMetadataItems } from '@/object-metadata/hooks/useActiveFieldMetadataItems'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies'; import { useViewOrDefaultViewFromPrefetchedViews } from '@/views/hooks/useViewOrDefaultViewFromPrefetchedViews'; -import { getQueryVariablesFromView } from '@/views/utils/getQueryVariablesFromView'; +import { useQueryVariablesFromView } from './useQueryVariablesFromView'; export const useQueryVariablesFromActiveFieldsOfViewOrDefaultView = ({ objectMetadataItem, @@ -13,14 +12,9 @@ export const useQueryVariablesFromActiveFieldsOfViewOrDefaultView = ({ objectMetadataItemId: objectMetadataItem.id, }); - const { activeFieldMetadataItems } = useActiveFieldMetadataItems({ - objectMetadataItem, - }); - const { filterValueDependencies } = useFilterValueDependencies(); - const { filter, orderBy } = getQueryVariablesFromView({ - fieldMetadataItems: activeFieldMetadataItems, + const { filter, orderBy } = useQueryVariablesFromView({ objectMetadataItem, view, filterValueDependencies, diff --git a/packages/twenty-front/src/modules/views/utils/getQueryVariablesFromView.ts b/packages/twenty-front/src/modules/views/hooks/useQueryVariablesFromView.ts similarity index 85% rename from packages/twenty-front/src/modules/views/utils/getQueryVariablesFromView.ts rename to packages/twenty-front/src/modules/views/hooks/useQueryVariablesFromView.ts index 240bd3a59..63c2d10d3 100644 --- a/packages/twenty-front/src/modules/views/utils/getQueryVariablesFromView.ts +++ b/packages/twenty-front/src/modules/views/hooks/useQueryVariablesFromView.ts @@ -1,24 +1,20 @@ -import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; - import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy'; import { RecordFilterValueDependencies } from '@/object-record/record-filter/types/RecordFilterValueDependencies'; - import { computeRecordGqlOperationFilter } from '@/object-record/record-filter/utils/computeRecordGqlOperationFilter'; import { View } from '@/views/types/View'; +import { getFilterableFieldsWithVectorSearch } from '@/views/utils/getFilterableFieldsWithVectorSearch'; import { mapViewFilterGroupsToRecordFilterGroups } from '@/views/utils/mapViewFilterGroupsToRecordFilterGroups'; import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters'; import { mapViewSortsToSorts } from '@/views/utils/mapViewSortsToSorts'; import { isDefined } from 'twenty-shared/utils'; -export const getQueryVariablesFromView = ({ +export const useQueryVariablesFromView = ({ view, - fieldMetadataItems, objectMetadataItem, filterValueDependencies, }: { view: View | null | undefined; - fieldMetadataItems: FieldMetadataItem[]; objectMetadataItem: ObjectMetadataItem; filterValueDependencies: RecordFilterValueDependencies; }) => { @@ -35,9 +31,12 @@ export const getQueryVariablesFromView = ({ viewFilterGroups ?? [], ); + const filterableFieldMetadataItems = + getFilterableFieldsWithVectorSearch(objectMetadataItem); + const recordFilters = mapViewFiltersToFilters( viewFilters, - fieldMetadataItems, + filterableFieldMetadataItems, ); const filter = computeRecordGqlOperationFilter({ diff --git a/packages/twenty-front/src/modules/views/hooks/useSetEditableFilterChipDropdownStates.ts b/packages/twenty-front/src/modules/views/hooks/useSetEditableFilterChipDropdownStates.ts index d411d037f..6dd452c4b 100644 --- a/packages/twenty-front/src/modules/views/hooks/useSetEditableFilterChipDropdownStates.ts +++ b/packages/twenty-front/src/modules/views/hooks/useSetEditableFilterChipDropdownStates.ts @@ -4,6 +4,9 @@ import { selectedOperandInDropdownComponentState } from '@/object-record/object- import { subFieldNameUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/subFieldNameUsedInDropdownComponentState'; import { useFilterableFieldMetadataItemsInRecordIndexContext } from '@/object-record/record-filter/hooks/useFilterableFieldMetadataItemsInRecordIndexContext'; import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; +import { useVectorSearchFieldInRecordIndexContextOrThrow } from '@/views/hooks/useVectorSearchFieldInRecordIndexContextOrThrow'; +import { vectorSearchInputComponentState } from '@/views/states/vectorSearchInputComponentState'; +import { isVectorSearchFilter } from '@/views/utils/isVectorSearchFilter'; import { useRecoilCallback } from 'recoil'; import { isDefined } from 'twenty-shared/utils'; @@ -11,10 +14,17 @@ export const useSetEditableFilterChipDropdownStates = () => { const { filterableFieldMetadataItems } = useFilterableFieldMetadataItemsInRecordIndexContext(); + const { vectorSearchField } = + useVectorSearchFieldInRecordIndexContextOrThrow(); + const setEditableFilterChipDropdownStates = useRecoilCallback( ({ set }) => (recordFilter: RecordFilter) => { - const fieldMetadataItem = filterableFieldMetadataItems.find( + const filterableFieldsWithVector = vectorSearchField + ? filterableFieldMetadataItems.concat(vectorSearchField) + : filterableFieldMetadataItems; + + const fieldMetadataItem = filterableFieldsWithVector.find( (fieldMetadataItem) => fieldMetadataItem.id === recordFilter.fieldMetadataId, ); @@ -23,6 +33,15 @@ export const useSetEditableFilterChipDropdownStates = () => { return; } + if (isVectorSearchFilter(recordFilter)) { + set( + vectorSearchInputComponentState.atomFamily({ + instanceId: recordFilter.id, + }), + recordFilter.value, + ); + } + set( fieldMetadataItemIdUsedInDropdownComponentState.atomFamily({ instanceId: recordFilter.id, @@ -51,7 +70,7 @@ export const useSetEditableFilterChipDropdownStates = () => { recordFilter.subFieldName, ); }, - [filterableFieldMetadataItems], + [filterableFieldMetadataItems, vectorSearchField], ); return { diff --git a/packages/twenty-front/src/modules/views/hooks/useSetVectorSearchInputValueFromExistingFilter.ts b/packages/twenty-front/src/modules/views/hooks/useSetVectorSearchInputValueFromExistingFilter.ts new file mode 100644 index 000000000..3b01c0414 --- /dev/null +++ b/packages/twenty-front/src/modules/views/hooks/useSetVectorSearchInputValueFromExistingFilter.ts @@ -0,0 +1,23 @@ +import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2'; +import { vectorSearchInputComponentState } from '@/views/states/vectorSearchInputComponentState'; +import { isDefined } from 'twenty-shared/utils'; +import { useVectorSearchFilterState } from './useVectorSearchFilterState'; + +export const useSetVectorSearchInputValueFromExistingFilter = ( + filterDropdownId: string, +) => { + const [, setVectorSearchInputValue] = useRecoilComponentStateV2( + vectorSearchInputComponentState, + filterDropdownId, + ); + const { getExistingVectorSearchFilter } = useVectorSearchFilterState(); + + const setVectorSearchInputValueFromExistingFilter = () => { + const existingVectorSearchFilter = getExistingVectorSearchFilter(); + if (isDefined(existingVectorSearchFilter)) { + setVectorSearchInputValue(existingVectorSearchFilter.value); + } + }; + + return { setVectorSearchInputValueFromExistingFilter }; +}; diff --git a/packages/twenty-front/src/modules/views/hooks/useVectorSearchFieldInRecordIndexContextOrThrow.ts b/packages/twenty-front/src/modules/views/hooks/useVectorSearchFieldInRecordIndexContextOrThrow.ts new file mode 100644 index 000000000..7101c8d3c --- /dev/null +++ b/packages/twenty-front/src/modules/views/hooks/useVectorSearchFieldInRecordIndexContextOrThrow.ts @@ -0,0 +1,13 @@ +import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; +import { SEARCH_VECTOR_FIELD_NAME } from '../constants/ViewFieldConstants'; + +export const useVectorSearchFieldInRecordIndexContextOrThrow = () => { + const { objectMetadataItem } = useRecordIndexContextOrThrow(); + + const vectorSearchField = objectMetadataItem.fields.find( + (field) => + field.type === 'TS_VECTOR' && field.name === SEARCH_VECTOR_FIELD_NAME, + ); + + return { vectorSearchField }; +}; diff --git a/packages/twenty-front/src/modules/views/hooks/useVectorSearchFilterActions.ts b/packages/twenty-front/src/modules/views/hooks/useVectorSearchFilterActions.ts new file mode 100644 index 000000000..bebe8546c --- /dev/null +++ b/packages/twenty-front/src/modules/views/hooks/useVectorSearchFilterActions.ts @@ -0,0 +1,55 @@ +import { getFilterTypeFromFieldType } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions'; +import { useRemoveRecordFilter } from '@/object-record/record-filter/hooks/useRemoveRecordFilter'; +import { useUpsertRecordFilter } from '@/object-record/record-filter/hooks/useUpsertRecordFilter'; +import { isRecordFilterConsideredEmpty } from '@/object-record/record-filter/utils/isRecordFilterConsideredEmpty'; +import { useVectorSearchFieldInRecordIndexContextOrThrow } from '@/views/hooks/useVectorSearchFieldInRecordIndexContextOrThrow'; +import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; +import { isDefined } from 'twenty-shared/utils'; +import { v4 } from 'uuid'; +import { useVectorSearchFilterState } from './useVectorSearchFilterState'; + +export const useVectorSearchFilterActions = () => { + const { vectorSearchField } = + useVectorSearchFieldInRecordIndexContextOrThrow(); + const { getExistingVectorSearchFilter } = useVectorSearchFilterState(); + const { upsertRecordFilter } = useUpsertRecordFilter(); + const { removeRecordFilter } = useRemoveRecordFilter(); + + const applyVectorSearchFilter = (value: string) => { + if (!vectorSearchField) { + return; + } + + const existingVectorSearchFilter = getExistingVectorSearchFilter(); + + const vectorSearchRecordFilter = { + id: existingVectorSearchFilter?.id ?? v4(), + fieldMetadataId: vectorSearchField.id, + value: value, + displayValue: value, + operand: ViewFilterOperand.VectorSearch, + type: getFilterTypeFromFieldType(vectorSearchField.type), + label: 'Search', + }; + + upsertRecordFilter(vectorSearchRecordFilter); + }; + + const removeEmptyVectorSearchFilter = () => { + const vectorSearchFilter = getExistingVectorSearchFilter(); + + if ( + isDefined(vectorSearchFilter) && + isRecordFilterConsideredEmpty(vectorSearchFilter) + ) { + removeRecordFilter({ + recordFilterId: vectorSearchFilter.id, + }); + } + }; + + return { + applyVectorSearchFilter, + removeEmptyVectorSearchFilter, + }; +}; diff --git a/packages/twenty-front/src/modules/views/hooks/useVectorSearchFilterState.ts b/packages/twenty-front/src/modules/views/hooks/useVectorSearchFilterState.ts new file mode 100644 index 000000000..5924aa138 --- /dev/null +++ b/packages/twenty-front/src/modules/views/hooks/useVectorSearchFilterState.ts @@ -0,0 +1,17 @@ +import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { isVectorSearchFilter } from '@/views/utils/isVectorSearchFilter'; + +export const useVectorSearchFilterState = () => { + const currentRecordFilters = useRecoilComponentValueV2( + currentRecordFiltersComponentState, + ); + + const getExistingVectorSearchFilter = () => { + return currentRecordFilters.find(isVectorSearchFilter); + }; + + return { + getExistingVectorSearchFilter, + }; +}; diff --git a/packages/twenty-front/src/modules/views/states/vectorSearchInputComponentState.ts b/packages/twenty-front/src/modules/views/states/vectorSearchInputComponentState.ts new file mode 100644 index 000000000..29c83f674 --- /dev/null +++ b/packages/twenty-front/src/modules/views/states/vectorSearchInputComponentState.ts @@ -0,0 +1,8 @@ +import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2'; +import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext'; + +export const vectorSearchInputComponentState = createComponentStateV2({ + key: 'vectorSearchInputComponentState', + defaultValue: '', + componentInstanceContext: ViewComponentInstanceContext, +}); diff --git a/packages/twenty-front/src/modules/views/types/ViewFilterOperand.ts b/packages/twenty-front/src/modules/views/types/ViewFilterOperand.ts index 0d6446de9..7ea686348 100644 --- a/packages/twenty-front/src/modules/views/types/ViewFilterOperand.ts +++ b/packages/twenty-front/src/modules/views/types/ViewFilterOperand.ts @@ -14,4 +14,5 @@ export enum ViewFilterOperand { IsInPast = 'isInPast', IsInFuture = 'isInFuture', IsToday = 'isToday', + VectorSearch = 'search', } diff --git a/packages/twenty-front/src/modules/views/utils/getFilterableFieldsWithVectorSearch.ts b/packages/twenty-front/src/modules/views/utils/getFilterableFieldsWithVectorSearch.ts new file mode 100644 index 000000000..3a4b36167 --- /dev/null +++ b/packages/twenty-front/src/modules/views/utils/getFilterableFieldsWithVectorSearch.ts @@ -0,0 +1,19 @@ +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { getFilterFilterableFieldMetadataItems } from '@/object-metadata/utils/getFilterFilterableFieldMetadataItems'; +import { SEARCH_VECTOR_FIELD_NAME } from '../constants/ViewFieldConstants'; + +export const getFilterableFieldsWithVectorSearch = ( + objectMetadataItem: ObjectMetadataItem, +) => { + const vectorSearchField = objectMetadataItem.fields.find( + (field) => + field.type === 'TS_VECTOR' && field.name === SEARCH_VECTOR_FIELD_NAME, + ); + + return [ + ...objectMetadataItem.fields.filter( + getFilterFilterableFieldMetadataItems({ isJsonFilterEnabled: true }), + ), + ...(vectorSearchField ? [vectorSearchField] : []), + ]; +}; diff --git a/packages/twenty-front/src/modules/views/utils/isVectorSearchFilter.ts b/packages/twenty-front/src/modules/views/utils/isVectorSearchFilter.ts new file mode 100644 index 000000000..29ed05fc4 --- /dev/null +++ b/packages/twenty-front/src/modules/views/utils/isVectorSearchFilter.ts @@ -0,0 +1,6 @@ +import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; +import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; + +export const isVectorSearchFilter = (filter: RecordFilter) => { + return filter.operand === ViewFilterOperand.VectorSearch; +}; diff --git a/packages/twenty-front/src/modules/views/utils/mapViewFiltersToFilters.ts b/packages/twenty-front/src/modules/views/utils/mapViewFiltersToFilters.ts index 521991d87..c821a2713 100644 --- a/packages/twenty-front/src/modules/views/utils/mapViewFiltersToFilters.ts +++ b/packages/twenty-front/src/modules/views/utils/mapViewFiltersToFilters.ts @@ -3,6 +3,7 @@ import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { getFilterTypeFromFieldType } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions'; +import { isSystemSearchVectorField } from '@/object-record/utils/isSystemSearchVectorField'; import { isDefined } from 'twenty-shared/utils'; import { ViewFilter } from '../types/ViewFilter'; @@ -26,6 +27,10 @@ export const mapViewFiltersToFilters = ( availableFieldMetadataItem.type, ); + const label = isSystemSearchVectorField(availableFieldMetadataItem.name) + ? 'Search' + : availableFieldMetadataItem.label; + return { id: viewFilter.id, fieldMetadataId: viewFilter.fieldMetadataId, @@ -34,7 +39,7 @@ export const mapViewFiltersToFilters = ( operand: viewFilter.operand, recordFilterGroupId: viewFilter.viewFilterGroupId, positionInRecordFilterGroup: viewFilter.positionInViewFilterGroup, - label: availableFieldMetadataItem.label, + label, type: filterType, subFieldName: viewFilter.subFieldName, } satisfies RecordFilter; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/compute-where-condition-parts.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/compute-where-condition-parts.ts index 159caca2f..01ac741ad 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/compute-where-condition-parts.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/compute-where-condition-parts.ts @@ -4,6 +4,7 @@ import { GraphqlQueryRunnerException, GraphqlQueryRunnerExceptionCode, } from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception'; +import { formatSearchTerms } from 'src/engine/core-modules/search/utils/format-search-terms'; type WhereConditionParts = { sql: string; @@ -96,10 +97,7 @@ export const computeWhereConditionParts = ({ params: { [`${key}${uuid}`]: value }, }; case 'search': { - const tsQuery = value - .split(/\s+/) - .map((term: string) => `${term}:*`) - .join(' & '); + const tsQuery = formatSearchTerms(value, 'and'); return { sql: `(