From d468c3dc844a066a9953bff3f26151d8c3637194 Mon Sep 17 00:00:00 2001 From: Lucas Bordeau Date: Thu, 24 Jul 2025 12:08:16 +0200 Subject: [PATCH] Add ability to save any field filter to view (#13401) This PR adds the ability to save an any field filter to a view. It adds a new `anyFieldFilterValue` on both core view and workspace view entities. It also introduces the necessary utils that mimic the logic that manages the save of record filters and record sorts on views. --- .../findAllViewsOperationSignatureFactory.ts | 1 + .../components/UpdateViewButtonGroup.tsx | 7 ++- .../src/modules/views/components/ViewBar.tsx | 2 + .../ViewBarAnyFieldFilterEffect.tsx | 58 +++++++++++++++++++ .../views/components/ViewBarDetails.tsx | 20 +++++-- ...rrentViewAnyFieldFilterToAnyFieldFilter.ts | 39 +++++++++++++ .../hooks/useCreateViewFromCurrentView.ts | 7 +++ ...ilterDifferentFromCurrentAnyFieldFilter.ts | 18 ++++++ .../hooks/useSaveAnyFieldFilterToView.ts | 43 ++++++++++++++ .../useSaveCurrentViewFiltersAndSorts.ts | 4 ++ ...lizedAnyFieldFilterComponentFamilyState.ts | 9 +++ .../src/modules/views/types/GraphQLView.ts | 1 + .../src/modules/views/types/View.ts | 1 + .../__tests__/compareNonEmptyString.test.ts | 51 ++++++++++++++++ .../src/utils/compareNonEmptyStrings.ts | 13 +++++ .../migrate-views-to-core.command.ts | 1 + ...8-createAnyFieldFilterValueColumnOnView.ts | 19 ++++++ .../metadata-modules/view/view.entity.ts | 3 + .../constants/standard-field-ids.ts | 1 + .../standard-objects/view.workspace-entity.ts | 10 ++++ 20 files changed, 302 insertions(+), 6 deletions(-) create mode 100644 packages/twenty-front/src/modules/views/components/ViewBarAnyFieldFilterEffect.tsx create mode 100644 packages/twenty-front/src/modules/views/hooks/useApplyCurrentViewAnyFieldFilterToAnyFieldFilter.ts create mode 100644 packages/twenty-front/src/modules/views/hooks/useIsViewAnyFieldFilterDifferentFromCurrentAnyFieldFilter.ts create mode 100644 packages/twenty-front/src/modules/views/hooks/useSaveAnyFieldFilterToView.ts create mode 100644 packages/twenty-front/src/modules/views/states/hasInitializedAnyFieldFilterComponentFamilyState.ts create mode 100644 packages/twenty-front/src/utils/__tests__/compareNonEmptyString.test.ts create mode 100644 packages/twenty-front/src/utils/compareNonEmptyStrings.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1753349164408-createAnyFieldFilterValueColumnOnView.ts diff --git a/packages/twenty-front/src/modules/prefetch/graphql/operation-signatures/factories/findAllViewsOperationSignatureFactory.ts b/packages/twenty-front/src/modules/prefetch/graphql/operation-signatures/factories/findAllViewsOperationSignatureFactory.ts index 0238ab99b..0b96d95ad 100644 --- a/packages/twenty-front/src/modules/prefetch/graphql/operation-signatures/factories/findAllViewsOperationSignatureFactory.ts +++ b/packages/twenty-front/src/modules/prefetch/graphql/operation-signatures/factories/findAllViewsOperationSignatureFactory.ts @@ -25,5 +25,6 @@ export const findAllViewsOperationSignatureFactory: RecordGqlOperationSignatureF viewSorts: true, viewFields: true, viewGroups: true, + anyFieldFilterValue: true, }, }); diff --git a/packages/twenty-front/src/modules/views/components/UpdateViewButtonGroup.tsx b/packages/twenty-front/src/modules/views/components/UpdateViewButtonGroup.tsx index 879e5a3f7..9778dfc92 100644 --- a/packages/twenty-front/src/modules/views/components/UpdateViewButtonGroup.tsx +++ b/packages/twenty-front/src/modules/views/components/UpdateViewButtonGroup.tsx @@ -14,6 +14,7 @@ import { useAreViewFilterGroupsDifferentFromRecordFilterGroups } from '@/views/h import { useAreViewFiltersDifferentFromRecordFilters } from '@/views/hooks/useAreViewFiltersDifferentFromRecordFilters'; import { useAreViewSortsDifferentFromRecordSorts } from '@/views/hooks/useAreViewSortsDifferentFromRecordSorts'; import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly'; +import { useIsViewAnyFieldFilterDifferentFromCurrentAnyFieldFilter } from '@/views/hooks/useIsViewAnyFieldFilterDifferentFromCurrentAnyFieldFilter'; import { useSaveCurrentViewFiltersAndSorts } from '@/views/hooks/useSaveCurrentViewFiltersAndSorts'; import { VIEW_PICKER_DROPDOWN_ID } from '@/views/view-picker/constants/ViewPickerDropdownId'; import { useViewPickerMode } from '@/views/view-picker/hooks/useViewPickerMode'; @@ -84,10 +85,14 @@ export const UpdateViewButtonGroup = () => { const { viewSortsAreDifferentFromRecordSorts } = useAreViewSortsDifferentFromRecordSorts(); + const { viewAnyFieldFilterDifferentFromCurrentAnyFieldFilter } = + useIsViewAnyFieldFilterDifferentFromCurrentAnyFieldFilter(); + const canShowButton = (viewFiltersAreDifferentFromRecordFilters || viewSortsAreDifferentFromRecordSorts || - viewFilterGroupsAreDifferentFromRecordFilterGroups) && + viewFilterGroupsAreDifferentFromRecordFilterGroups || + viewAnyFieldFilterDifferentFromCurrentAnyFieldFilter) && !hasFiltersQueryParams; if (!canShowButton) { diff --git a/packages/twenty-front/src/modules/views/components/ViewBar.tsx b/packages/twenty-front/src/modules/views/components/ViewBar.tsx index 3aaa46c33..f78a212d5 100644 --- a/packages/twenty-front/src/modules/views/components/ViewBar.tsx +++ b/packages/twenty-front/src/modules/views/components/ViewBar.tsx @@ -12,6 +12,7 @@ import { ViewPickerDropdown } from '@/views/view-picker/components/ViewPickerDro import { ObjectFilterDropdownComponentInstanceContext } from '@/object-record/object-filter-dropdown/states/contexts/ObjectFilterDropdownComponentInstanceContext'; import { VIEW_SORT_DROPDOWN_ID } from '@/object-record/object-sort-dropdown/constants/ViewSortDropdownId'; import { ObjectSortDropdownComponentInstanceContext } from '@/object-record/object-sort-dropdown/states/context/ObjectSortDropdownComponentInstanceContext'; +import { ViewBarAnyFieldFilterEffect } from '@/views/components/ViewBarAnyFieldFilterEffect'; import { ViewBarFilterDropdown } from '@/views/components/ViewBarFilterDropdown'; import { ViewBarRecordFilterEffect } from '@/views/components/ViewBarRecordFilterEffect'; import { ViewBarRecordFilterGroupEffect } from '@/views/components/ViewBarRecordFilterGroupEffect'; @@ -43,6 +44,7 @@ export const ViewBar = ({ value={{ instanceId: VIEW_SORT_DROPDOWN_ID }} > + diff --git a/packages/twenty-front/src/modules/views/components/ViewBarAnyFieldFilterEffect.tsx b/packages/twenty-front/src/modules/views/components/ViewBarAnyFieldFilterEffect.tsx new file mode 100644 index 000000000..a3782042f --- /dev/null +++ b/packages/twenty-front/src/modules/views/components/ViewBarAnyFieldFilterEffect.tsx @@ -0,0 +1,58 @@ +import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState'; +import { anyFieldFilterValueComponentState } from '@/object-record/record-filter/states/anyFieldFilterValueComponentState'; +import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; +import { prefetchViewFromViewIdFamilySelector } from '@/prefetch/states/selector/prefetchViewFromViewIdFamilySelector'; +import { useRecoilComponentFamilyStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyStateV2'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; +import { hasInitializedAnyFieldFilterComponentFamilyState } from '@/views/states/hasInitializedAnyFieldFilterComponentFamilyState'; +import { useEffect } from 'react'; +import { useRecoilValue } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; + +export const ViewBarAnyFieldFilterEffect = () => { + const currentViewId = useRecoilComponentValueV2( + contextStoreCurrentViewIdComponentState, + ); + + const { objectMetadataItem } = useRecordIndexContextOrThrow(); + + const currentView = useRecoilValue( + prefetchViewFromViewIdFamilySelector({ + viewId: currentViewId ?? '', + }), + ); + + const [hasInitializedAnyFieldFilter, setHasInitializedAnyFieldFilter] = + useRecoilComponentFamilyStateV2( + hasInitializedAnyFieldFilterComponentFamilyState, + { + viewId: currentViewId ?? undefined, + }, + ); + + const setAnyFieldFilterValue = useSetRecoilComponentStateV2( + anyFieldFilterValueComponentState, + ); + + useEffect(() => { + if (!hasInitializedAnyFieldFilter && isDefined(currentView)) { + if (currentView.objectMetadataId !== objectMetadataItem.id) { + return; + } + + setAnyFieldFilterValue(currentView.anyFieldFilterValue ?? ''); + + setHasInitializedAnyFieldFilter(true); + } + }, [ + setAnyFieldFilterValue, + currentViewId, + hasInitializedAnyFieldFilter, + setHasInitializedAnyFieldFilter, + currentView, + objectMetadataItem, + ]); + + return null; +}; diff --git a/packages/twenty-front/src/modules/views/components/ViewBarDetails.tsx b/packages/twenty-front/src/modules/views/components/ViewBarDetails.tsx index 8f0c5bd97..97475f9f4 100644 --- a/packages/twenty-front/src/modules/views/components/ViewBarDetails.tsx +++ b/packages/twenty-front/src/modules/views/components/ViewBarDetails.tsx @@ -27,8 +27,10 @@ import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDrop import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; import { AnyFieldSearchDropdownButton } from '@/views/components/AnyFieldSearchDropdownButton'; import { ANY_FIELD_SEARCH_DROPDOWN_ID } from '@/views/constants/AnyFieldSearchDropdownId'; +import { useApplyCurrentViewAnyFieldFilterToAnyFieldFilter } from '@/views/hooks/useApplyCurrentViewAnyFieldFilterToAnyFieldFilter'; import { useApplyCurrentViewFilterGroupsToCurrentRecordFilterGroups } from '@/views/hooks/useApplyCurrentViewFilterGroupsToCurrentRecordFilterGroups'; import { useAreViewFilterGroupsDifferentFromRecordFilterGroups } from '@/views/hooks/useAreViewFilterGroupsDifferentFromRecordFilterGroups'; +import { useIsViewAnyFieldFilterDifferentFromCurrentAnyFieldFilter } from '@/views/hooks/useIsViewAnyFieldFilterDifferentFromCurrentAnyFieldFilter'; import { isViewBarExpandedComponentState } from '@/views/states/isViewBarExpandedComponentState'; import { t } from '@lingui/core/macro'; import { isNonEmptyArray, isNonEmptyString } from '@sniptt/guards'; @@ -144,11 +146,8 @@ export const ViewBarDetails = ({ const { viewSortsAreDifferentFromRecordSorts } = useAreViewSortsDifferentFromRecordSorts(); - const canResetView = - (viewFiltersAreDifferentFromRecordFilters || - viewSortsAreDifferentFromRecordSorts || - viewFilterGroupsAreDifferentFromRecordFilterGroups) && - !hasFiltersQueryParams; + const { viewAnyFieldFilterDifferentFromCurrentAnyFieldFilter } = + useIsViewAnyFieldFilterDifferentFromCurrentAnyFieldFilter(); const { checkIsSoftDeleteFilter } = useCheckIsSoftDeleteFilter(); @@ -170,6 +169,9 @@ export const ViewBarDetails = ({ const { applyCurrentViewFiltersToCurrentRecordFilters } = useApplyCurrentViewFiltersToCurrentRecordFilters(); + const { applyCurrentViewAnyFieldFilterToAnyFieldFilter } = + useApplyCurrentViewAnyFieldFilterToAnyFieldFilter(); + const { applyCurrentViewSortsToCurrentRecordSorts } = useApplyCurrentViewSortsToCurrentRecordSorts(); @@ -177,6 +179,7 @@ export const ViewBarDetails = ({ applyCurrentViewFilterGroupsToCurrentRecordFilterGroups(); applyCurrentViewFiltersToCurrentRecordFilters(); applyCurrentViewSortsToCurrentRecordSorts(); + applyCurrentViewAnyFieldFilterToAnyFieldFilter(); toggleSoftDeleteFilterState(false); }; @@ -188,6 +191,13 @@ export const ViewBarDetails = ({ ANY_FIELD_SEARCH_DROPDOWN_ID, ); + const canResetView = + (viewFiltersAreDifferentFromRecordFilters || + viewSortsAreDifferentFromRecordSorts || + viewFilterGroupsAreDifferentFromRecordFilterGroups || + viewAnyFieldFilterDifferentFromCurrentAnyFieldFilter) && + !hasFiltersQueryParams; + const shouldShowAnyFieldSearchChip = isNonEmptyString(anyFieldFilterValue) || isAnyFieldSearchDropdownOpen; diff --git a/packages/twenty-front/src/modules/views/hooks/useApplyCurrentViewAnyFieldFilterToAnyFieldFilter.ts b/packages/twenty-front/src/modules/views/hooks/useApplyCurrentViewAnyFieldFilterToAnyFieldFilter.ts new file mode 100644 index 000000000..1e56d826b --- /dev/null +++ b/packages/twenty-front/src/modules/views/hooks/useApplyCurrentViewAnyFieldFilterToAnyFieldFilter.ts @@ -0,0 +1,39 @@ +import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState'; +import { anyFieldFilterValueComponentState } from '@/object-record/record-filter/states/anyFieldFilterValueComponentState'; +import { prefetchViewFromViewIdFamilySelector } from '@/prefetch/states/selector/prefetchViewFromViewIdFamilySelector'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; +import { useRecoilCallback } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; + +export const useApplyCurrentViewAnyFieldFilterToAnyFieldFilter = () => { + const currentViewId = useRecoilComponentValueV2( + contextStoreCurrentViewIdComponentState, + ); + + const setAnyFieldFilterValue = useSetRecoilComponentStateV2( + anyFieldFilterValueComponentState, + ); + + const applyCurrentViewAnyFieldFilterToAnyFieldFilter = useRecoilCallback( + ({ snapshot }) => + () => { + const currentView = snapshot + .getLoadable( + prefetchViewFromViewIdFamilySelector({ + viewId: currentViewId ?? '', + }), + ) + .getValue(); + + if (isDefined(currentView)) { + setAnyFieldFilterValue(currentView.anyFieldFilterValue ?? ''); + } + }, + [currentViewId, setAnyFieldFilterValue], + ); + + return { + applyCurrentViewAnyFieldFilterToAnyFieldFilter, + }; +}; diff --git a/packages/twenty-front/src/modules/views/hooks/useCreateViewFromCurrentView.ts b/packages/twenty-front/src/modules/views/hooks/useCreateViewFromCurrentView.ts index fa04fd585..e5b851914 100644 --- a/packages/twenty-front/src/modules/views/hooks/useCreateViewFromCurrentView.ts +++ b/packages/twenty-front/src/modules/views/hooks/useCreateViewFromCurrentView.ts @@ -3,6 +3,7 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord'; import { useLazyFindManyRecords } from '@/object-record/hooks/useLazyFindManyRecords'; import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState'; +import { anyFieldFilterValueComponentState } from '@/object-record/record-filter/states/anyFieldFilterValueComponentState'; import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState'; @@ -40,6 +41,10 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => { objectNameSingular: CoreObjectNameSingular.View, }); + const anyFieldFilterValue = useRecoilComponentValueV2( + anyFieldFilterValueComponentState, + ); + const { createViewFieldRecords } = usePersistViewFieldRecords(); const { createViewSortRecords } = usePersistViewSortRecords(); @@ -126,6 +131,7 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => { type: type ?? sourceView.type, objectMetadataId: sourceView.objectMetadataId, openRecordIn: sourceView.openRecordIn, + anyFieldFilterValue: anyFieldFilterValue, }); if (isUndefinedOrNull(newView)) { @@ -209,6 +215,7 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => { set(isPersistingViewFieldsState, false); }, [ + anyFieldFilterValue, currentViewIdCallbackState, createOneRecord, createViewFieldRecords, diff --git a/packages/twenty-front/src/modules/views/hooks/useIsViewAnyFieldFilterDifferentFromCurrentAnyFieldFilter.ts b/packages/twenty-front/src/modules/views/hooks/useIsViewAnyFieldFilterDifferentFromCurrentAnyFieldFilter.ts new file mode 100644 index 000000000..79832d875 --- /dev/null +++ b/packages/twenty-front/src/modules/views/hooks/useIsViewAnyFieldFilterDifferentFromCurrentAnyFieldFilter.ts @@ -0,0 +1,18 @@ +import { anyFieldFilterValueComponentState } from '@/object-record/record-filter/states/anyFieldFilterValueComponentState'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly'; +import { compareNonEmptyStrings } from '~/utils/compareNonEmptyStrings'; + +export const useIsViewAnyFieldFilterDifferentFromCurrentAnyFieldFilter = () => { + const { currentView } = useGetCurrentViewOnly(); + const anyFieldFilterValue = useRecoilComponentValueV2( + anyFieldFilterValueComponentState, + ); + + const viewAnyFieldFilterValue = currentView?.anyFieldFilterValue; + + const viewAnyFieldFilterDifferentFromCurrentAnyFieldFilter = + !compareNonEmptyStrings(viewAnyFieldFilterValue, anyFieldFilterValue); + + return { viewAnyFieldFilterDifferentFromCurrentAnyFieldFilter }; +}; diff --git a/packages/twenty-front/src/modules/views/hooks/useSaveAnyFieldFilterToView.ts b/packages/twenty-front/src/modules/views/hooks/useSaveAnyFieldFilterToView.ts new file mode 100644 index 000000000..465f16ffe --- /dev/null +++ b/packages/twenty-front/src/modules/views/hooks/useSaveAnyFieldFilterToView.ts @@ -0,0 +1,43 @@ +import { anyFieldFilterValueComponentState } from '@/object-record/record-filter/states/anyFieldFilterValueComponentState'; +import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; +import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly'; +import { useUpdateView } from '@/views/hooks/useUpdateView'; +import { useRecoilCallback } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; + +export const useSaveAnyFieldFilterToView = () => { + const { updateView } = useUpdateView(); + + const { currentView } = useGetCurrentViewOnly(); + + const anyFieldFilterValueCallbackState = useRecoilComponentCallbackStateV2( + anyFieldFilterValueComponentState, + ); + + const saveAnyFieldFilterToView = useRecoilCallback( + ({ snapshot }) => + async () => { + if (!isDefined(currentView)) { + return; + } + + const currentViewAnyFieldFilterValue = currentView?.anyFieldFilterValue; + + const currentAnyFieldFilterValue = snapshot + .getLoadable(anyFieldFilterValueCallbackState) + .getValue(); + + if (currentAnyFieldFilterValue !== currentViewAnyFieldFilterValue) { + await updateView({ + ...currentView, + anyFieldFilterValue: currentAnyFieldFilterValue, + }); + } + }, + [updateView, anyFieldFilterValueCallbackState, currentView], + ); + + return { + saveAnyFieldFilterToView, + }; +}; diff --git a/packages/twenty-front/src/modules/views/hooks/useSaveCurrentViewFiltersAndSorts.ts b/packages/twenty-front/src/modules/views/hooks/useSaveCurrentViewFiltersAndSorts.ts index 4e48feba0..1eebfa39f 100644 --- a/packages/twenty-front/src/modules/views/hooks/useSaveCurrentViewFiltersAndSorts.ts +++ b/packages/twenty-front/src/modules/views/hooks/useSaveCurrentViewFiltersAndSorts.ts @@ -1,3 +1,4 @@ +import { useSaveAnyFieldFilterToView } from '@/views/hooks/useSaveAnyFieldFilterToView'; import { useSaveRecordFilterGroupsToViewFilterGroups } from '@/views/hooks/useSaveRecordFilterGroupsToViewFilterGroups'; import { useSaveRecordFiltersToViewFilters } from '@/views/hooks/useSaveRecordFiltersToViewFilters'; import { useSaveRecordSortsToViewSorts } from '@/views/hooks/useSaveRecordSortsToViewSorts'; @@ -11,10 +12,13 @@ export const useSaveCurrentViewFiltersAndSorts = () => { const { saveRecordSortsToViewSorts } = useSaveRecordSortsToViewSorts(); + const { saveAnyFieldFilterToView } = useSaveAnyFieldFilterToView(); + const saveCurrentViewFilterAndSorts = async () => { await saveRecordSortsToViewSorts(); await saveRecordFiltersToViewFilters(); await saveRecordFilterGroupsToViewFilterGroups(); + await saveAnyFieldFilterToView(); }; return { diff --git a/packages/twenty-front/src/modules/views/states/hasInitializedAnyFieldFilterComponentFamilyState.ts b/packages/twenty-front/src/modules/views/states/hasInitializedAnyFieldFilterComponentFamilyState.ts new file mode 100644 index 000000000..66c1943a2 --- /dev/null +++ b/packages/twenty-front/src/modules/views/states/hasInitializedAnyFieldFilterComponentFamilyState.ts @@ -0,0 +1,9 @@ +import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext'; +import { createComponentFamilyStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentFamilyStateV2'; + +export const hasInitializedAnyFieldFilterComponentFamilyState = + createComponentFamilyStateV2({ + key: 'hasInitializedAnyFieldFilterComponentFamilyState', + defaultValue: false, + componentInstanceContext: RecordFiltersComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/views/types/GraphQLView.ts b/packages/twenty-front/src/modules/views/types/GraphQLView.ts index 9fa643df2..3660b6a5c 100644 --- a/packages/twenty-front/src/modules/views/types/GraphQLView.ts +++ b/packages/twenty-front/src/modules/views/types/GraphQLView.ts @@ -29,4 +29,5 @@ export type GraphQLView = { viewGroups: ViewGroup[]; position: number; icon: string; + anyFieldFilterValue?: string | null; }; diff --git a/packages/twenty-front/src/modules/views/types/View.ts b/packages/twenty-front/src/modules/views/types/View.ts index 3d0c4ed3b..4c7afc064 100644 --- a/packages/twenty-front/src/modules/views/types/View.ts +++ b/packages/twenty-front/src/modules/views/types/View.ts @@ -29,5 +29,6 @@ export type View = { position: number; icon: string; openRecordIn: ViewOpenRecordInType; + anyFieldFilterValue?: string | null; __typename: 'View'; }; diff --git a/packages/twenty-front/src/utils/__tests__/compareNonEmptyString.test.ts b/packages/twenty-front/src/utils/__tests__/compareNonEmptyString.test.ts new file mode 100644 index 000000000..54da87e00 --- /dev/null +++ b/packages/twenty-front/src/utils/__tests__/compareNonEmptyString.test.ts @@ -0,0 +1,51 @@ +import { compareNonEmptyStrings } from '~/utils/compareNonEmptyStrings'; + +describe('compareNonEmptyStrings', () => { + it('should return true for undefined === null', () => { + expect(compareNonEmptyStrings(undefined, null)).toBe(true); + }); + + it('should return true for null === undefined', () => { + expect(compareNonEmptyStrings(null, undefined)).toBe(true); + }); + + it('should return true for undefined === undefined', () => { + expect(compareNonEmptyStrings(undefined, undefined)).toBe(true); + }); + + it('should return true for null === null', () => { + expect(compareNonEmptyStrings(null, null)).toBe(true); + }); + + it('should return true for "" === null', () => { + expect(compareNonEmptyStrings('', null)).toBe(true); + }); + + it('should return true for "" === undefined', () => { + expect(compareNonEmptyStrings('', undefined)).toBe(true); + }); + + it('should return true for "" === ""', () => { + expect(compareNonEmptyStrings('', '')).toBe(true); + }); + + it('should return true for "a" === "a"', () => { + expect(compareNonEmptyStrings('a', 'a')).toBe(true); + }); + + it('should return false for "a" === "b"', () => { + expect(compareNonEmptyStrings('a', 'b')).toBe(false); + }); + + it('should return false for undefined === "a"', () => { + expect(compareNonEmptyStrings(undefined, 'a')).toBe(false); + }); + + it('should return false for null === "a"', () => { + expect(compareNonEmptyStrings(null, 'a')).toBe(false); + }); + + it('should return false for "" === "a"', () => { + expect(compareNonEmptyStrings('', 'a')).toBe(false); + }); +}); diff --git a/packages/twenty-front/src/utils/compareNonEmptyStrings.ts b/packages/twenty-front/src/utils/compareNonEmptyStrings.ts new file mode 100644 index 000000000..f154426f0 --- /dev/null +++ b/packages/twenty-front/src/utils/compareNonEmptyStrings.ts @@ -0,0 +1,13 @@ +import { isNonEmptyString } from '@sniptt/guards'; +import { Nullable } from 'twenty-ui/utilities'; + +export const compareNonEmptyStrings = ( + valueA: Nullable, + valueB: Nullable, +) => { + if (!isNonEmptyString(valueA) && !isNonEmptyString(valueB)) { + return true; + } + + return valueA === valueB; +}; diff --git a/packages/twenty-server/src/database/commands/views-migration/migrate-views-to-core.command.ts b/packages/twenty-server/src/database/commands/views-migration/migrate-views-to-core.command.ts index 84ff82ec6..ac4829243 100644 --- a/packages/twenty-server/src/database/commands/views-migration/migrate-views-to-core.command.ts +++ b/packages/twenty-server/src/database/commands/views-migration/migrate-views-to-core.command.ts @@ -252,6 +252,7 @@ export class MigrateViewsToCoreCommand extends ActiveOrSuspendedWorkspacesMigrat deletedAt: workspaceView.deletedAt ? new Date(workspaceView.deletedAt) : null, + anyFieldFilterValue: workspaceView.anyFieldFilterValue, }; const repository = queryRunner.manager.getRepository(View); diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1753349164408-createAnyFieldFilterValueColumnOnView.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1753349164408-createAnyFieldFilterValueColumnOnView.ts new file mode 100644 index 000000000..cfeb715c3 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1753349164408-createAnyFieldFilterValueColumnOnView.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateAnyFieldFilterValueColumnOnView1753349164408 + implements MigrationInterface +{ + name = 'CreateAnyFieldFilterValueColumnOnView1753349164408'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."view" ADD "anyFieldFilterValue" text`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."view" DROP COLUMN "anyFieldFilterValue"`, + ); + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/view/view.entity.ts b/packages/twenty-server/src/engine/metadata-modules/view/view.entity.ts index f92235fa0..d9b7e1694 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view/view.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view/view.entity.ts @@ -88,6 +88,9 @@ export class View { @DeleteDateColumn({ type: 'timestamptz' }) deletedAt?: Date | null; + @Column({ nullable: true, type: 'text', default: null }) + anyFieldFilterValue?: string | null; + @ManyToOne(() => Workspace, { onDelete: 'CASCADE', }) diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts index 1ef67a125..ed3ffa247 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts @@ -444,6 +444,7 @@ export const VIEW_STANDARD_FIELD_IDS = { viewFilterGroups: '20202020-0318-474a-84a1-bac895ceaa5a', viewSorts: '20202020-891b-45c3-9fe1-80a75b4aa043', favorites: '20202020-c818-4a86-8284-9ec0ef0a59a5', + anyFieldFilterValue: '20202020-3143-46c0-bb05-034063ce0703', }; export const WEBHOOK_STANDARD_FIELD_IDS = { diff --git a/packages/twenty-server/src/modules/view/standard-objects/view.workspace-entity.ts b/packages/twenty-server/src/modules/view/standard-objects/view.workspace-entity.ts index 5052d5f66..cadd69d03 100644 --- a/packages/twenty-server/src/modules/view/standard-objects/view.workspace-entity.ts +++ b/packages/twenty-server/src/modules/view/standard-objects/view.workspace-entity.ts @@ -307,4 +307,14 @@ export class ViewWorkspaceEntity extends BaseWorkspaceEntity { }) @WorkspaceIsNullable() kanbanAggregateOperationFieldMetadataId?: string | null; + + @WorkspaceField({ + standardId: VIEW_STANDARD_FIELD_IDS.anyFieldFilterValue, + type: FieldMetadataType.TEXT, + label: msg`Any field filter value`, + description: msg`Any field filter value`, + defaultValue: null, + }) + @WorkspaceIsNullable() + anyFieldFilterValue?: string | null; }