From 9fb7ef5d4758863b8e53a61f4845f93a4482e624 Mon Sep 17 00:00:00 2001 From: Guillim Date: Thu, 24 Apr 2025 19:02:03 +0200 Subject: [PATCH] Automatically Apply Values on Filtered Views (#11717) Issue : When I create a task in the "Assigned to me" task view, it will disappear from the view because the Assignee field isn't automatically populated. Solution : We created a "buildRecordInputFromFilters" funciron that will convert filtered into their corresponding values for the input. Fixes https://github.com/twentyhq/core-team-issues/issues/708 --------- Co-authored-by: etiennejouan --- .../record-filter/types/RecordFilter.ts | 4 + .../utils/getRecordFilterOperands.ts | 179 ++++--- .../hooks/useBuildRecordInputFromFilters.ts | 58 ++ .../hooks/useCreateNewIndexRecord.ts | 14 +- .../utils/buildRecordInputFromFilter.spec.ts | 500 ++++++++++++++++++ .../utils/buildRecordInputFromFilter.ts | 299 +++++++++++ 6 files changed, 991 insertions(+), 63 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/record-table/hooks/useBuildRecordInputFromFilters.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-table/utils/buildRecordInputFromFilter.spec.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-table/utils/buildRecordInputFromFilter.ts 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 353dad717..f82b62c1b 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,5 @@ import { FilterableFieldType } from '@/object-record/record-filter/types/FilterableFieldType'; +import { FILTER_OPERANDS_MAP } from '@/object-record/record-filter/utils/getRecordFilterOperands'; import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; export type RecordFilter = { @@ -14,3 +15,6 @@ export type RecordFilter = { label: string; subFieldName?: string | null | undefined; }; + +export type RecordFilterToRecordInputOperand = + (typeof FILTER_OPERANDS_MAP)[T][number]; 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 a94c9a5cc..33ff418ae 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 @@ -7,17 +7,116 @@ export type GetRecordFilterOperandsParams = { subFieldName?: string | null | undefined; }; +const emptyOperands = [ + RecordFilterOperand.IsEmpty, + RecordFilterOperand.IsNotEmpty, +] as const; + +const relationOperands = [ + RecordFilterOperand.Is, + RecordFilterOperand.IsNot, +] as const; + +type FilterOperandMap = { + [K in FilterableFieldType]: readonly RecordFilterOperand[]; +}; + +export const FILTER_OPERANDS_MAP = { + TEXT: [ + RecordFilterOperand.Contains, + RecordFilterOperand.DoesNotContain, + ...emptyOperands, + ], + EMAILS: [ + RecordFilterOperand.Contains, + RecordFilterOperand.DoesNotContain, + ...emptyOperands, + ], + FULL_NAME: [ + RecordFilterOperand.Contains, + RecordFilterOperand.DoesNotContain, + ...emptyOperands, + ], + ADDRESS: [ + RecordFilterOperand.Contains, + RecordFilterOperand.DoesNotContain, + ...emptyOperands, + ], + LINKS: [ + RecordFilterOperand.Contains, + RecordFilterOperand.DoesNotContain, + ...emptyOperands, + ], + PHONES: [ + RecordFilterOperand.Contains, + RecordFilterOperand.DoesNotContain, + ...emptyOperands, + ], + CURRENCY: [ + RecordFilterOperand.GreaterThan, + RecordFilterOperand.LessThan, + ...emptyOperands, + ], + NUMBER: [ + RecordFilterOperand.GreaterThan, + RecordFilterOperand.LessThan, + ...emptyOperands, + ], + RAW_JSON: [ + RecordFilterOperand.Contains, + RecordFilterOperand.DoesNotContain, + ...emptyOperands, + ], + DATE_TIME: [ + RecordFilterOperand.Is, + RecordFilterOperand.IsRelative, + RecordFilterOperand.IsInPast, + RecordFilterOperand.IsInFuture, + RecordFilterOperand.IsToday, + RecordFilterOperand.IsBefore, + RecordFilterOperand.IsAfter, + ...emptyOperands, + ], + DATE: [ + RecordFilterOperand.Is, + RecordFilterOperand.IsRelative, + RecordFilterOperand.IsInPast, + RecordFilterOperand.IsInFuture, + RecordFilterOperand.IsToday, + RecordFilterOperand.IsBefore, + RecordFilterOperand.IsAfter, + ...emptyOperands, + ], + RATING: [ + RecordFilterOperand.Is, + RecordFilterOperand.GreaterThan, + RecordFilterOperand.LessThan, + ...emptyOperands, + ], + RELATION: [...relationOperands, ...emptyOperands], + MULTI_SELECT: [ + RecordFilterOperand.Contains, + RecordFilterOperand.DoesNotContain, + ...emptyOperands, + ], + SELECT: [RecordFilterOperand.Is, RecordFilterOperand.IsNot, ...emptyOperands], + ACTOR: [ + RecordFilterOperand.Contains, + RecordFilterOperand.DoesNotContain, + ...emptyOperands, + ], + ARRAY: [ + RecordFilterOperand.Contains, + RecordFilterOperand.DoesNotContain, + ...emptyOperands, + ], + BOOLEAN: [RecordFilterOperand.Is], +} as const satisfies FilterOperandMap; + export const getRecordFilterOperands = ({ filterType, subFieldName, -}: GetRecordFilterOperandsParams): RecordFilterOperand[] => { - const emptyOperands = [ - RecordFilterOperand.IsEmpty, - RecordFilterOperand.IsNotEmpty, - ]; - - const relationOperands = [RecordFilterOperand.Is, RecordFilterOperand.IsNot]; - +}: GetRecordFilterOperandsParams) => { switch (filterType) { case 'TEXT': case 'EMAILS': @@ -25,57 +124,23 @@ export const getRecordFilterOperands = ({ case 'ADDRESS': case 'LINKS': case 'PHONES': - return [ - RecordFilterOperand.Contains, - RecordFilterOperand.DoesNotContain, - ...emptyOperands, - ]; + return FILTER_OPERANDS_MAP.TEXT; case 'CURRENCY': case 'NUMBER': - return [ - RecordFilterOperand.GreaterThan, - RecordFilterOperand.LessThan, - ...emptyOperands, - ]; + return FILTER_OPERANDS_MAP.NUMBER; case 'RAW_JSON': - return [ - RecordFilterOperand.Contains, - RecordFilterOperand.DoesNotContain, - ...emptyOperands, - ]; + return FILTER_OPERANDS_MAP.RAW_JSON; case 'DATE_TIME': case 'DATE': - return [ - RecordFilterOperand.Is, - RecordFilterOperand.IsRelative, - RecordFilterOperand.IsInPast, - RecordFilterOperand.IsInFuture, - RecordFilterOperand.IsToday, - RecordFilterOperand.IsBefore, - RecordFilterOperand.IsAfter, - ...emptyOperands, - ]; + return FILTER_OPERANDS_MAP.DATE_TIME; case 'RATING': - return [ - RecordFilterOperand.Is, - RecordFilterOperand.GreaterThan, - RecordFilterOperand.LessThan, - ...emptyOperands, - ]; + return FILTER_OPERANDS_MAP.RATING; case 'RELATION': - return [...relationOperands, ...emptyOperands]; + return FILTER_OPERANDS_MAP.RELATION; case 'MULTI_SELECT': - return [ - RecordFilterOperand.Contains, - RecordFilterOperand.DoesNotContain, - ...emptyOperands, - ]; + return FILTER_OPERANDS_MAP.MULTI_SELECT; case 'SELECT': - return [ - RecordFilterOperand.Is, - RecordFilterOperand.IsNot, - ...emptyOperands, - ]; + return FILTER_OPERANDS_MAP.SELECT; case 'ACTOR': { if (isFilterOnActorSourceSubField(subFieldName)) { return [ @@ -85,20 +150,12 @@ export const getRecordFilterOperands = ({ ]; } - return [ - RecordFilterOperand.Contains, - RecordFilterOperand.DoesNotContain, - ...emptyOperands, - ]; + return FILTER_OPERANDS_MAP.ACTOR; } case 'ARRAY': - return [ - RecordFilterOperand.Contains, - RecordFilterOperand.DoesNotContain, - ...emptyOperands, - ]; + return FILTER_OPERANDS_MAP.ARRAY; case 'BOOLEAN': - return [RecordFilterOperand.Is]; + return FILTER_OPERANDS_MAP.BOOLEAN; default: return []; } diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/useBuildRecordInputFromFilters.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/useBuildRecordInputFromFilters.ts new file mode 100644 index 000000000..288805f51 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/useBuildRecordInputFromFilters.ts @@ -0,0 +1,58 @@ +import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; +import { buildValueFromFilter } from '@/object-record/record-table/utils/buildRecordInputFromFilter'; + +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { useRecoilValue } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; + +export const useBuildRecordInputFromFilters = ({ + objectMetadataItem, +}: { + objectMetadataItem: ObjectMetadataItem; +}) => { + // we might need to build a recoil callback for better performance + const currentRecordFilters = useRecoilComponentValueV2( + currentRecordFiltersComponentState, + ); + + const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); + + const buildRecordInputFromFilters = (): Partial => { + const recordInput: Partial = {}; + + currentRecordFilters.forEach((filter) => { + const fieldMetadataItem = objectMetadataItem.fields.find( + (field) => field.id === filter.fieldMetadataId, + ); + + if (!isDefined(fieldMetadataItem)) { + return; + } + if (fieldMetadataItem.type === 'RELATION') { + const value = buildValueFromFilter({ + filter, + options: fieldMetadataItem.options ?? undefined, + relationType: fieldMetadataItem.relationDefinition?.direction, + currentWorkspaceMember: currentWorkspaceMember ?? undefined, + label: filter.label, + }); + if (!isDefined(value)) { + return; + } + recordInput[`${fieldMetadataItem.name}Id`] = value; + } else { + recordInput[fieldMetadataItem.name] = buildValueFromFilter({ + filter, + options: fieldMetadataItem.options ?? undefined, + }); + } + }); + + return recordInput; + }; + + return { buildRecordInputFromFilters }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/useCreateNewIndexRecord.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/useCreateNewIndexRecord.ts index 1637a4771..605e7a50e 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/hooks/useCreateNewIndexRecord.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/useCreateNewIndexRecord.ts @@ -2,6 +2,7 @@ import { useOpenRecordInCommandMenu } from '@/command-menu/hooks/useOpenRecordIn import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord'; import { recordIndexOpenRecordInState } from '@/object-record/record-index/states/recordIndexOpenRecordInState'; +import { useBuildRecordInputFromFilters } from '@/object-record/record-table/hooks/useBuildRecordInputFromFilters'; import { useRecordTitleCell } from '@/object-record/record-title-cell/hooks/useRecordTitleCell'; import { RecordTitleCellContainerType } from '@/object-record/record-title-cell/types/RecordTitleCellContainerType'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; @@ -27,17 +28,25 @@ export const useCreateNewIndexRecord = ({ const { openRecordTitleCell } = useRecordTitleCell(); + const { buildRecordInputFromFilters } = useBuildRecordInputFromFilters({ + objectMetadataItem, + }); + const createNewIndexRecord = useRecoilCallback( ({ snapshot }) => async (recordInput?: Partial) => { const recordId = v4(); + const recordInputFromFilters = buildRecordInputFromFilters(); const recordIndexOpenRecordIn = snapshot .getLoadable(recordIndexOpenRecordInState) .getValue(); - await createOneRecord({ id: recordId, ...recordInput }); - + await createOneRecord({ + id: recordId, + ...recordInputFromFilters, + ...recordInput, + }); if (recordIndexOpenRecordIn === ViewOpenRecordInType.SIDE_PANEL) { openRecordInCommandMenu({ recordId, @@ -58,6 +67,7 @@ export const useCreateNewIndexRecord = ({ } }, [ + buildRecordInputFromFilters, createOneRecord, navigate, objectMetadataItem.labelIdentifierFieldMetadataId, diff --git a/packages/twenty-front/src/modules/object-record/record-table/utils/buildRecordInputFromFilter.spec.ts b/packages/twenty-front/src/modules/object-record/record-table/utils/buildRecordInputFromFilter.spec.ts new file mode 100644 index 000000000..afdceb433 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/utils/buildRecordInputFromFilter.spec.ts @@ -0,0 +1,500 @@ +import { FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem'; +import { FilterableFieldType } from '@/object-record/record-filter/types/FilterableFieldType'; +import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; +import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; +import { ColorScheme } from '@/workspace-member/types/WorkspaceMember'; +import { RelationDefinitionType } from '~/generated-metadata/graphql'; +import { buildValueFromFilter } from './buildRecordInputFromFilter'; + +// TODO: fix the dates, and test the not supported types +const mockDate = new Date('2024-03-20T12:00:00Z'); + +beforeAll(() => { + jest.useFakeTimers(); + jest.setSystemTime(mockDate); +}); + +afterAll(() => { + jest.useRealTimers(); +}); + +describe('buildValueFromFilter', () => { + const createTestFilter = ( + operand: ViewFilterOperand, + value: string, + type: FilterableFieldType, + ): RecordFilter => ({ + id: 'test-id', + fieldMetadataId: 'test-field-id', + value, + displayValue: value, + type, + operand, + label: 'Test Label', + }); + + describe('TEXT field type', () => { + const testCases = [ + { + operand: ViewFilterOperand.Contains, + value: 'test', + expected: 'test', + }, + { + operand: ViewFilterOperand.DoesNotContain, + value: 'test', + expected: undefined, + }, + { + operand: ViewFilterOperand.IsNotEmpty, + value: 'test', + expected: 'test', + }, + { + operand: ViewFilterOperand.IsEmpty, + value: 'test', + expected: undefined, + }, + ]; + + it.each(testCases)( + 'should handle $operand with value "$value"', + ({ operand, value, expected }) => { + const filter = createTestFilter(operand, value, 'TEXT'); + expect(buildValueFromFilter({ filter })).toBe(expected); + }, + ); + }); + + describe('DATE_TIME field type', () => { + const testCases = [ + { + operand: ViewFilterOperand.Is, + value: '2024-03-20T12:00:00Z', + expected: mockDate, + }, + { + operand: ViewFilterOperand.IsAfter, + value: '2024-03-20T12:00:00Z', + expected: mockDate, + }, + { + operand: ViewFilterOperand.IsBefore, + value: '2024-03-20T12:00:00Z', + expected: mockDate, + }, + { + operand: ViewFilterOperand.IsInPast, + value: '2024-03-20T12:00:00Z', + expected: mockDate, + }, + { + operand: ViewFilterOperand.IsInFuture, + value: '2024-03-20T12:00:00Z', + expected: mockDate, + }, + { + operand: ViewFilterOperand.IsToday, + value: '', + expected: mockDate, + }, + { + operand: ViewFilterOperand.IsRelative, + value: '', + expected: mockDate, + }, + { + operand: ViewFilterOperand.IsEmpty, + value: '', + expected: undefined, + }, + { + operand: ViewFilterOperand.IsNotEmpty, + value: '2024-03-20T12:00:00Z', + expected: mockDate, + }, + ]; + + it.each(testCases)( + 'should handle $operand with value "$value"', + ({ operand, value, expected }) => { + const filter = createTestFilter(operand, value, 'DATE_TIME'); + const result = buildValueFromFilter({ filter }); + if (expected instanceof Date) { + expect(result).toBeInstanceOf(Date); + expect(result).toEqual(expected); + } else { + expect(result).toBe(expected); + } + }, + ); + }); + + describe('NUMBER field type', () => { + const testCases = [ + { + operand: ViewFilterOperand.GreaterThan, + value: '5', + expected: 6, + }, + { + operand: ViewFilterOperand.LessThan, + value: '5', + expected: 4, + }, + { + operand: ViewFilterOperand.IsNotEmpty, + value: '5', + expected: 5, + }, + { + operand: ViewFilterOperand.IsEmpty, + value: '5', + expected: undefined, + }, + ]; + + it.each(testCases)( + 'should handle $operand with value "$value"', + ({ operand, value, expected }) => { + const filter = createTestFilter(operand, value, 'NUMBER'); + expect(buildValueFromFilter({ filter })).toBe(expected); + }, + ); + }); + + describe('BOOLEAN field type', () => { + const testCases = [ + { + operand: ViewFilterOperand.Is, + value: 'true', + expected: true, + }, + { + operand: ViewFilterOperand.Is, + value: 'false', + expected: false, + }, + ]; + + it.each(testCases)( + 'should handle $operand with value "$value"', + ({ operand, value, expected }) => { + const filter = createTestFilter(operand, value, 'BOOLEAN'); + expect(buildValueFromFilter({ filter })).toBe(expected); + }, + ); + }); + + describe('ARRAY field type', () => { + const testCases = [ + { + operand: ViewFilterOperand.Contains, + value: 'test', + expected: 'test', + }, + { + operand: ViewFilterOperand.DoesNotContain, + value: 'test', + expected: undefined, + }, + { + operand: ViewFilterOperand.IsNotEmpty, + value: 'test', + expected: 'test', + }, + { + operand: ViewFilterOperand.IsEmpty, + value: 'test', + expected: undefined, + }, + ]; + + it.each(testCases)( + 'should handle $operand with value "$value"', + ({ operand, value, expected }) => { + const filter = createTestFilter(operand, value, 'ARRAY'); + expect(buildValueFromFilter({ filter })).toBe(expected); + }, + ); + }); + + describe('RELATION field type', () => { + const mockCurrentWorkspaceMember = { + id: 'current-workspace-member-id', + name: { firstName: 'John', lastName: 'Doe' }, + locale: 'en', + colorScheme: 'Light' as ColorScheme, + avatarUrl: '', + dateFormat: null, + timeFormat: null, + timeZone: null, + }; + + const testCases = [ + { + operand: ViewFilterOperand.Is, + value: JSON.stringify({ + isCurrentWorkspaceMemberSelected: false, + selectedRecordIds: ['record-1'], + }), + relationType: RelationDefinitionType.MANY_TO_ONE, + label: 'belongs to one', + expected: 'record-1', + }, + { + operand: ViewFilterOperand.Is, + value: JSON.stringify({ + isCurrentWorkspaceMemberSelected: true, + selectedRecordIds: ['record-1'], + }), + relationType: RelationDefinitionType.MANY_TO_ONE, + label: 'Assignee', + expected: 'current-workspace-member-id', + }, + { + operand: ViewFilterOperand.Is, + value: JSON.stringify({ + isCurrentWorkspaceMemberSelected: false, + selectedRecordIds: ['record-1', 'record-2'], + }), + relationType: RelationDefinitionType.MANY_TO_MANY, + label: 'hasmany', + expected: undefined, + }, + { + operand: ViewFilterOperand.IsNot, + value: JSON.stringify({ + isCurrentWorkspaceMemberSelected: false, + selectedRecordIds: ['record-1'], + }), + relationType: RelationDefinitionType.MANY_TO_ONE, + label: 'Assignee', + expected: undefined, + }, + { + operand: ViewFilterOperand.IsEmpty, + value: JSON.stringify({ + isCurrentWorkspaceMemberSelected: false, + selectedRecordIds: ['record-1'], + }), + relationType: RelationDefinitionType.MANY_TO_ONE, + label: 'Assignee', + expected: undefined, + }, + ]; + + it.each(testCases)( + 'should handle $operand with value "$value" for $relationType relation', + ({ operand, value, relationType, label, expected }) => { + const filter = createTestFilter(operand, value, 'RELATION'); + expect( + buildValueFromFilter({ + filter, + relationType, + currentWorkspaceMember: mockCurrentWorkspaceMember, + label, + }), + ).toEqual(expected); + }, + ); + }); + + describe('Composite field types', () => { + const compositeTypes: FilterableFieldType[] = ['ACTOR', 'FULL_NAME']; + + it.each(compositeTypes)( + 'should return undefined for composite type %s', + (type) => { + const filter = createTestFilter(ViewFilterOperand.Is, 'test', type); + expect(buildValueFromFilter({ filter })).toBeUndefined(); + }, + ); + }); + + describe('RAW_JSON field type', () => { + it('should return undefined', () => { + const filter = createTestFilter(ViewFilterOperand.Is, 'test', 'RAW_JSON'); + expect(buildValueFromFilter({ filter })).toBeUndefined(); + }); + }); + + describe('RATING field type', () => { + const mockOptions = [ + { + label: 'Rating 1', + value: 'RATING_1', + id: '1', + position: 1, + }, + { + label: 'Rating 2', + value: 'RATING_2', + id: '2', + position: 2, + }, + { + label: 'Rating 3', + value: 'RATING_3', + id: '3', + position: 3, + }, + ]; + + const testCases = [ + { + operand: ViewFilterOperand.Is, + value: 'Rating 1', + expected: 'RATING_1', + }, + { + operand: ViewFilterOperand.IsNotEmpty, + value: 'Rating 2', + expected: 'RATING_2', + }, + { + operand: ViewFilterOperand.IsEmpty, + value: 'Rating 1', + expected: undefined, + }, + { + operand: ViewFilterOperand.GreaterThan, + value: 'Rating 1', + expected: 'RATING_2', + }, + { + operand: ViewFilterOperand.LessThan, + value: 'Rating 2', + expected: 'RATING_1', + }, + ]; + + it.each(testCases)( + 'should handle $operand with value "$value"', + ({ operand, value, expected }) => { + const filter = createTestFilter(operand, value, 'RATING'); + expect( + buildValueFromFilter({ + filter, + options: mockOptions as FieldMetadataItemOption[], + }), + ).toBe(expected); + }, + ); + + it('should return undefined when option is not found', () => { + const filter = createTestFilter( + ViewFilterOperand.Is, + 'Rating 4', + 'RATING', + ); + expect( + buildValueFromFilter({ + filter, + options: mockOptions as FieldMetadataItemOption[], + }), + ).toBeUndefined(); + }); + }); + + describe('SELECT field type', () => { + const mockOptions = [ + { + label: 'Option 1', + value: 'OPTION_1', + color: 'red', + id: '1', + position: 1, + }, + { + label: 'Option 2', + value: 'OPTION_2', + color: 'blue', + id: '2', + position: 2, + }, + ]; + + const testCases = [ + { + operand: ViewFilterOperand.Is, + value: JSON.stringify(['OPTION_1']), + expected: 'OPTION_1', + }, + { + operand: ViewFilterOperand.IsNot, + value: JSON.stringify(['OPTION_1']), + expected: undefined, + }, + { + operand: ViewFilterOperand.IsEmpty, + value: JSON.stringify(['OPTION_1']), + expected: undefined, + }, + ]; + + it.each(testCases)( + 'should handle $operand with value "$value"', + ({ operand, value, expected }) => { + const filter = createTestFilter(operand, value, 'SELECT'); + expect( + buildValueFromFilter({ + filter, + options: mockOptions as FieldMetadataItemOption[], + }), + ).toBe(expected); + }, + ); + + it('should handle invalid JSON', () => { + const filter = createTestFilter( + ViewFilterOperand.Is, + 'invalid-json', + 'SELECT', + ); + expect( + buildValueFromFilter({ + filter, + options: mockOptions as FieldMetadataItemOption[], + }), + ).toBeUndefined(); + }); + }); + + describe('MULTI_SELECT field type', () => { + const testCases = [ + { + operand: ViewFilterOperand.Contains, + value: JSON.stringify(['OPTION_1', 'OPTION_2']), + expected: ['OPTION_1', 'OPTION_2'], + }, + { + operand: ViewFilterOperand.DoesNotContain, + value: JSON.stringify(['OPTION_1']), + expected: undefined, + }, + { + operand: ViewFilterOperand.IsEmpty, + value: JSON.stringify(['OPTION_1']), + expected: undefined, + }, + ]; + + it.each(testCases)( + 'should handle $operand with value "$value"', + ({ operand, value, expected }) => { + const filter = createTestFilter(operand, value, 'MULTI_SELECT'); + expect(buildValueFromFilter({ filter })).toEqual(expected); + }, + ); + + it('should handle invalid JSON', () => { + const filter = createTestFilter( + ViewFilterOperand.Contains, + 'invalid-json', + 'MULTI_SELECT', + ); + expect(buildValueFromFilter({ filter })).toBeUndefined(); + }); + }); +}); 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 new file mode 100644 index 000000000..76becf223 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/utils/buildRecordInputFromFilter.ts @@ -0,0 +1,299 @@ +import { CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState'; +import { FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem'; +import { isCompositeField } from '@/object-record/object-filter-dropdown/utils/isCompositeField'; + +import { + RecordFilter, + RecordFilterToRecordInputOperand, +} from '@/object-record/record-filter/types/RecordFilter'; +import { FILTER_OPERANDS_MAP } from '@/object-record/record-filter/utils/getRecordFilterOperands'; +import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; +import { assertUnreachable } from 'twenty-shared/utils'; +import { RelationDefinitionType } from '~/generated-metadata/graphql'; +import { parseJson } from '~/utils/parseJson'; + +export const buildValueFromFilter = ({ + filter, + options, + relationType, + currentWorkspaceMember, + label, +}: { + filter: RecordFilter; + options?: FieldMetadataItemOption[]; + relationType?: RelationDefinitionType; + currentWorkspaceMember?: CurrentWorkspaceMember; + label?: string; +}) => { + if (isCompositeField(filter.type)) { + return; + } + + if (filter.type === 'RAW_JSON') { + return; + } + + const operands = FILTER_OPERANDS_MAP[filter.type]; + if (!operands.some((operand) => operand === filter.operand)) { + throw new Error('Operand not supported for this field type'); + } + + switch (filter.type) { + case 'TEXT': { + return computeValueFromFilterText( + filter.operand as (typeof FILTER_OPERANDS_MAP)['TEXT'][number], + filter.value, + ); + } + case 'RATING': + return computeValueFromFilterRating( + filter.operand as (typeof FILTER_OPERANDS_MAP)['RATING'][number], + filter.value, + options, + ); + case 'DATE_TIME': + case 'DATE': + return computeValueFromFilterDate( + filter.operand as (typeof FILTER_OPERANDS_MAP)['DATE_TIME'][number], + filter.value, + ); + case 'NUMBER': + return computeValueFromFilterNumber( + filter.operand as (typeof FILTER_OPERANDS_MAP)['NUMBER'][number], + filter.value, + ); + case 'BOOLEAN': + return computeValueFromFilterBoolean( + filter.operand as (typeof FILTER_OPERANDS_MAP)['BOOLEAN'][number], + filter.value, + ); + case 'ARRAY': + return computeValueFromFilterArray( + filter.operand as (typeof FILTER_OPERANDS_MAP)['ARRAY'][number], + filter.value, + ); + case 'SELECT': + return computeValueFromFilterSelect( + filter.operand as (typeof FILTER_OPERANDS_MAP)['SELECT'][number], + filter.value, + options, + ); + case 'MULTI_SELECT': + return computeValueFromFilterMultiSelect( + filter.operand as (typeof FILTER_OPERANDS_MAP)['MULTI_SELECT'][number], + filter.value, + ); + case 'RELATION': { + return computeValueFromFilterRelation( + filter.operand as (typeof FILTER_OPERANDS_MAP)['RELATION'][number], + filter.value, + relationType, + currentWorkspaceMember, + label, + ); + } + default: + assertUnreachable(filter.type); + } +}; + +const computeValueFromFilterText = ( + operand: RecordFilterToRecordInputOperand<'TEXT'>, + value: string, +) => { + switch (operand) { + case ViewFilterOperand.Contains: + return value; + case ViewFilterOperand.IsNotEmpty: + return value; + case ViewFilterOperand.IsEmpty: + case ViewFilterOperand.DoesNotContain: + return undefined; + default: + assertUnreachable(operand); + } +}; + +const computeValueFromFilterDate = ( + operand: RecordFilterToRecordInputOperand<'DATE_TIME'>, + value: string, +) => { + switch (operand) { + case ViewFilterOperand.Is: + case ViewFilterOperand.IsAfter: + case ViewFilterOperand.IsBefore: + return new Date(value); + case ViewFilterOperand.IsToday: + case ViewFilterOperand.IsNotEmpty: + case ViewFilterOperand.IsInPast: + case ViewFilterOperand.IsInFuture: + case ViewFilterOperand.IsRelative: + return new Date(); + case ViewFilterOperand.IsEmpty: + return undefined; + default: + assertUnreachable(operand); + } +}; + +const computeValueFromFilterNumber = ( + operand: RecordFilterToRecordInputOperand<'NUMBER'>, + value: string, +) => { + switch (operand) { + case ViewFilterOperand.GreaterThan: + return Number(value) + 1; + case ViewFilterOperand.LessThan: + return Number(value) - 1; + case ViewFilterOperand.IsNotEmpty: + return Number(value); + case ViewFilterOperand.IsEmpty: + return undefined; + default: + assertUnreachable(operand); + } +}; + +const computeValueFromFilterBoolean = ( + operand: RecordFilterToRecordInputOperand<'BOOLEAN'>, + value: string, +) => { + switch (operand) { + case ViewFilterOperand.Is: + return value === 'true'; + default: + assertUnreachable(operand); + } +}; + +const computeValueFromFilterArray = ( + operand: RecordFilterToRecordInputOperand<'ARRAY'>, + value: string, +) => { + switch (operand) { + case ViewFilterOperand.Contains: + case ViewFilterOperand.IsNotEmpty: + return value; + case ViewFilterOperand.DoesNotContain: + case ViewFilterOperand.IsEmpty: + return undefined; + default: + assertUnreachable(operand); + } +}; + +const computeValueFromFilterRating = ( + operand: RecordFilterToRecordInputOperand<'RATING'>, + value: string, + options?: FieldMetadataItemOption[], +) => { + const option = options?.find((option) => option.label === value); + if (!option) { + return undefined; + } + + switch (operand) { + case ViewFilterOperand.Is: + case ViewFilterOperand.IsNotEmpty: + return option.value; + case ViewFilterOperand.GreaterThan: { + const plusOne = options?.find( + (opt) => opt.position === option.position + 1, + )?.value; + return plusOne ? plusOne : option.value; + } + case ViewFilterOperand.LessThan: { + const minusOne = options?.find( + (opt) => opt.position === option.position - 1, + )?.value; + return minusOne ? minusOne : option.value; + } + case ViewFilterOperand.IsEmpty: + return undefined; + default: + assertUnreachable(operand); + } +}; + +const computeValueFromFilterSelect = ( + operand: RecordFilterToRecordInputOperand<'SELECT'>, + value: string, + options?: FieldMetadataItemOption[], +) => { + switch (operand) { + case ViewFilterOperand.Is: + case ViewFilterOperand.IsNotEmpty: + try { + const valueParsed = parseJson(value)?.[0]; + const option = options?.find((option) => option.value === valueParsed); + if (!option) { + return undefined; + } + return option.value; + } catch (error) { + return undefined; + } + case ViewFilterOperand.IsNot: + case ViewFilterOperand.IsEmpty: + return undefined; + default: + assertUnreachable(operand); + } +}; + +const computeValueFromFilterMultiSelect = ( + operand: RecordFilterToRecordInputOperand<'MULTI_SELECT'>, + value: string, +) => { + switch (operand) { + case ViewFilterOperand.Contains: + case ViewFilterOperand.IsNotEmpty: + try { + const parsedValue = parseJson(value); + return parsedValue ? parsedValue : undefined; + } catch (error) { + return undefined; + } + case ViewFilterOperand.DoesNotContain: + case ViewFilterOperand.IsEmpty: + return undefined; + default: + assertUnreachable(operand); + } +}; + +const computeValueFromFilterRelation = ( + operand: RecordFilterToRecordInputOperand<'RELATION'>, + value: string, + relationType?: RelationDefinitionType, + currentWorkspaceMember?: CurrentWorkspaceMember, + label?: string, +) => { + switch (operand) { + case ViewFilterOperand.Is: { + const parsedValue = parseJson<{ + isCurrentWorkspaceMemberSelected: boolean; + selectedRecordIds: string[]; + }>(value); + if ( + relationType === RelationDefinitionType.MANY_TO_ONE || + relationType === RelationDefinitionType.ONE_TO_ONE + ) { + if (label === 'Assignee') { + return parsedValue?.isCurrentWorkspaceMemberSelected + ? currentWorkspaceMember?.id + : undefined; + } else { + return parsedValue?.selectedRecordIds?.[0]; + } + } + return undefined; //todo + } + case ViewFilterOperand.IsNot: + case ViewFilterOperand.IsNotEmpty: // todo + case ViewFilterOperand.IsEmpty: + return undefined; + default: + assertUnreachable(operand); + } +};