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: `(