From 97d4ec96af417387ae1f1c9ef0d33ba45571dbe8 Mon Sep 17 00:00:00 2001 From: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Date: Wed, 28 May 2025 12:22:28 +0200 Subject: [PATCH] Fix view filter update and deletion propagation (#12082) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Introduction Diff description: ~500 tests and +500 additions close https://github.com/twentyhq/core-team-issues/issues/731 ## What has been done here In a nutshell on a field metadata type ( `SELECT MULTI_SELECT` ) update, we will be browsing all `ViewFilters` in a post hook searching for some referencing related updated `fieldMetadata` select. In order to update or delete the `viewFilter` depending on the associated mutations. ## How to test: - Add FieldMetadata `SELECT | MULTI_SELECT` to an existing or a new `objectMetadata` - Create a filtered view on created `fieldMetadata` with any options you would like - Remove some options ( in the best of the world some that are selected by the filter ) from the `fieldMetadata` settings page - Go back to the filtered view, removed or updated options should have been hydrated in the `displayValue` and the filtered data should make sense ## All filtered options are deleted edge case If an update implies that a viewFilter does not have any existing related options anymore, then we remove the viewFilter ## Testing ```sh PASS test/integration/metadata/suites/field-metadata/update-one-field-metadata-related-record.integration-spec.ts (27 s) update-one-field-metadata-related-record SELECT ✓ should delete related view filter if all select field options got deleted (2799 ms) ✓ should update related multi selected options view filter (1244 ms) ✓ should update related solo selected option view filter (1235 ms) ✓ should handle partial deletion of selected options in view filter (1210 ms) ✓ should handle reordering of options while maintaining view filter values (1487 ms) ✓ should handle no changes update of options while maintaining existing view filter values (1174 ms) ✓ should handle adding new options while maintaining existing view filter (1174 ms) ✓ should update display value with options label if less than 3 options are selected (1249 ms) ✓ should throw error if view filter value is not a stringified JSON array (1300 ms) MULTI_SELECT ✓ should delete related view filter if all select field options got deleted (1127 ms) ✓ should update related multi selected options view filter (1215 ms) ✓ should update related solo selected option view filter (1404 ms) ✓ should handle partial deletion of selected options in view filter (1936 ms) ✓ should handle reordering of options while maintaining view filter values (1261 ms) ✓ should handle no changes update of options while maintaining existing view filter values (1831 ms) ✓ should handle adding new options while maintaining existing view filter (1610 ms) ✓ should update display value with options label if less than 3 options are selected (1889 ms) ✓ should throw error if view filter value is not a stringified JSON array (1365 ms) Test Suites: 1 passed, 1 total Tests: 18 passed, 18 total Snapshots: 18 passed, 18 total Time: 27.039 s ``` ## Out of scope - We should handle ViewFilter validation when extracting its definition from the metadata https://github.com/twentyhq/core-team-issues/issues/1009 ## Concerns - Are we able through the api to update an RATING fieldMetadata ? ( if yes than that's an issue and we should handle RATING the same way than for SELECT and MULTI_SELECT ) - It's not possible to group a view from a MULTI_SELECT field The above points create a double nor a triple "lecture" to the post hook effect: - ViewGroup -> only SELECT - VIewFilter -> only SELECT || MULTI_SELECT - Rating nothing I think we should determine the scope of all of that --------- Co-authored-by: Charles Bochet --- .../hooks/useUpdateOneFieldMetadataItem.ts | 4 +- .../ObjectFilterDropdownOptionSelect.tsx | 2 +- .../components/RichTextV2FieldDisplay.tsx | 7 +- .../hooks/usePrecomputedJsonDraftValue.ts | 7 +- .../hooks/useRichTextFieldDisplay.ts | 6 +- .../utils/buildRecordInputFromFilter.ts | 3 +- .../components/ViewBarRecordFilterEffect.tsx | 40 +- ...urrentRecordFiltersComponentFamilyState.ts | 9 - packages/twenty-front/src/utils/parseJson.ts | 12 - packages/twenty-server/package.json | 1 + .../field-metadata/field-metadata.service.ts | 10 +- .../field-metadata-related-records.spec.ts | 347 ++++++++++++++++ .../field-metadata-related-records.service.ts | 159 +++++++- ...ect-or-multi-select-field-metadata.util.ts | 18 + .../create-one-operation-factory.util.ts | 6 +- .../utils/create-one-operation.util.ts | 43 ++ .../utils/find-one-operation-factory.util.ts | 4 +- .../graphql/utils/find-one-operation.util.ts | 40 ++ ...ta-related-record.integration-spec.ts.snap | 175 ++++++++ ...-field-metadata-select.integration-spec.ts | 12 +- ...etadata-related-record.integration-spec.ts | 381 ++++++++++++++++++ ...-field-metadata-select.integration-spec.ts | 12 +- .../utils/create-one-field-metadata.util.ts | 7 +- .../utils/update-one-field-metadata.util.ts | 7 +- .../utils/create-one-object-metadata.util.ts | 7 +- .../types/common-response-body.type.ts | 6 + .../FieldMetadataMaxOptionsToDisplay.ts | 1 + packages/twenty-shared/src/constants/index.ts | 1 + packages/twenty-shared/src/utils/index.ts | 1 + packages/twenty-shared/src/utils/parseJson.ts | 7 + yarn.lock | 8 + 31 files changed, 1250 insertions(+), 93 deletions(-) delete mode 100644 packages/twenty-front/src/modules/views/states/hasInitializedCurrentRecordFiltersComponentFamilyState.ts delete mode 100644 packages/twenty-front/src/utils/parseJson.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/field-metadata/services/__tests__/field-metadata-related-records.spec.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/is-select-or-multi-select-field-metadata.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/create-one-operation.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/find-one-operation.util.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/field-metadata/__snapshots__/update-one-field-metadata-related-record.integration-spec.ts.snap create mode 100644 packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata-related-record.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/metadata/types/common-response-body.type.ts create mode 100644 packages/twenty-shared/src/constants/FieldMetadataMaxOptionsToDisplay.ts create mode 100644 packages/twenty-shared/src/utils/parseJson.ts diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useUpdateOneFieldMetadataItem.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useUpdateOneFieldMetadataItem.ts index a252c87b4..0691ec89b 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/useUpdateOneFieldMetadataItem.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/useUpdateOneFieldMetadataItem.ts @@ -17,8 +17,8 @@ import { useSetRecoilState } from 'recoil'; import { getRecordsFromRecordConnection } from '@/object-record/cache/utils/getRecordsFromRecordConnection'; import { RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection'; import { useSetRecordGroups } from '@/object-record/record-group/hooks/useSetRecordGroups'; -import { useApolloMetadataClient } from './useApolloMetadataClient'; import { isDefined } from 'twenty-shared/utils'; +import { useApolloMetadataClient } from './useApolloMetadataClient'; export const useUpdateOneFieldMetadataItem = () => { const apolloMetadataClient = useApolloMetadataClient(); @@ -27,6 +27,7 @@ export const useUpdateOneFieldMetadataItem = () => { useRefreshObjectMetadataItems('network-only'); const { setRecordGroupsFromViewGroups } = useSetRecordGroups(); + const cache = useApolloClient().cache; const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState); @@ -113,6 +114,7 @@ export const useUpdateOneFieldMetadataItem = () => { correspondingObjectMetadataItemRefreshed, ); } + cache.evict({ id: `Views:${view.id}` }); } return result; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownOptionSelect.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownOptionSelect.tsx index 755f5ebe4..248b65d24 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownOptionSelect.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownOptionSelect.tsx @@ -20,11 +20,11 @@ import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { isNonEmptyString } from '@sniptt/guards'; +import { MAX_OPTIONS_TO_DISPLAY } from 'twenty-shared/constants'; import { isDefined } from 'twenty-shared/utils'; import { MenuItem, MenuItemMultiSelect } from 'twenty-ui/navigation'; export const EMPTY_FILTER_VALUE = ''; -export const MAX_OPTIONS_TO_DISPLAY = 3; type SelectOptionForFilter = FieldMetadataItemOption & { isSelected: boolean; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/RichTextV2FieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/RichTextV2FieldDisplay.tsx index d04f739d9..3aa3611ee 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/RichTextV2FieldDisplay.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/RichTextV2FieldDisplay.tsx @@ -1,12 +1,15 @@ import { useRichTextV2FieldDisplay } from '@/object-record/record-field/meta-types/hooks/useRichTextV2FieldDisplay'; import { getFirstNonEmptyLineOfRichText } from '@/ui/input/editor/utils/getFirstNonEmptyLineOfRichText'; import { PartialBlock } from '@blocknote/core'; -import { parseJson } from '~/utils/parseJson'; +import { isDefined, parseJson } from 'twenty-shared/utils'; export const RichTextV2FieldDisplay = () => { const { fieldValue } = useRichTextV2FieldDisplay(); - const blocks = parseJson(fieldValue?.blocknote); + const blocks = + isDefined(fieldValue) && isDefined(fieldValue.blocknote) + ? parseJson(fieldValue.blocknote) + : null; return (
diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/usePrecomputedJsonDraftValue.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/usePrecomputedJsonDraftValue.ts index df6c772e0..251d60e8f 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/usePrecomputedJsonDraftValue.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/usePrecomputedJsonDraftValue.ts @@ -2,9 +2,8 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; import { orderWorkflowRunOutput } from '@/object-record/record-field/meta-types/utils/orderWorkflowRunOutput'; import { useContext } from 'react'; -import { isDefined } from 'twenty-shared/utils'; +import { isDefined, parseJson } from 'twenty-shared/utils'; import { JsonObject, JsonValue } from 'type-fest'; -import { parseJson } from '~/utils/parseJson'; export const usePrecomputedJsonDraftValue = ({ draftValue, @@ -13,7 +12,9 @@ export const usePrecomputedJsonDraftValue = ({ }): JsonValue => { const { fieldDefinition } = useContext(FieldContext); - const parsedJsonValue = parseJson(draftValue); + const parsedJsonValue = isDefined(draftValue) + ? parseJson(draftValue) + : null; if ( fieldDefinition.metadata.objectMetadataNameSingular === diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useRichTextFieldDisplay.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useRichTextFieldDisplay.ts index 4af49ae07..10430a806 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useRichTextFieldDisplay.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useRichTextFieldDisplay.ts @@ -6,8 +6,8 @@ import { FieldRichTextValue } from '@/object-record/record-field/types/FieldMeta import { assertFieldMetadata } from '@/object-record/record-field/types/guards/assertFieldMetadata'; import { isFieldRichText } from '@/object-record/record-field/types/guards/isFieldRichText'; import { PartialBlock } from '@blocknote/core'; +import { isDefined, parseJson } from 'twenty-shared/utils'; import { FieldMetadataType } from '~/generated-metadata/graphql'; -import { parseJson } from '~/utils/parseJson'; import { FieldContext } from '../../contexts/FieldContext'; export const useRichTextFieldDisplay = () => { @@ -26,7 +26,9 @@ export const useRichTextFieldDisplay = () => { fieldName, ); - const fieldValueParsed = parseJson(fieldValue); + const fieldValueParsed = isDefined(fieldValue) + ? parseJson(fieldValue) + : null; return { fieldDefinition, 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 be0cc9768..ed8022073 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 @@ -8,9 +8,8 @@ import { } 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 { assertUnreachable, parseJson } from 'twenty-shared/utils'; import { RelationDefinitionType } from '~/generated-metadata/graphql'; -import { parseJson } from '~/utils/parseJson'; export const buildValueFromFilter = ({ filter, diff --git a/packages/twenty-front/src/modules/views/components/ViewBarRecordFilterEffect.tsx b/packages/twenty-front/src/modules/views/components/ViewBarRecordFilterEffect.tsx index 5a52d9064..5343dfb70 100644 --- a/packages/twenty-front/src/modules/views/components/ViewBarRecordFilterEffect.tsx +++ b/packages/twenty-front/src/modules/views/components/ViewBarRecordFilterEffect.tsx @@ -3,14 +3,13 @@ import { useFilterableFieldMetadataItems } from '@/object-record/record-filter/h 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 { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; -import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; -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'; +import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; export const ViewBarRecordFilterEffect = () => { const currentViewId = useRecoilComponentValueV2( @@ -25,47 +24,32 @@ export const ViewBarRecordFilterEffect = () => { }), ); - const [ - hasInitializedCurrentRecordFilters, - setHasInitializedCurrentRecordFilters, - ] = useRecoilComponentFamilyStateV2( - hasInitializedCurrentRecordFiltersComponentFamilyState, - { - viewId: currentViewId ?? undefined, - }, - ); - - const setCurrentRecordFilters = useSetRecoilComponentStateV2( - currentRecordFiltersComponentState, - ); + const [currentRecordFilters, setCurrentRecordFilters] = + useRecoilComponentStateV2(currentRecordFiltersComponentState); const { filterableFieldMetadataItems } = useFilterableFieldMetadataItems( objectMetadataItem.id, ); useEffect(() => { - if (isDefined(currentView) && !hasInitializedCurrentRecordFilters) { + if (isDefined(currentView)) { if (currentView.objectMetadataId !== objectMetadataItem.id) { return; } - if (isDefined(currentView)) { - setCurrentRecordFilters( - mapViewFiltersToFilters( - currentView.viewFilters, - filterableFieldMetadataItems, - ), - ); - - setHasInitializedCurrentRecordFilters(true); + const newRecordFilters = mapViewFiltersToFilters( + currentView.viewFilters, + filterableFieldMetadataItems, + ); + if (!isDeeplyEqual(currentRecordFilters, newRecordFilters)) { + setCurrentRecordFilters(newRecordFilters); } } }, [ currentViewId, + currentRecordFilters, setCurrentRecordFilters, filterableFieldMetadataItems, - hasInitializedCurrentRecordFilters, - setHasInitializedCurrentRecordFilters, currentView, objectMetadataItem, ]); diff --git a/packages/twenty-front/src/modules/views/states/hasInitializedCurrentRecordFiltersComponentFamilyState.ts b/packages/twenty-front/src/modules/views/states/hasInitializedCurrentRecordFiltersComponentFamilyState.ts deleted file mode 100644 index 820b10b8d..000000000 --- a/packages/twenty-front/src/modules/views/states/hasInitializedCurrentRecordFiltersComponentFamilyState.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext'; -import { createComponentFamilyStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentFamilyStateV2'; - -export const hasInitializedCurrentRecordFiltersComponentFamilyState = - createComponentFamilyStateV2({ - key: 'hasInitializedCurrentRecordFiltersComponentFamilyState', - defaultValue: false, - componentInstanceContext: RecordFiltersComponentInstanceContext, - }); diff --git a/packages/twenty-front/src/utils/parseJson.ts b/packages/twenty-front/src/utils/parseJson.ts deleted file mode 100644 index b32165546..000000000 --- a/packages/twenty-front/src/utils/parseJson.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { isDefined } from 'twenty-shared/utils'; -export const parseJson = (json: string | undefined | null) => { - if (!isDefined(json)) { - return null; - } - - try { - return JSON.parse(json) as T; - } catch (e) { - return null; - } -}; diff --git a/packages/twenty-server/package.json b/packages/twenty-server/package.json index c593e1516..c9e996b20 100644 --- a/packages/twenty-server/package.json +++ b/packages/twenty-server/package.json @@ -64,6 +64,7 @@ "zod-to-json-schema": "^3.23.1" }, "devDependencies": { + "@faker-js/faker": "^9.8.0", "@lingui/cli": "^5.1.2", "@nestjs/cli": "10.3.0", "@nx/js": "18.3.3", diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.service.ts index da27cab17..0fd47591f 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.service.ts @@ -42,7 +42,7 @@ import { } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util'; import { generateNullable } from 'src/engine/metadata-modules/field-metadata/utils/generate-nullable'; import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; -import { isSelectFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-select-field-metadata-type.util'; +import { isSelectOrMultiSelectFieldMetadata } from 'src/engine/metadata-modules/field-metadata/utils/is-select-or-multi-select-field-metadata.util'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util'; import { @@ -255,12 +255,18 @@ export class FieldMetadataService extends TypeOrmQueryService; + +describe('FieldMetadataRelatedRecordsService', () => { + describe('getOptionsDifferences', () => { + let service: FieldMetadataRelatedRecordsService; + let twentyORMGlobalManager: TwentyORMGlobalManager; + + beforeEach(() => { + twentyORMGlobalManager = {} as TwentyORMGlobalManager; + service = new FieldMetadataRelatedRecordsService(twentyORMGlobalManager); + }); + + const testCases: GetOptionsDifferencesTestContext[] = [ + { + title: 'should identify created options', + context: { + oldOptions: [ + { id: '1', label: 'Option 1', value: 'value1', position: 0 }, + ], + newOptions: [ + { id: '1', label: 'Option 1', value: 'value1', position: 0 }, + { id: '2', label: 'Option 2', value: 'value2', position: 1 }, + ], + expected: { + created: [ + { id: '2', label: 'Option 2', value: 'value2', position: 1 }, + ], + updated: [], + deleted: [], + }, + }, + }, + { + title: 'should identify updated options', + context: { + oldOptions: [ + { id: '1', label: 'Option 1', value: 'value1', position: 0 }, + ], + newOptions: [ + { + id: '1', + label: 'Option 1', + value: 'updated-value1', + position: 0, + }, + ], + expected: { + created: [], + updated: [ + { + old: { + id: '1', + label: 'Option 1', + value: 'value1', + position: 0, + }, + new: { + id: '1', + label: 'Option 1', + value: 'updated-value1', + position: 0, + }, + }, + ], + deleted: [], + }, + }, + }, + { + title: 'should identify deleted options', + context: { + oldOptions: [ + { id: '1', label: 'Option 1', value: 'value1', position: 0 }, + { id: '2', label: 'Option 2', value: 'value2', position: 1 }, + ], + newOptions: [ + { id: '1', label: 'Option 1', value: 'value1', position: 0 }, + ], + expected: { + created: [], + updated: [], + deleted: [ + { id: '2', label: 'Option 2', value: 'value2', position: 1 }, + ], + }, + }, + }, + { + title: 'should identify all types of changes', + context: { + oldOptions: [ + { id: '1', label: 'Option 1', value: 'value1', position: 0 }, + { id: '2', label: 'Option 2', value: 'value2', position: 1 }, + { id: '3', label: 'Option 3', value: 'value3', position: 2 }, + ], + newOptions: [ + { + id: '1', + label: 'Option 1', + value: 'updated-value1', + position: 0, + }, + { id: '3', label: 'Option 3', value: 'value3', position: 1 }, + { id: '4', label: 'Option 4', value: 'value4', position: 2 }, + ], + expected: { + created: [ + { id: '4', label: 'Option 4', value: 'value4', position: 2 }, + ], + updated: [ + { + old: { + id: '1', + label: 'Option 1', + value: 'value1', + position: 0, + }, + new: { + id: '1', + label: 'Option 1', + value: 'updated-value1', + position: 0, + }, + }, + ], + deleted: [ + { id: '2', label: 'Option 2', value: 'value2', position: 1 }, + ], + }, + }, + }, + { + title: 'should handle empty arrays', + context: { + oldOptions: [], + newOptions: [], + expected: { + created: [], + updated: [], + deleted: [], + }, + }, + }, + { + title: 'should handle all new options', + context: { + oldOptions: [], + newOptions: [ + { id: '1', label: 'Option 1', value: 'value1', position: 0 }, + { id: '2', label: 'Option 2', value: 'value2', position: 1 }, + ], + expected: { + created: [ + { id: '1', label: 'Option 1', value: 'value1', position: 0 }, + { id: '2', label: 'Option 2', value: 'value2', position: 1 }, + ], + updated: [], + deleted: [], + }, + }, + }, + { + title: 'should handle all deleted options', + context: { + oldOptions: [ + { id: '1', label: 'Option 1', value: 'value1', position: 0 }, + { id: '2', label: 'Option 2', value: 'value2', position: 1 }, + ], + newOptions: [], + expected: { + created: [], + updated: [], + deleted: [ + { id: '1', label: 'Option 1', value: 'value1', position: 0 }, + { id: '2', label: 'Option 2', value: 'value2', position: 1 }, + ], + }, + }, + }, + { + title: + 'should not consider changes to label as updates when value remains the same', + context: { + oldOptions: [ + { + id: 'f86eaffd-b773-4c9a-957b-86dca4a62731', + label: 'Option 0', + value: 'option0', + position: 1, + }, + { + id: '28d80b3c-79bd-4a1b-a868-9616534de0fa', + label: 'Option 1', + value: 'option1', + position: 2, + }, + { + id: '25a05cd8-256f-4652-9e4a-6d9ca0b96f4d', + label: 'Option 2', + value: 'option2', + position: 3, + }, + ], + newOptions: [ + { + id: 'f86eaffd-b773-4c9a-957b-86dca4a62731', + label: 'Option 0_UPDATED', // Label changed but value remains the same + value: 'option0', + position: 1, + }, + { + id: '28d80b3c-79bd-4a1b-a868-9616534de0fa', + label: 'Option 1', // No change + value: 'option1', + position: 2, + }, + { + id: '25a05cd8-256f-4652-9e4a-6d9ca0b96f4d', + label: 'Option 2_UPDATED', // Label changed but value remains the same + value: 'option2', + position: 3, + }, + ], + expected: { + created: [], + updated: [], // No updates because only labels changed, not values + deleted: [], + }, + }, + }, + { + title: + 'should consider changes to label as updates when value remains the same if compareLabel is true', + context: { + oldOptions: [ + { + id: 'f86eaffd-b773-4c9a-957b-86dca4a62731', + label: 'Option 0', + value: 'option0', + position: 1, + }, + { + id: '28d80b3c-79bd-4a1b-a868-9616534de0fa', + label: 'Option 1', + value: 'option1', + position: 2, + }, + { + id: '25a05cd8-256f-4652-9e4a-6d9ca0b96f4d', + label: 'Option 2', + value: 'option2', + position: 3, + }, + ], + newOptions: [ + { + id: 'f86eaffd-b773-4c9a-957b-86dca4a62731', + label: 'Option 0_UPDATED', // Label changed but value remains the same + value: 'option0', + position: 1, + }, + { + id: '28d80b3c-79bd-4a1b-a868-9616534de0fa', + label: 'Option 1', // No change + value: 'option1', + position: 2, + }, + { + id: '25a05cd8-256f-4652-9e4a-6d9ca0b96f4d', + label: 'Option 2_UPDATED', // Label changed but value remains the same + value: 'option2', + position: 3, + }, + ], + expected: { + created: [], + updated: [ + { + new: { + id: 'f86eaffd-b773-4c9a-957b-86dca4a62731', + label: 'Option 0_UPDATED', + position: 1, + value: 'option0', + }, + old: { + id: 'f86eaffd-b773-4c9a-957b-86dca4a62731', + label: 'Option 0', + position: 1, + value: 'option0', + }, + }, + { + new: { + id: '25a05cd8-256f-4652-9e4a-6d9ca0b96f4d', + label: 'Option 2_UPDATED', + position: 3, + value: 'option2', + }, + old: { + id: '25a05cd8-256f-4652-9e4a-6d9ca0b96f4d', + label: 'Option 2', + position: 3, + value: 'option2', + }, + }, + ], + deleted: [], + }, + compareLabel: true, + }, + }, + ]; + + test.each(testCases)( + '$title', + ({ context: { oldOptions, newOptions, expected, compareLabel } }) => { + const result = service.getOptionsDifferences( + oldOptions, + newOptions, + compareLabel, + ); + + expect(result.created).toEqual(expected.created); + expect(result.updated).toEqual(expected.updated); + expect(result.deleted).toEqual(expected.deleted); + }, + ); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-related-records.service.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-related-records.service.ts index b0dcaa658..3027dfc70 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-related-records.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-related-records.service.ts @@ -1,15 +1,22 @@ import { Injectable } from '@nestjs/common'; +import { MAX_OPTIONS_TO_DISPLAY } from 'twenty-shared/constants'; +import { isDefined, parseJson } from 'twenty-shared/utils'; import { In } from 'typeorm'; import { FieldMetadataComplexOption, FieldMetadataDefaultOption, } from 'src/engine/metadata-modules/field-metadata/dtos/options.input'; -import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { + FieldMetadataException, + FieldMetadataExceptionCode, +} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception'; import { isSelectFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-select-field-metadata-type.util'; +import { SelectOrMultiSelectFieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/utils/is-select-or-multi-select-field-metadata.util'; import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { ViewFilterWorkspaceEntity } from 'src/modules/view/standard-objects/view-filter.workspace-entity'; import { ViewGroupWorkspaceEntity } from 'src/modules/view/standard-objects/view-group.workspace-entity'; import { ViewWorkspaceEntity } from 'src/modules/view/standard-objects/view.workspace-entity'; @@ -19,6 +26,10 @@ type Differences = { deleted: T[]; }; +type GetOptionsDifferences = Differences< + FieldMetadataDefaultOption | FieldMetadataComplexOption +>; + @Injectable() export class FieldMetadataRelatedRecordsService { constructor( @@ -26,17 +37,20 @@ export class FieldMetadataRelatedRecordsService { ) {} public async updateRelatedViewGroups( - oldFieldMetadata: FieldMetadataEntity, - newFieldMetadata: FieldMetadataEntity, + oldFieldMetadata: SelectOrMultiSelectFieldMetadataEntity, + newFieldMetadata: SelectOrMultiSelectFieldMetadataEntity, ): Promise { + // TODO legacy should support multi-select and rating ? if ( !isSelectFieldMetadataType(newFieldMetadata.type) || !isSelectFieldMetadataType(oldFieldMetadata.type) ) { return; } - - const views = await this.getFieldMetadataViews(newFieldMetadata); + const views = await this.getFieldMetadataViewWithRelation( + newFieldMetadata, + 'viewGroups', + ); const { created, updated, deleted } = this.getOptionsDifferences( oldFieldMetadata.options, @@ -100,8 +114,113 @@ export class FieldMetadataRelatedRecordsService { } } + private computeViewFilterDisplayValue( + newViewFilterOptions: FieldMetadataDefaultOption[], + ): string { + if (newViewFilterOptions.length > MAX_OPTIONS_TO_DISPLAY) { + return `${newViewFilterOptions.length} options`; + } + + return newViewFilterOptions.map((option) => option.label).join(', '); + } + + public async updateRelatedViewFilters( + oldFieldMetadata: SelectOrMultiSelectFieldMetadataEntity, + newFieldMetadata: SelectOrMultiSelectFieldMetadataEntity, + ): Promise { + const views = await this.getFieldMetadataViewWithRelation( + newFieldMetadata, + 'viewFilters', + ); + + const alsoCompareLabel = true; + const { + updated: updatedFieldMetadataOptions, + deleted: deletedFieldMetadataOptions, + } = this.getOptionsDifferences( + oldFieldMetadata.options, + newFieldMetadata.options, + alsoCompareLabel, + ); + + if ( + updatedFieldMetadataOptions.length === 0 && + deletedFieldMetadataOptions.length === 0 + ) { + return; + } + + const viewFilterRepository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + newFieldMetadata.workspaceId, + 'viewFilter', + ); + + for (const filter of views) { + if (filter.viewFilters.length === 0) { + continue; + } + + for (const viewFilter of filter.viewFilters) { + const viewFilterValue = parseJson(viewFilter.value); + + // Note below assertion could be removed after https://github.com/twentyhq/core-team-issues/issues/1009 completion + if (!isDefined(viewFilterValue) || !Array.isArray(viewFilterValue)) { + throw new FieldMetadataException( + `Unexpected invalid view filter value for filter ${viewFilter.id}`, + FieldMetadataExceptionCode.INTERNAL_SERVER_ERROR, + ); + } + + const viewFilterOptions = viewFilterValue + .map((value) => + oldFieldMetadata.options.find((option) => option.value === value), + ) + .filter(isDefined); + + const afterDeleteViewFilterOptions = viewFilterOptions.filter( + (viewFilterOption) => + !deletedFieldMetadataOptions.some( + (option) => option.value === viewFilterOption.value, + ), + ); + + if (afterDeleteViewFilterOptions.length === 0) { + await viewFilterRepository.delete({ id: viewFilter.id }); + continue; + } + + const afterUpdateAndDeleteViewFilterOptions = + afterDeleteViewFilterOptions.map((viewFilterOption) => { + const updatedOption = updatedFieldMetadataOptions.find( + ({ old }) => viewFilterOption.value === old.value, + ); + + return isDefined(updatedOption) + ? updatedOption.new + : viewFilterOption; + }); + + const displayValue = this.computeViewFilterDisplayValue( + afterUpdateAndDeleteViewFilterOptions, + ); + const value = JSON.stringify( + afterUpdateAndDeleteViewFilterOptions.map((option) => option.value), + ); + + await viewFilterRepository.update( + { id: viewFilter.id }, + { + value, + displayValue, + }, + ); + } + } + } + async syncNoValueViewGroup( - fieldMetadata: FieldMetadataEntity, + fieldMetadata: SelectOrMultiSelectFieldMetadataEntity, view: ViewWorkspaceEntity, viewGroupRepository: WorkspaceRepository, ): Promise { @@ -125,10 +244,11 @@ export class FieldMetadataRelatedRecordsService { } } - private getOptionsDifferences( + public getOptionsDifferences( oldOptions: (FieldMetadataDefaultOption | FieldMetadataComplexOption)[], newOptions: (FieldMetadataDefaultOption | FieldMetadataComplexOption)[], - ): Differences { + compareLabel = false, + ): GetOptionsDifferences { const differences: Differences< FieldMetadataDefaultOption | FieldMetadataComplexOption > = { @@ -138,18 +258,26 @@ export class FieldMetadataRelatedRecordsService { }; const oldOptionsMap = new Map(oldOptions.map((opt) => [opt.id, opt])); - const newOptionsMap = new Map(newOptions.map((opt) => [opt.id, opt])); for (const newOption of newOptions) { const oldOption = oldOptionsMap.get(newOption.id); - if (!oldOption) { + if (!isDefined(oldOption)) { differences.created.push(newOption); - } else if (oldOption.value !== newOption.value) { + continue; + } + + if ( + oldOption.value !== newOption.value || + (compareLabel && oldOption.label !== newOption.label) + ) { differences.updated.push({ old: oldOption, new: newOption }); + continue; } } + const newOptionsMap = new Map(newOptions.map((opt) => [opt.id, opt])); + for (const oldOption of oldOptions) { if (!newOptionsMap.has(oldOption.id)) { differences.deleted.push(oldOption); @@ -159,8 +287,9 @@ export class FieldMetadataRelatedRecordsService { return differences; } - private async getFieldMetadataViews( - fieldMetadata: FieldMetadataEntity, + private async getFieldMetadataViewWithRelation( + fieldMetadata: SelectOrMultiSelectFieldMetadataEntity, + relation: keyof Pick, ): Promise { const viewRepository = await this.twentyORMGlobalManager.getRepositoryForWorkspace( @@ -170,11 +299,11 @@ export class FieldMetadataRelatedRecordsService { return viewRepository.find({ where: { - viewGroups: { + [relation]: { fieldMetadataId: fieldMetadata.id, }, }, - relations: ['viewGroups'], + relations: [relation], }); } diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/is-select-or-multi-select-field-metadata.util.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/is-select-or-multi-select-field-metadata.util.ts new file mode 100644 index 000000000..be36683f7 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/is-select-or-multi-select-field-metadata.util.ts @@ -0,0 +1,18 @@ +import { FieldMetadataType } from 'twenty-shared/types'; + +import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; + +export type SelectOrMultiSelectFieldMetadataEntity = FieldMetadataEntity< + FieldMetadataType.SELECT | FieldMetadataType.MULTI_SELECT +>; +export const isSelectOrMultiSelectFieldMetadata = ( + fieldMetadata: unknown, +): fieldMetadata is SelectOrMultiSelectFieldMetadataEntity => { + if (!(fieldMetadata instanceof FieldMetadataEntity)) { + return false; + } + + return [FieldMetadataType.SELECT, FieldMetadataType.MULTI_SELECT].includes( + fieldMetadata.type, + ); +}; diff --git a/packages/twenty-server/test/integration/graphql/utils/create-one-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/create-one-operation-factory.util.ts index f5f93ef26..a33c31fd8 100644 --- a/packages/twenty-server/test/integration/graphql/utils/create-one-operation-factory.util.ts +++ b/packages/twenty-server/test/integration/graphql/utils/create-one-operation-factory.util.ts @@ -13,13 +13,13 @@ export const createOneOperationFactory = ({ data = {}, }: CreateOneOperationFactoryParams) => ({ query: gql` - mutation Create${capitalize(objectMetadataSingularName)}($data: ${capitalize(objectMetadataSingularName)}CreateInput) { - create${capitalize(objectMetadataSingularName)}(data: $data) { + mutation CreateOne${capitalize(objectMetadataSingularName)}($input: ${capitalize(objectMetadataSingularName)}CreateInput!) { + create${capitalize(objectMetadataSingularName)}(data: $input) { ${gqlFields} } } `, variables: { - data, + input: data, }, }); diff --git a/packages/twenty-server/test/integration/graphql/utils/create-one-operation.util.ts b/packages/twenty-server/test/integration/graphql/utils/create-one-operation.util.ts new file mode 100644 index 000000000..b0e5a0bde --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/create-one-operation.util.ts @@ -0,0 +1,43 @@ +import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util'; +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; +import { CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type'; +import { PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type'; +import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util'; +import { capitalize } from 'twenty-shared/utils'; + +import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; + +type CreateOneOperationArgs = PerformMetadataQueryParams & { + objectMetadataSingularName: string; +}; +export const createOneOperation = async ({ + input, + gqlFields = 'id', + objectMetadataSingularName, + expectToFail = false, +}: CreateOneOperationArgs): CommonResponseBody<{ + createOneResponse: ObjectRecord; +}> => { + const graphqlOperation = createOneOperationFactory({ + data: input as object, // TODO default generic does not work + objectMetadataSingularName, + gqlFields, + }); + + const response = await makeGraphqlAPIRequest(graphqlOperation); + + if (expectToFail) { + warnIfNoErrorButExpectedToFail({ + response, + errorMessage: 'Create one operation should have failed but did not', + }); + } + + return { + data: { + createOneResponse: + response.body.data[`create${capitalize(objectMetadataSingularName)}`], + }, + errors: response.body.errors, + }; +}; diff --git a/packages/twenty-server/test/integration/graphql/utils/find-one-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/find-one-operation-factory.util.ts index bf561f593..0c100b894 100644 --- a/packages/twenty-server/test/integration/graphql/utils/find-one-operation-factory.util.ts +++ b/packages/twenty-server/test/integration/graphql/utils/find-one-operation-factory.util.ts @@ -4,7 +4,7 @@ import { capitalize } from 'twenty-shared/utils'; type FindOneOperationFactoryParams = { objectMetadataSingularName: string; gqlFields: string; - filter?: object; + filter?: unknown; }; export const findOneOperationFactory = ({ @@ -13,7 +13,7 @@ export const findOneOperationFactory = ({ filter = {}, }: FindOneOperationFactoryParams) => ({ query: gql` - query ${capitalize(objectMetadataSingularName)}($filter: ${capitalize(objectMetadataSingularName)}FilterInput) { + query FindOne${capitalize(objectMetadataSingularName)}($filter: ${capitalize(objectMetadataSingularName)}FilterInput!) { ${objectMetadataSingularName}(filter: $filter) { ${gqlFields} } diff --git a/packages/twenty-server/test/integration/graphql/utils/find-one-operation.util.ts b/packages/twenty-server/test/integration/graphql/utils/find-one-operation.util.ts new file mode 100644 index 000000000..5f17d92ca --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/find-one-operation.util.ts @@ -0,0 +1,40 @@ +import { findOneOperationFactory } from 'test/integration/graphql/utils/find-one-operation-factory.util'; +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; +import { CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type'; +import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util'; + +import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; + +type FindOneOperationArgs = Parameters[0] & { + expectToFail?: boolean; +}; +export const findOneOperation = async ({ + gqlFields = 'id', + objectMetadataSingularName, + expectToFail = false, + filter, +}: FindOneOperationArgs): CommonResponseBody<{ + findResponse: ObjectRecord; +}> => { + const graphqlOperation = findOneOperationFactory({ + objectMetadataSingularName, + gqlFields, + filter, + }); + + const response = await makeGraphqlAPIRequest(graphqlOperation); + + if (expectToFail) { + warnIfNoErrorButExpectedToFail({ + response, + errorMessage: 'Find one operation should have failed but did not', + }); + } + + return { + data: { + findResponse: response.body.data[objectMetadataSingularName], + }, + errors: response.body.errors, + }; +}; diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/__snapshots__/update-one-field-metadata-related-record.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/field-metadata/__snapshots__/update-one-field-metadata-related-record.integration-spec.ts.snap new file mode 100644 index 000000000..3539e5b85 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/__snapshots__/update-one-field-metadata-related-record.integration-spec.ts.snap @@ -0,0 +1,175 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`update-one-field-metadata-related-record MULTI_SELECT should delete related view filter if all select field options got deleted 1`] = ` +[ + { + "extensions": { + "code": "NOT_FOUND", + }, + "message": "Record not found", + }, +] +`; + +exports[`update-one-field-metadata-related-record MULTI_SELECT should handle adding new options while maintaining existing view filter 1`] = ` +{ + "displayValue": "2 options", + "id": Any, + "value": "["OPTION_0","OPTION_1"]", +} +`; + +exports[`update-one-field-metadata-related-record MULTI_SELECT should handle no changes update of options while maintaining existing view filter values 1`] = ` +{ + "displayValue": "10 options", + "id": Any, + "value": "["OPTION_0","OPTION_1","OPTION_2","OPTION_3","OPTION_4","OPTION_5","OPTION_6","OPTION_7","OPTION_8","OPTION_9"]", +} +`; + +exports[`update-one-field-metadata-related-record MULTI_SELECT should handle partial deletion of selected options in view filter 1`] = ` +{ + "displayValue": "6 options", + "id": Any, + "value": "["OPTION_4","OPTION_5","OPTION_6","OPTION_7","OPTION_8","OPTION_9"]", +} +`; + +exports[`update-one-field-metadata-related-record MULTI_SELECT should handle reordering of options while maintaining view filter values 1`] = ` +{ + "displayValue": "2 options", + "id": Any, + "value": "["OPTION_0","OPTION_1"]", +} +`; + +exports[`update-one-field-metadata-related-record MULTI_SELECT should throw error if view filter value is not a stringified JSON array 1`] = ` +[ + { + "extensions": { + "code": "INTERNAL_SERVER_ERROR", + "exceptionEventId": "mocked-exception-id", + }, + "message": "Unexpected invalid view filter value for filter 20202020-e3b5-4fa7-85aa-9b1950fc7bf5", + }, +] +`; + +exports[`update-one-field-metadata-related-record MULTI_SELECT should update display value with options label if less than 3 options are selected 1`] = ` +{ + "displayValue": "Option 8, Option 9", + "id": Any, + "value": "["OPTION_8","OPTION_9"]", +} +`; + +exports[`update-one-field-metadata-related-record MULTI_SELECT should update related multi selected options view filter 1`] = ` +{ + "displayValue": "10 options", + "id": Any, + "value": "["OPTION_0_UPDATED","OPTION_1","OPTION_2_UPDATED","OPTION_3","OPTION_4_UPDATED","OPTION_5","OPTION_6_UPDATED","OPTION_7","OPTION_8_UPDATED","OPTION_9"]", +} +`; + +exports[`update-one-field-metadata-related-record MULTI_SELECT should update related solo selected option view filter 1`] = ` +{ + "displayValue": "Option 5 updated", + "id": Any, + "value": "["OPTION_5_UPDATED"]", +} +`; + +exports[`update-one-field-metadata-related-record MULTI_SELECT should update the display value on an option label change only 1`] = ` +{ + "displayValue": "Option 0 updated, Option 1 updated, Option 2 updated", + "id": Any, + "value": "["OPTION_0","OPTION_1","OPTION_2"]", +} +`; + +exports[`update-one-field-metadata-related-record SELECT should delete related view filter if all select field options got deleted 1`] = ` +[ + { + "extensions": { + "code": "NOT_FOUND", + }, + "message": "Record not found", + }, +] +`; + +exports[`update-one-field-metadata-related-record SELECT should handle adding new options while maintaining existing view filter 1`] = ` +{ + "displayValue": "2 options", + "id": Any, + "value": "["OPTION_0","OPTION_1"]", +} +`; + +exports[`update-one-field-metadata-related-record SELECT should handle no changes update of options while maintaining existing view filter values 1`] = ` +{ + "displayValue": "10 options", + "id": Any, + "value": "["OPTION_0","OPTION_1","OPTION_2","OPTION_3","OPTION_4","OPTION_5","OPTION_6","OPTION_7","OPTION_8","OPTION_9"]", +} +`; + +exports[`update-one-field-metadata-related-record SELECT should handle partial deletion of selected options in view filter 1`] = ` +{ + "displayValue": "6 options", + "id": Any, + "value": "["OPTION_4","OPTION_5","OPTION_6","OPTION_7","OPTION_8","OPTION_9"]", +} +`; + +exports[`update-one-field-metadata-related-record SELECT should handle reordering of options while maintaining view filter values 1`] = ` +{ + "displayValue": "2 options", + "id": Any, + "value": "["OPTION_0","OPTION_1"]", +} +`; + +exports[`update-one-field-metadata-related-record SELECT should throw error if view filter value is not a stringified JSON array 1`] = ` +[ + { + "extensions": { + "code": "INTERNAL_SERVER_ERROR", + "exceptionEventId": "mocked-exception-id", + }, + "message": "Unexpected invalid view filter value for filter 20202020-e3b5-4fa7-85aa-9b1950fc7bf5", + }, +] +`; + +exports[`update-one-field-metadata-related-record SELECT should update display value with options label if less than 3 options are selected 1`] = ` +{ + "displayValue": "Option 8, Option 9", + "id": Any, + "value": "["OPTION_8","OPTION_9"]", +} +`; + +exports[`update-one-field-metadata-related-record SELECT should update related multi selected options view filter 1`] = ` +{ + "displayValue": "10 options", + "id": Any, + "value": "["OPTION_0_UPDATED","OPTION_1","OPTION_2_UPDATED","OPTION_3","OPTION_4_UPDATED","OPTION_5","OPTION_6_UPDATED","OPTION_7","OPTION_8_UPDATED","OPTION_9"]", +} +`; + +exports[`update-one-field-metadata-related-record SELECT should update related solo selected option view filter 1`] = ` +{ + "displayValue": "Option 5 updated", + "id": Any, + "value": "["OPTION_5_UPDATED"]", +} +`; + +exports[`update-one-field-metadata-related-record SELECT should update the display value on an option label change only 1`] = ` +{ + "displayValue": "Option 0 updated, Option 1 updated, Option 2 updated", + "id": Any, + "value": "["OPTION_0","OPTION_1","OPTION_2"]", +} +`; diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/create-one-field-metadata-select.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/create-one-field-metadata-select.integration-spec.ts index f42837bde..2d9baca16 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/create-one-field-metadata-select.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/create-one-field-metadata-select.integration-spec.ts @@ -7,12 +7,15 @@ import { LISTING_NAME_PLURAL, LISTING_NAME_SINGULAR, } from 'test/integration/metadata/suites/object-metadata/constants/test-object-names.constant'; +import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util'; import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util'; import { FieldMetadataType } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; -import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util'; -import { FieldMetadataComplexOption } from 'src/engine/metadata-modules/field-metadata/dtos/options.input'; +import { + FieldMetadataComplexOption, + FieldMetadataDefaultOption, +} from 'src/engine/metadata-modules/field-metadata/dtos/options.input'; const { failingTestCases, successfulTestCases } = UPDATE_CREATE_ONE_FIELD_METADATA_SELECT_TEST_CASES; @@ -62,8 +65,9 @@ describe('Field metadata select creation tests group', () => { expect(data).not.toBeNull(); expect(data.createOneField).toBeDefined(); - const createdOptions: FieldMetadataComplexOption[] = - data.createOneField.options; + const createdOptions: + | FieldMetadataDefaultOption[] + | FieldMetadataComplexOption[] = data.createOneField.options; const optionsToCompare = expectedOptions ?? input.options; diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata-related-record.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata-related-record.integration-spec.ts new file mode 100644 index 000000000..3d868d12a --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata-related-record.integration-spec.ts @@ -0,0 +1,381 @@ +import { faker } from '@faker-js/faker'; +import { isDefined } from 'class-validator'; +import { createOneOperation } from 'test/integration/graphql/utils/create-one-operation.util'; +import { findOneOperation } from 'test/integration/graphql/utils/find-one-operation.util'; +import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util'; +import { updateOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/update-one-field-metadata.util'; +import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util'; +import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util'; +import { getMockCreateObjectInput } from 'test/integration/metadata/suites/object-metadata/utils/generate-mock-create-object-metadata-input'; +import { EachTestingContext } from 'twenty-shared/testing'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { parseJson } from 'twenty-shared/utils'; + +import { + FieldMetadataComplexOption, + FieldMetadataDefaultOption, +} from 'src/engine/metadata-modules/field-metadata/dtos/options.input'; +import { EnumFieldMetadataType } from 'src/engine/metadata-modules/workspace-migration/factories/enum-column-action.factory'; + +type Option = FieldMetadataDefaultOption | FieldMetadataComplexOption; + +const generateOption = (index: number): Option => ({ + label: `Option ${index}`, + value: `OPTION_${index}`, + color: 'green', + position: index, +}); +const generateOptions = (length: number) => + Array.from({ length }, (_value, index) => generateOption(index)); +const updateOption = ({ value, label, ...option }: Option) => ({ + ...option, + value: `${value}_UPDATED`, + label: `${label} updated`, +}); + +const ALL_OPTIONS = generateOptions(10); + +const isEven = (_value: unknown, index: number) => index % 2 === 0; + +type ViewFilterUpdate = { + displayValue: string; + value: string[]; +}; + +type FieldMetadataOptionsAndType = { + options: Option[]; + type: EnumFieldMetadataType; +}; + +type TestCase = EachTestingContext<{ + fieldMetadata?: FieldMetadataOptionsAndType; + createViewFilter?: ViewFilterUpdate; + updateOptions: ( + options: FieldMetadataDefaultOption[] | FieldMetadataComplexOption[], + ) => FieldMetadataDefaultOption[] | FieldMetadataComplexOption[]; + expected?: null; +}>; +const testFieldMetadataType: EnumFieldMetadataType[] = [ + FieldMetadataType.SELECT, + FieldMetadataType.MULTI_SELECT, +]; + +describe('update-one-field-metadata-related-record', () => { + let idToDelete: string; + + const createObjectSelectFieldAndView = async ({ + options, + type: fieldMetadataType, + }: FieldMetadataOptionsAndType) => { + const singular = faker.lorem.words(); + const plural = singular + faker.lorem.word(); + const { + data: { createOneObject }, + } = await createOneObjectMetadata({ + input: getMockCreateObjectInput({ + labelSingular: singular, + labelPlural: plural, + nameSingular: singular.split(' ').join(''), + namePlural: plural.split(' ').join(''), + isLabelSyncedWithName: false, + }), + }); + + idToDelete = createOneObject.id; + + const { + data: { createOneField }, + } = await createOneFieldMetadata({ + input: { + objectMetadataId: createOneObject.id, + type: fieldMetadataType, + name: 'testName', + label: 'Test name', + isLabelSyncedWithName: true, + options, + }, + gqlFields: ` + id + options + `, + }); + + const { + data: { createOneResponse: createOneView }, + } = await createOneOperation<{ + id: string; + objectMetadataId: string; + type: string; + }>({ + objectMetadataSingularName: 'view', + input: { + id: faker.string.uuid(), + objectMetadataId: createOneObject.id, + type: 'table', + }, + }); + + return { createOneObject, createOneField, createOneView }; + }; + + afterEach(async () => { + if (isDefined(idToDelete)) { + await deleteOneObjectMetadata({ + input: { idToDelete: idToDelete }, + }); + } + }); + + describe.each(testFieldMetadataType)('%s', (fieldType) => { + const testCases: TestCase[] = [ + { + title: + 'should delete related view filter if all select field options got deleted', + context: { + updateOptions: () => generateOptions(3), + expected: null, + }, + }, + { + title: 'should update related multi selected options view filter', + context: { + updateOptions: (options) => + options.map((option, index) => + isEven(option, index) ? updateOption(option) : option, + ), + }, + }, + { + title: 'should update related solo selected option view filter', + context: { + createViewFilter: { + displayValue: ALL_OPTIONS[5].label, + value: [ALL_OPTIONS[5].value], + }, + updateOptions: (options) => [updateOption(options[5])], + }, + }, + { + title: + 'should handle partial deletion of selected options in view filter', + context: { + updateOptions: (options) => options.slice(4), + }, + }, + { + title: + 'should handle reordering of options while maintaining view filter values', + context: { + createViewFilter: { + displayValue: '2 options', + value: ALL_OPTIONS.slice(0, 2).map((option) => option.value), + }, + updateOptions: (options) => [...options].reverse(), + }, + }, + { + title: + 'should handle no changes update of options while maintaining existing view filter values', + context: { + updateOptions: (options) => options, + }, + }, + { + title: + 'should handle adding new options while maintaining existing view filter', + context: { + fieldMetadata: { + options: ALL_OPTIONS.slice(0, 5), + type: fieldType, + }, + createViewFilter: { + displayValue: '2 options', + value: ALL_OPTIONS.slice(0, 2).map((option) => option.value), + }, + updateOptions: (options) => [ + ...options, + ...generateOptions(6).slice(5), + ], + }, + }, + { + title: + 'should update display value with options label if less than 3 options are selected', + context: { + updateOptions: (options) => options.slice(8), + }, + }, + { + title: 'should update the display value on an option label change only', + context: { + createViewFilter: { + displayValue: 'Option 3', + value: ALL_OPTIONS.slice(0, 3).map((option) => option.value), + }, + updateOptions: (options) => + options.map((option) => ({ + ...option, + label: `${option.label} updated`, + })), + }, + }, + ]; + + test.each(testCases)( + '$title', + async ({ + context: { + expected, + createViewFilter = { + displayValue: '10 options', + value: ALL_OPTIONS.map((option) => option.value), + }, + fieldMetadata = { options: ALL_OPTIONS, type: fieldType }, + updateOptions, + }, + }) => { + const { createOneField, createOneView } = + await createObjectSelectFieldAndView(fieldMetadata); + const { + data: { createOneResponse: createOneViewFilter }, + } = await createOneOperation<{ + id: string; + viewId: string; + fieldMetadataId: string; + operand: string; + value: string; + displayValue: string; + }>({ + objectMetadataSingularName: 'viewFilter', + input: { + id: faker.string.uuid(), + viewId: createOneView.id, + fieldMetadataId: createOneField.id, + operand: 'is', + value: JSON.stringify(createViewFilter.value), + displayValue: createViewFilter.displayValue, + }, + }); + + const optionsWithIds = createOneField.options; + const updatedOptions = updateOptions(optionsWithIds); + + await updateOneFieldMetadata({ + input: { + idToUpdate: createOneField.id, + updatePayload: { + options: updatedOptions, + }, + }, + gqlFields: ` + id + options + `, + }); + + const { + data: { findResponse }, + errors, + } = await findOneOperation({ + gqlFields: ` + id + displayValue + value + `, + objectMetadataSingularName: 'viewFilter', + filter: { + id: { eq: createOneViewFilter.id }, + }, + }); + + if (expected !== undefined) { + expect(findResponse).toBe(expected); + expect(errors).toMatchSnapshot(); + + return; + } + + const parsedViewFilterValues = parseJson(findResponse.value); + + expect(parsedViewFilterValues).not.toBeNull(); + if (parsedViewFilterValues === null) { + throw new Error('Invariant parsedValue should not be null'); + } + expect(updatedOptions.map((option) => option.value)).toEqual( + expect.arrayContaining(parsedViewFilterValues), + ); + + expect(findResponse).toMatchSnapshot({ + id: expect.any(String), + }); + }, + ); + + // Note these test exists only because we do not validate the view filter value on creation/update + // Should be removed after https://github.com/twentyhq/core-team-issues/issues/1009 completion + const failingTestCases: EachTestingContext<{ + createViewFilterValue: unknown; + }>[] = [ + { + title: + 'should throw error if view filter value is not a stringified JSON array', + context: { + createViewFilterValue: JSON.stringify( + 'not an array stringified json', + ), + }, + }, + ]; + + test.each(failingTestCases)( + '$title', + async ({ context: { createViewFilterValue } }) => { + const { createOneField, createOneView } = + await createObjectSelectFieldAndView({ + options: ALL_OPTIONS, + type: fieldType, + }); + + const viewFilterId = '20202020-e3b5-4fa7-85aa-9b1950fc7bf5'; + + await createOneOperation<{ + id: string; + viewId: string; + fieldMetadataId: string; + operand: string; + value: string; + displayValue: string; + }>({ + objectMetadataSingularName: 'viewFilter', + input: { + id: viewFilterId, + viewId: createOneView.id, + fieldMetadataId: createOneField.id, + operand: 'is', + value: createViewFilterValue as unknown as string, + displayValue: '10 options', + }, + }); + + const optionsWithIds = createOneField.options; + const updatePayload = { + options: optionsWithIds.map((option) => updateOption(option)), + }; + const { errors, data } = await updateOneFieldMetadata({ + input: { + idToUpdate: createOneField.id, + updatePayload, + }, + gqlFields: ` + id + options + `, + }); + + expect(data).toBeNull(); + expect(errors).toBeDefined(); + expect(errors).toMatchSnapshot(); + }, + ); + }); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata-select.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata-select.integration-spec.ts index 24d01e545..004baca87 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata-select.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata-select.integration-spec.ts @@ -9,12 +9,15 @@ import { LISTING_NAME_PLURAL, LISTING_NAME_SINGULAR, } from 'test/integration/metadata/suites/object-metadata/constants/test-object-names.constant'; +import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util'; import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util'; import { FieldMetadataType } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; -import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util'; -import { FieldMetadataComplexOption } from 'src/engine/metadata-modules/field-metadata/dtos/options.input'; +import { + FieldMetadataComplexOption, + FieldMetadataDefaultOption, +} from 'src/engine/metadata-modules/field-metadata/dtos/options.input'; const { failingTestCases, successfulTestCases } = UPDATE_CREATE_ONE_FIELD_METADATA_SELECT_TEST_CASES; @@ -146,8 +149,9 @@ describe('Field metadata select update tests group', () => { }); expect(data.updateOneField).toBeDefined(); - const updatedOptions: FieldMetadataComplexOption[] = - data.updateOneField.options; + const updatedOptions: + | FieldMetadataComplexOption[] + | FieldMetadataDefaultOption[] = data.updateOneField.options; expect(errors).toBeUndefined(); updatedOptions.forEach((option) => expect(option.id).toBeDefined()); diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util.ts index 4196347fe..0a49d10f5 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util.ts +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util.ts @@ -3,14 +3,19 @@ import { createOneFieldMetadataQueryFactory, } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata-query-factory.util'; import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; +import { CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type'; import { PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type'; import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util'; +import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; + export const createOneFieldMetadata = async ({ input, gqlFields, expectToFail = false, -}: PerformMetadataQueryParams) => { +}: PerformMetadataQueryParams): CommonResponseBody<{ + createOneField: FieldMetadataEntity; +}> => { const graphqlOperation = createOneFieldMetadataQueryFactory({ input, gqlFields, diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/utils/update-one-field-metadata.util.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/utils/update-one-field-metadata.util.ts index 08e90d53a..11183ebd3 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/utils/update-one-field-metadata.util.ts +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/utils/update-one-field-metadata.util.ts @@ -3,14 +3,19 @@ import { UpdateOneFieldFactoryInput, updateOneFieldMetadataQueryFactory, } from 'test/integration/metadata/suites/field-metadata/utils/update-one-field-metadata-query-factory.util'; +import { CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type'; import { PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type'; import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util'; +import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; + export const updateOneFieldMetadata = async ({ input, gqlFields, expectToFail = false, -}: PerformMetadataQueryParams) => { +}: PerformMetadataQueryParams): CommonResponseBody<{ + updateOneField: FieldMetadataEntity; +}> => { const graphqlOperation = updateOneFieldMetadataQueryFactory({ input, gqlFields, diff --git a/packages/twenty-server/test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util.ts b/packages/twenty-server/test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util.ts index 07a0ffb9e..b8da9dff4 100644 --- a/packages/twenty-server/test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util.ts +++ b/packages/twenty-server/test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util.ts @@ -3,14 +3,19 @@ import { createOneObjectMetadataQueryFactory, } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata-query-factory.util'; import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; +import { CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type'; import { PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type'; import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util'; +import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; + export const createOneObjectMetadata = async ({ input, gqlFields, expectToFail = false, -}: PerformMetadataQueryParams) => { +}: PerformMetadataQueryParams): CommonResponseBody<{ + createOneObject: ObjectMetadataEntity; // not accurate +}> => { const graphqlOperation = createOneObjectMetadataQueryFactory({ input, gqlFields, diff --git a/packages/twenty-server/test/integration/metadata/types/common-response-body.type.ts b/packages/twenty-server/test/integration/metadata/types/common-response-body.type.ts new file mode 100644 index 000000000..11141aaa6 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/types/common-response-body.type.ts @@ -0,0 +1,6 @@ +import { BaseGraphQLError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; + +export type CommonResponseBody = Promise<{ + data: T; + errors: BaseGraphQLError[]; +}>; diff --git a/packages/twenty-shared/src/constants/FieldMetadataMaxOptionsToDisplay.ts b/packages/twenty-shared/src/constants/FieldMetadataMaxOptionsToDisplay.ts new file mode 100644 index 000000000..22b9640fb --- /dev/null +++ b/packages/twenty-shared/src/constants/FieldMetadataMaxOptionsToDisplay.ts @@ -0,0 +1 @@ +export const MAX_OPTIONS_TO_DISPLAY = 3; diff --git a/packages/twenty-shared/src/constants/index.ts b/packages/twenty-shared/src/constants/index.ts index da79b0d6c..e2ae5ead0 100644 --- a/packages/twenty-shared/src/constants/index.ts +++ b/packages/twenty-shared/src/constants/index.ts @@ -8,6 +8,7 @@ */ export { FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION } from './FieldForTotalCountAggregateOperation'; +export { MAX_OPTIONS_TO_DISPLAY } from './FieldMetadataMaxOptionsToDisplay'; export { FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED } from './FieldRestrictedAdditionalPermissionsRequired'; export { PermissionsOnAllObjectRecords } from './PermissionsOnAllObjectRecords'; export { QUERY_MAX_RECORDS } from './QueryMaxRecords'; diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts index f12ae0cd4..2ad817d17 100644 --- a/packages/twenty-shared/src/utils/index.ts +++ b/packages/twenty-shared/src/utils/index.ts @@ -14,6 +14,7 @@ export { sanitizeURL, getLogoUrlFromDomainName, } from './image/getLogoUrlFromDomainName'; +export { parseJson } from './parseJson'; export { capitalize } from './strings/capitalize'; export { absoluteUrlSchema } from './url/absoluteUrlSchema'; export { buildSignedPath } from './url/buildSignedPath'; diff --git a/packages/twenty-shared/src/utils/parseJson.ts b/packages/twenty-shared/src/utils/parseJson.ts new file mode 100644 index 000000000..4a463c2ed --- /dev/null +++ b/packages/twenty-shared/src/utils/parseJson.ts @@ -0,0 +1,7 @@ +export const parseJson = (json: string): T | null => { + try { + return JSON.parse(json); + } catch (e) { + return null; + } +}; diff --git a/yarn.lock b/yarn.lock index 8b3d93e12..b9a44a433 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7172,6 +7172,13 @@ __metadata: languageName: node linkType: hard +"@faker-js/faker@npm:^9.8.0": + version: 9.8.0 + resolution: "@faker-js/faker@npm:9.8.0" + checksum: 10c0/f5db1125c1ea0115b9142fb4d4cb37cac2da4cd12ed02afb1cabf3b9600bd365bf480386250975a4fb200d77c00e8364dc8f61ac273a9dcdd2f2f42c3b29b0ec + languageName: node + linkType: hard + "@fal-works/esbuild-plugin-global-externals@npm:^2.1.2": version: 2.1.2 resolution: "@fal-works/esbuild-plugin-global-externals@npm:2.1.2" @@ -55600,6 +55607,7 @@ __metadata: dependencies: "@clickhouse/client": "npm:^1.11.0" "@esbuild-plugins/node-modules-polyfill": "npm:^0.2.2" + "@faker-js/faker": "npm:^9.8.0" "@graphql-yoga/nestjs": "patch:@graphql-yoga/nestjs@2.1.0#./patches/@graphql-yoga+nestjs+2.1.0.patch" "@langchain/mistralai": "npm:^0.0.24" "@langchain/openai": "npm:^0.1.3"