feat: implement TS vector search filter (#12392)

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 <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
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 <charlesBochet@users.noreply.github.com>
Co-authored-by: Jordan Chalupka <9794216+jordan-chalupka@users.noreply.github.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
Co-authored-by: Thomas Trompette <thomas.trompette@sfr.fr>
Co-authored-by: Guillim <guillim@users.noreply.github.com>
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 <martmull@hotmail.fr>
Co-authored-by: Thomas des Francs <tdesfrancs@gmail.com>
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 <github-actions@twenty.com>
Co-authored-by: Weiko <corentin@twenty.com>
Co-authored-by: Matt Dvertola <64113801+mdvertola@users.noreply.github.com>
Co-authored-by: guillim <guigloo@msn.com>
Co-authored-by: Zeroday BYTE <github@zerodaysec.org>
This commit is contained in:
Abdul Rahman
2025-06-04 18:37:52 +05:30
committed by GitHub
parent 7046965496
commit 63c9af54f5
43 changed files with 656 additions and 132 deletions

View File

@ -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 = {

View File

@ -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 (
<ViewBarFilterDropdownVectorSearchInput
filterDropdownId={filterDropdownId}
/>
);
}
if (!isDefined(fieldMetadataItemUsedInDropdown)) {
return null;
}

View File

@ -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,

View File

@ -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],
);

View File

@ -28,3 +28,5 @@ export type FilterableFieldType = PickLiteral<
FieldType,
FilterableFieldTypeBaseLiteral
>;
export type FilterableAndTSVectorFieldType = FilterableFieldType | 'TS_VECTOR';

View File

@ -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<T extends FilterableFieldType> =
(typeof FILTER_OPERANDS_MAP)[T][number];
export type RecordFilterToRecordInputOperand<
T extends FilterableAndTSVectorFieldType,
> = (typeof FILTER_OPERANDS_MAP)[T][number];

View File

@ -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');
});
});
});

View File

@ -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,

View File

@ -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:

View File

@ -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}`);
}

View File

@ -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)}`,
);
}
}
};

View File

@ -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}"`,

View File

@ -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);
}
};

View File

@ -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;
};

View File

@ -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,
);