From bddca094511d0355d956d88d92c4b22b201d6d51 Mon Sep 17 00:00:00 2001 From: Lucas Bordeau Date: Thu, 23 Jan 2025 11:09:44 +0100 Subject: [PATCH] Refactored table filters to consume new currentRecordFilters component state (#9652) This PR implements a first real use case, now currentRecordFilters component state acts as the global record filter reference. It is set by the view initially and can be reset to view filters state at any point. This new state is also modified by two new upsertRecordFilter / removeRecordFilter hooks that will be drop-in replacement of the actual upsertCombinedViewFilter and removeCombinediewFilter hooks. This PR implements the logic to manipulate record filters but only reads it to make the table find many request, all other features are still relying on the old view filter implementation. Advanced filters are ignored because they are hidden and because this effort is made precisely to allow the completion of the advanced filters feature. --- .../RecordIndexActionMenuBar.stories.tsx | 102 ++++----- .../components/CommandMenuContainer.tsx | 59 +++--- .../__stories__/CommandMenu.stories.tsx | 19 +- .../hooks/useDeleteFavoriteFolder.ts | 9 +- .../hooks/usePrefetchedFavoritesData.ts | 4 +- .../usePrefetchedFavoritesFoldersData.ts | 4 +- .../ObjectFilterDropdownSourceSelect.tsx | 9 + .../MultipleFiltersDropdownButton.stories.tsx | 21 +- ...vailableFilterDefinitionsComponentState.ts | 1 + .../ObjectOptionsDropdownContent.stories.tsx | 33 +-- .../__tests__/useRemoveRecordFilter.test.tsx | 117 +++++++++++ .../__tests__/useUpsertRecordFilter.test.tsx | 112 ++++++++++ .../hooks/useApplyRecordFilter.ts | 24 +-- .../hooks/useRemoveRecordFilter.ts | 46 ++++ .../hooks/useUpsertRecordFilter.ts | 54 +++++ .../RecordFiltersComponentInstanceContext.ts | 4 + .../currentRecordFiltersComponentState.ts | 11 + .../__tests__/isMatchingArrayFilter.test.ts | 128 +++++++++++ .../useFindManyRecordIndexTableParams.ts | 13 +- .../hooks/useHandleToggleColumnFilter.ts | 5 + .../hooks/useHandleToggleTrashColumnFilter.ts | 11 +- .../components/RightDrawerRecord.tsx | 49 +++-- .../RecordTableEmptyStateSoftDelete.tsx | 21 +- .../components/PrefetchRunQueriesEffect.tsx | 8 +- ... useUpsertRecordsInCacheForPrefetchKey.ts} | 3 +- .../SignInBackgroundMockContainer.tsx | 49 +++-- .../EditableFilterDropdownButton.tsx | 9 +- .../components/QueryParamsFiltersEffect.tsx | 6 + .../views/components/VariantFilterChip.tsx | 5 + .../src/modules/views/components/ViewBar.tsx | 2 + .../views/components/ViewBarDetails.tsx | 6 + .../views/components/ViewBarFilterEffect.tsx | 22 +- .../components/ViewBarRecordFilterEffect.tsx | 50 +++++ ...ViewFiltersToCurrentRecordFilters.test.tsx | 198 ++++++++++++++++++ ...ViewFiltersToCurrentRecordFilters.test.tsx | 108 ++++++++++ ...urrentViewFiltersToCurrentRecordFilters.ts | 42 ++++ ...eApplyViewFiltersToCurrentRecordFilters.ts | 31 +++ .../pages/object-record/RecordIndexPage.tsx | 39 ++-- .../pages/object-record/RecordShowPage.tsx | 105 +++++----- .../src/testing/decorators/PageDecorator.tsx | 9 +- ...taAndApolloMocksAndContextStoreWrapper.tsx | 39 ++-- .../getJestMetadataAndApolloMocksWrapper.tsx | 18 +- 42 files changed, 1303 insertions(+), 302 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/record-filter/hooks/__tests__/useRemoveRecordFilter.test.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-filter/hooks/__tests__/useUpsertRecordFilter.test.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-filter/hooks/useRemoveRecordFilter.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-filter/hooks/useUpsertRecordFilter.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-filter/states/currentRecordFiltersComponentState.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/isMatchingArrayFilter.test.ts rename packages/twenty-front/src/modules/prefetch/hooks/internal/{usePrefetchRunQuery.ts => useUpsertRecordsInCacheForPrefetchKey.ts} (94%) create mode 100644 packages/twenty-front/src/modules/views/components/ViewBarRecordFilterEffect.tsx create mode 100644 packages/twenty-front/src/modules/views/hooks/__tests__/useApplyCurrentViewFiltersToCurrentRecordFilters.test.tsx create mode 100644 packages/twenty-front/src/modules/views/hooks/__tests__/useApplyViewFiltersToCurrentRecordFilters.test.tsx create mode 100644 packages/twenty-front/src/modules/views/hooks/useApplyCurrentViewFiltersToCurrentRecordFilters.ts create mode 100644 packages/twenty-front/src/modules/views/hooks/useApplyViewFiltersToCurrentRecordFilters.ts diff --git a/packages/twenty-front/src/modules/action-menu/components/__stories__/RecordIndexActionMenuBar.stories.tsx b/packages/twenty-front/src/modules/action-menu/components/__stories__/RecordIndexActionMenuBar.stories.tsx index ed53303db..4c7b2b538 100644 --- a/packages/twenty-front/src/modules/action-menu/components/__stories__/RecordIndexActionMenuBar.stories.tsx +++ b/packages/twenty-front/src/modules/action-menu/components/__stories__/RecordIndexActionMenuBar.stories.tsx @@ -10,6 +10,7 @@ import { getActionBarIdFromActionMenuId } from '@/action-menu/utils/getActionBar import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState'; import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext'; +import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext'; import { isBottomBarOpenedComponentState } from '@/ui/layout/bottom-bar/states/isBottomBarOpenedComponentState'; import { expect, jest } from '@storybook/jest'; import { Meta, StoryObj } from '@storybook/react'; @@ -25,58 +26,63 @@ const meta: Meta = { decorators: [ RouterDecorator, (Story) => ( - - { - set( - contextStoreTargetedRecordsRuleComponentState.atomFamily({ - instanceId: 'story-action-menu', - }), - { - mode: 'selection', - selectedRecordIds: ['1', '2', '3'], - }, - ); - set( - contextStoreNumberOfSelectedRecordsComponentState.atomFamily({ - instanceId: 'story-action-menu', - }), - 3, - ); - const map = new Map(); - map.set('delete', { - isPinned: true, - scope: ActionMenuEntryScope.RecordSelection, - type: ActionMenuEntryType.Standard, - key: 'delete', - label: 'Delete', - position: 0, - Icon: IconTrash, - onClick: deleteMock, - }); - set( - actionMenuEntriesComponentState.atomFamily({ - instanceId: 'story-action-menu', - }), - map, - ); - set( - isBottomBarOpenedComponentState.atomFamily({ - instanceId: getActionBarIdFromActionMenuId('story-action-menu'), - }), - true, - ); - }} + - { + set( + contextStoreTargetedRecordsRuleComponentState.atomFamily({ + instanceId: 'story-action-menu', + }), + { + mode: 'selection', + selectedRecordIds: ['1', '2', '3'], + }, + ); + set( + contextStoreNumberOfSelectedRecordsComponentState.atomFamily({ + instanceId: 'story-action-menu', + }), + 3, + ); + const map = new Map(); + map.set('delete', { + isPinned: true, + scope: ActionMenuEntryScope.RecordSelection, + type: ActionMenuEntryType.Standard, + key: 'delete', + label: 'Delete', + position: 0, + Icon: IconTrash, + onClick: deleteMock, + }); + set( + actionMenuEntriesComponentState.atomFamily({ + instanceId: 'story-action-menu', + }), + map, + ); + set( + isBottomBarOpenedComponentState.atomFamily({ + instanceId: + getActionBarIdFromActionMenuId('story-action-menu'), + }), + true, + ); + }} > - - - - + + + + + + ), ], args: { diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenuContainer.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenuContainer.tsx index b3124e701..694637ed7 100644 --- a/packages/twenty-front/src/modules/command-menu/components/CommandMenuContainer.tsx +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenuContainer.tsx @@ -9,6 +9,7 @@ import { useCommandMenuHotKeys } from '@/command-menu/hooks/useCommandMenuHotKey import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState'; import { CommandMenuAnimationVariant } from '@/command-menu/types/CommandMenuAnimationVariant'; import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext'; +import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext'; import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope'; import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; import { workflowReactFlowRefState } from '@/workflow/workflow-diagram/states/workflowReactFlowRefState'; @@ -74,37 +75,41 @@ export const CommandMenuContainer = ({ const theme = useTheme(); return ( - - - - - {isWorkflowEnabled && } - - {isCommandMenuOpened && ( - - {children} - - )} - - - + + + {isWorkflowEnabled && } + + {isCommandMenuOpened && ( + + {children} + + )} + + + + ); }; diff --git a/packages/twenty-front/src/modules/command-menu/components/__stories__/CommandMenu.stories.tsx b/packages/twenty-front/src/modules/command-menu/components/__stories__/CommandMenu.stories.tsx index efd71ca5e..b7e2dcd68 100644 --- a/packages/twenty-front/src/modules/command-menu/components/__stories__/CommandMenu.stories.tsx +++ b/packages/twenty-front/src/modules/command-menu/components/__stories__/CommandMenu.stories.tsx @@ -19,6 +19,7 @@ import { ActionMenuComponentInstanceContext } from '@/action-menu/states/context import { CommandMenuRouter } from '@/command-menu/components/CommandMenuRouter'; import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState'; import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext'; +import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext'; import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator'; import { JestContextStoreSetter } from '~/testing/jest/JestContextStoreSetter'; import { CommandMenu } from '../CommandMenu'; @@ -29,17 +30,21 @@ const openTimeout = 50; const ContextStoreDecorator: Decorator = (Story) => { return ( - - - - - - - + + + + + + + ); }; diff --git a/packages/twenty-front/src/modules/favorites/hooks/useDeleteFavoriteFolder.ts b/packages/twenty-front/src/modules/favorites/hooks/useDeleteFavoriteFolder.ts index 4c0925ca6..c10adccb8 100644 --- a/packages/twenty-front/src/modules/favorites/hooks/useDeleteFavoriteFolder.ts +++ b/packages/twenty-front/src/modules/favorites/hooks/useDeleteFavoriteFolder.ts @@ -4,7 +4,7 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi import { useReadFindManyRecordsQueryInCache } from '@/object-record/cache/hooks/useReadFindManyRecordsQueryInCache'; import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord'; import { PREFETCH_CONFIG } from '@/prefetch/constants/PrefetchConfig'; -import { usePrefetchRunQuery } from '@/prefetch/hooks/internal/usePrefetchRunQuery'; +import { useUpsertRecordsInCacheForPrefetchKey } from '@/prefetch/hooks/internal/useUpsertRecordsInCacheForPrefetchKey'; import { PrefetchKey } from '@/prefetch/types/PrefetchKey'; export const useDeleteFavoriteFolder = () => { @@ -12,9 +12,10 @@ export const useDeleteFavoriteFolder = () => { objectNameSingular: CoreObjectNameSingular.FavoriteFolder, }); - const { upsertRecordsInCache } = usePrefetchRunQuery({ - prefetchKey: PrefetchKey.AllFavorites, - }); + const { upsertRecordsInCache } = + useUpsertRecordsInCacheForPrefetchKey({ + prefetchKey: PrefetchKey.AllFavorites, + }); const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular: diff --git a/packages/twenty-front/src/modules/favorites/hooks/usePrefetchedFavoritesData.ts b/packages/twenty-front/src/modules/favorites/hooks/usePrefetchedFavoritesData.ts index 7eae9cf3a..ece3d2bdf 100644 --- a/packages/twenty-front/src/modules/favorites/hooks/usePrefetchedFavoritesData.ts +++ b/packages/twenty-front/src/modules/favorites/hooks/usePrefetchedFavoritesData.ts @@ -1,6 +1,6 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { Favorite } from '@/favorites/types/Favorite'; -import { usePrefetchRunQuery } from '@/prefetch/hooks/internal/usePrefetchRunQuery'; +import { useUpsertRecordsInCacheForPrefetchKey } from '@/prefetch/hooks/internal/useUpsertRecordsInCacheForPrefetchKey'; import { usePrefetchedData } from '@/prefetch/hooks/usePrefetchedData'; import { PrefetchKey } from '@/prefetch/types/PrefetchKey'; import { useRecoilValue } from 'recoil'; @@ -33,7 +33,7 @@ export const usePrefetchedFavoritesData = (): PrefetchedFavoritesData => { ); const { upsertRecordsInCache: upsertFavorites } = - usePrefetchRunQuery({ + useUpsertRecordsInCacheForPrefetchKey({ prefetchKey: PrefetchKey.AllFavorites, }); diff --git a/packages/twenty-front/src/modules/favorites/hooks/usePrefetchedFavoritesFoldersData.ts b/packages/twenty-front/src/modules/favorites/hooks/usePrefetchedFavoritesFoldersData.ts index b794dc2ec..32972ed05 100644 --- a/packages/twenty-front/src/modules/favorites/hooks/usePrefetchedFavoritesFoldersData.ts +++ b/packages/twenty-front/src/modules/favorites/hooks/usePrefetchedFavoritesFoldersData.ts @@ -1,6 +1,6 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { FavoriteFolder } from '@/favorites/types/FavoriteFolder'; -import { usePrefetchRunQuery } from '@/prefetch/hooks/internal/usePrefetchRunQuery'; +import { useUpsertRecordsInCacheForPrefetchKey } from '@/prefetch/hooks/internal/useUpsertRecordsInCacheForPrefetchKey'; import { usePrefetchedData } from '@/prefetch/hooks/usePrefetchedData'; import { PrefetchKey } from '@/prefetch/types/PrefetchKey'; import { useRecoilValue } from 'recoil'; @@ -26,7 +26,7 @@ export const usePrefetchedFavoritesFoldersData = ); const { upsertRecordsInCache: upsertFavoriteFolders } = - usePrefetchRunQuery({ + useUpsertRecordsInCacheForPrefetchKey({ prefetchKey: PrefetchKey.AllFavoritesFolders, }); diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownSourceSelect.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownSourceSelect.tsx index 92b77bd08..5639d82ea 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownSourceSelect.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownSourceSelect.tsx @@ -9,6 +9,7 @@ import { selectedFilterComponentState } from '@/object-record/object-filter-drop import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState'; import { getActorSourceMultiSelectOptions } from '@/object-record/object-filter-dropdown/utils/getActorSourceMultiSelectOptions'; import { useApplyRecordFilter } from '@/object-record/record-filter/hooks/useApplyRecordFilter'; +import { useRemoveRecordFilter } from '@/object-record/record-filter/hooks/useRemoveRecordFilter'; import { RelationPickerHotkeyScope } from '@/object-record/relation-picker/types/RelationPickerHotkeyScope'; import { MultipleSelectDropdown } from '@/object-record/select/components/MultipleSelectDropdown'; import { SelectableItem } from '@/object-record/select/types/SelectableItem'; @@ -61,6 +62,7 @@ export const ObjectFilterDropdownSourceSelect = ({ const { currentViewWithCombinedFiltersAndSorts } = useGetCurrentView(viewComponentId); + // TODO: this should be removed as it is not consistent across re-renders const [fieldId] = useState(v4()); const sourceTypes = getActorSourceMultiSelectOptions( @@ -73,6 +75,8 @@ export const ObjectFilterDropdownSourceSelect = ({ const { emptyRecordFilter } = useEmptyRecordFilter(); + const { removeRecordFilter } = useRemoveRecordFilter(); + const handleMultipleItemSelectChange = ( itemToSelect: SelectableItem, newSelectedValue: boolean, @@ -83,8 +87,13 @@ export const ObjectFilterDropdownSourceSelect = ({ (id) => id !== itemToSelect.id, ); + if (!filterDefinitionUsedInDropdown) { + throw new Error('Filter definition used in dropdown should be defined'); + } + if (newSelectedItemIds.length === 0) { emptyRecordFilter(); + removeRecordFilter(filterDefinitionUsedInDropdown.fieldMetadataId); deleteCombinedViewFilter(fieldId); return; } diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/__stories__/MultipleFiltersDropdownButton.stories.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/__stories__/MultipleFiltersDropdownButton.stories.tsx index 250b9b364..7120a230f 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/__stories__/MultipleFiltersDropdownButton.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/__stories__/MultipleFiltersDropdownButton.stories.tsx @@ -5,6 +5,7 @@ import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlur import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { MultipleFiltersDropdownButton } from '@/object-record/object-filter-dropdown/components/MultipleFiltersDropdownButton'; import { ObjectFilterDropdownComponentInstanceContext } from '@/object-record/object-filter-dropdown/states/contexts/ObjectFilterDropdownComponentInstanceContext'; +import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext'; import { RecordIndexContextProvider } from '@/object-record/record-index/contexts/RecordIndexContext'; import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext'; import { tableColumnsComponentState } from '@/object-record/record-table/states/tableColumnsComponentState'; @@ -107,17 +108,21 @@ const meta: Meta = { recordIndexId: instanceId, }} > - - {} }} + - - - - - + {} }} + > + + + + + + ); }, diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/availableFilterDefinitionsComponentState.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/availableFilterDefinitionsComponentState.ts index 52ae7db07..ddc529f00 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/availableFilterDefinitionsComponentState.ts +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/availableFilterDefinitionsComponentState.ts @@ -1,5 +1,6 @@ import { ObjectFilterDropdownComponentInstanceContext } from '@/object-record/object-filter-dropdown/states/contexts/ObjectFilterDropdownComponentInstanceContext'; import { RecordFilterDefinition } from '@/object-record/record-filter/types/RecordFilterDefinition'; + import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2'; export const availableFilterDefinitionsComponentState = createComponentStateV2< diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/__stories__/ObjectOptionsDropdownContent.stories.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/__stories__/ObjectOptionsDropdownContent.stories.tsx index ce819f749..bb06fb661 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/__stories__/ObjectOptionsDropdownContent.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/__stories__/ObjectOptionsDropdownContent.stories.tsx @@ -7,6 +7,7 @@ import { ObjectOptionsDropdownContent } from '@/object-record/object-options-dro import { OBJECT_OPTIONS_DROPDOWN_ID } from '@/object-record/object-options-dropdown/constants/ObjectOptionsDropdownId'; import { ObjectOptionsDropdownContext } from '@/object-record/object-options-dropdown/states/contexts/ObjectOptionsDropdownContext'; import { ObjectOptionsContentId } from '@/object-record/object-options-dropdown/types/ObjectOptionsContentId'; +import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext'; import { RecordIndexContextProvider } from '@/object-record/record-index/contexts/RecordIndexContext'; import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext'; import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu'; @@ -37,22 +38,26 @@ const meta: Meta = { }, [setObjectMetadataItems]); return ( - {} }} + - - - {} }} + > + + - - - - - + + + + + + + ); }, ObjectMetadataItemsDecorator, diff --git a/packages/twenty-front/src/modules/object-record/record-filter/hooks/__tests__/useRemoveRecordFilter.test.tsx b/packages/twenty-front/src/modules/object-record/record-filter/hooks/__tests__/useRemoveRecordFilter.test.tsx new file mode 100644 index 000000000..9c8d1f021 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-filter/hooks/__tests__/useRemoveRecordFilter.test.tsx @@ -0,0 +1,117 @@ +import { renderHook } from '@testing-library/react'; +import { act } from 'react-dom/test-utils'; + +import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; +import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; +import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper'; +import { useRemoveRecordFilter } from '../useRemoveRecordFilter'; +import { useUpsertRecordFilter } from '../useUpsertRecordFilter'; + +const Wrapper = getJestMetadataAndApolloMocksWrapper({ + apolloMocks: [], +}); + +describe('useRemoveRecordFilter', () => { + it('should remove an existing filter', () => { + const { result } = renderHook( + () => { + const currentRecordFilters = useRecoilComponentValueV2( + currentRecordFiltersComponentState, + ); + + const { upsertRecordFilter } = useUpsertRecordFilter(); + const { removeRecordFilter } = useRemoveRecordFilter(); + + return { + upsertRecordFilter, + removeRecordFilter, + currentRecordFilters, + }; + }, + { + wrapper: Wrapper, + }, + ); + + const filter: RecordFilter = { + id: 'filter-1', + fieldMetadataId: 'field-1', + value: 'test-value', + operand: ViewFilterOperand.Contains, + displayValue: 'test-value', + definition: { + type: 'TEXT', + fieldMetadataId: 'field-1', + label: 'Test Field', + iconName: 'IconText', + }, + }; + + // First add a filter + act(() => { + result.current.upsertRecordFilter(filter); + }); + + expect(result.current.currentRecordFilters).toHaveLength(1); + expect(result.current.currentRecordFilters[0]).toEqual(filter); + + // Then remove it + act(() => { + result.current.removeRecordFilter(filter.fieldMetadataId); + }); + + expect(result.current.currentRecordFilters).toHaveLength(0); + }); + + it('should not modify filters when trying to remove a non-existent filter', () => { + const { result } = renderHook( + () => { + const currentRecordFilters = useRecoilComponentValueV2( + currentRecordFiltersComponentState, + ); + const { upsertRecordFilter } = useUpsertRecordFilter(); + const { removeRecordFilter } = useRemoveRecordFilter(); + return { + upsertRecordFilter, + removeRecordFilter, + currentRecordFilters, + }; + }, + { + wrapper: Wrapper, + }, + ); + + const filter: RecordFilter = { + id: 'filter-1', + fieldMetadataId: 'field-1', + value: 'test-value', + operand: ViewFilterOperand.Contains, + displayValue: 'test-value', + definition: { + type: 'TEXT', + fieldMetadataId: 'field-1', + label: 'Test Field', + iconName: 'IconText', + }, + }; + + // Add a filter + act(() => { + result.current.upsertRecordFilter(filter); + }); + + expect(result.current.currentRecordFilters).toHaveLength(1); + + // Try to remove a non-existent filter + act(() => { + result.current.removeRecordFilter('non-existent-field'); + }); + + // Filter list should remain unchanged + expect(result.current.currentRecordFilters).toHaveLength(1); + expect(result.current.currentRecordFilters[0]).toEqual(filter); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-filter/hooks/__tests__/useUpsertRecordFilter.test.tsx b/packages/twenty-front/src/modules/object-record/record-filter/hooks/__tests__/useUpsertRecordFilter.test.tsx new file mode 100644 index 000000000..3b25ff2f0 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-filter/hooks/__tests__/useUpsertRecordFilter.test.tsx @@ -0,0 +1,112 @@ +import { renderHook } from '@testing-library/react'; +import { act } from 'react-dom/test-utils'; + +import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; +import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; +import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper'; +import { useUpsertRecordFilter } from '../useUpsertRecordFilter'; + +const Wrapper = getJestMetadataAndApolloMocksWrapper({ + apolloMocks: [], +}); + +describe('useUpsertRecordFilter', () => { + it('should add a new filter when fieldMetadataId does not exist', () => { + const { result } = renderHook( + () => { + const currentRecordFilters = useRecoilComponentValueV2( + currentRecordFiltersComponentState, + ); + + const { upsertRecordFilter } = useUpsertRecordFilter(); + + return { upsertRecordFilter, currentRecordFilters }; + }, + { + wrapper: Wrapper, + }, + ); + + const newFilter: RecordFilter = { + id: 'filter-1', + fieldMetadataId: 'field-1', + value: 'test-value', + operand: ViewFilterOperand.Contains, + displayValue: 'test-value', + definition: { + type: 'TEXT', + fieldMetadataId: 'field-1', + label: 'Test Field', + iconName: 'IconText', + }, + }; + + act(() => { + result.current.upsertRecordFilter(newFilter); + }); + + expect(result.current.currentRecordFilters).toHaveLength(1); + expect(result.current.currentRecordFilters[0]).toEqual(newFilter); + }); + + it('should update an existing filter when fieldMetadataId exists', () => { + const { result } = renderHook( + () => { + const currentRecordFilters = useRecoilComponentValueV2( + currentRecordFiltersComponentState, + ); + + const { upsertRecordFilter } = useUpsertRecordFilter(); + + return { upsertRecordFilter, currentRecordFilters }; + }, + { + wrapper: Wrapper, + }, + ); + + const initialFilter: RecordFilter = { + id: 'filter-1', + fieldMetadataId: 'field-1', + value: 'initial-value', + operand: ViewFilterOperand.Contains, + displayValue: 'initial-value', + definition: { + type: 'TEXT', + fieldMetadataId: 'field-1', + label: 'Test Field', + iconName: 'IconText', + }, + }; + + const updatedFilter: RecordFilter = { + id: 'filter-1', + fieldMetadataId: 'field-1', + value: 'updated-value', + operand: ViewFilterOperand.Contains, + displayValue: 'updated-value', + definition: { + type: 'TEXT', + fieldMetadataId: 'field-1', + label: 'Test Field', + iconName: 'IconText', + }, + }; + + act(() => { + result.current.upsertRecordFilter(initialFilter); + }); + + expect(result.current.currentRecordFilters).toHaveLength(1); + expect(result.current.currentRecordFilters[0]).toEqual(initialFilter); + + act(() => { + result.current.upsertRecordFilter(updatedFilter); + }); + + expect(result.current.currentRecordFilters).toHaveLength(1); + expect(result.current.currentRecordFilters[0]).toEqual(updatedFilter); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-filter/hooks/useApplyRecordFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/hooks/useApplyRecordFilter.ts index ad0edea7e..ba82b14c5 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/hooks/useApplyRecordFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/hooks/useApplyRecordFilter.ts @@ -1,7 +1,6 @@ -import { onFilterSelectComponentState } from '@/object-record/object-filter-dropdown/states/onFilterSelectComponentState'; import { selectedFilterComponentState } from '@/object-record/object-filter-dropdown/states/selectedFilterComponentState'; +import { useUpsertRecordFilter } from '@/object-record/record-filter/hooks/useUpsertRecordFilter'; import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; -import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue'; import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; import { useUpsertCombinedViewFilters } from '@/views/hooks/useUpsertCombinedViewFilters'; import { useRecoilCallback } from 'recoil'; @@ -14,32 +13,19 @@ export const useApplyRecordFilter = (componentInstanceId?: string) => { componentInstanceId, ); - const onFilterSelectCallbackState = useRecoilComponentCallbackStateV2( - onFilterSelectComponentState, - componentInstanceId, - ); + const { upsertRecordFilter } = useUpsertRecordFilter(); const applyRecordFilter = useRecoilCallback( - ({ set, snapshot }) => + ({ set }) => (filter: RecordFilter | null) => { set(selectedFilterCallbackState, filter); - const onFilterSelect = getSnapshotValue( - snapshot, - onFilterSelectCallbackState, - ); - if (isDefined(filter)) { upsertCombinedViewFilter(filter); + upsertRecordFilter(filter); } - - onFilterSelect?.(filter); }, - [ - selectedFilterCallbackState, - onFilterSelectCallbackState, - upsertCombinedViewFilter, - ], + [selectedFilterCallbackState, upsertCombinedViewFilter, upsertRecordFilter], ); return { diff --git a/packages/twenty-front/src/modules/object-record/record-filter/hooks/useRemoveRecordFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/hooks/useRemoveRecordFilter.ts new file mode 100644 index 000000000..a41f5b3f8 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-filter/hooks/useRemoveRecordFilter.ts @@ -0,0 +1,46 @@ +import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; +import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; +import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; +import { useRecoilCallback } from 'recoil'; + +export const useRemoveRecordFilter = () => { + const currentRecordFiltersCallbackState = useRecoilComponentCallbackStateV2( + currentRecordFiltersComponentState, + ); + + const removeRecordFilter = useRecoilCallback( + ({ set, snapshot }) => + (fieldMetadataId: string) => { + const currentRecordFilters = getSnapshotValue( + snapshot, + currentRecordFiltersCallbackState, + ); + + const foundRecordFilterInCurrentRecordFilters = + currentRecordFilters.some( + (existingFilter) => + existingFilter.fieldMetadataId === fieldMetadataId, + ); + + if (foundRecordFilterInCurrentRecordFilters) { + set(currentRecordFiltersCallbackState, (currentRecordFilters) => { + const newCurrentRecordFilters = [...currentRecordFilters]; + + const indexOfFilterToRemove = newCurrentRecordFilters.findIndex( + (existingFilter) => + existingFilter.fieldMetadataId === fieldMetadataId, + ); + + newCurrentRecordFilters.splice(indexOfFilterToRemove, 1); + + return newCurrentRecordFilters; + }); + } + }, + [currentRecordFiltersCallbackState], + ); + + return { + removeRecordFilter, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-filter/hooks/useUpsertRecordFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/hooks/useUpsertRecordFilter.ts new file mode 100644 index 000000000..3e7291f57 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-filter/hooks/useUpsertRecordFilter.ts @@ -0,0 +1,54 @@ +import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; +import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; +import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; +import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; +import { useRecoilCallback } from 'recoil'; + +export const useUpsertRecordFilter = () => { + const currentRecordFiltersCallbackState = useRecoilComponentCallbackStateV2( + currentRecordFiltersComponentState, + ); + + const upsertRecordFilter = useRecoilCallback( + ({ set, snapshot }) => + (filter: RecordFilter) => { + const currentRecordFilters = getSnapshotValue( + snapshot, + currentRecordFiltersCallbackState, + ); + + const foundRecordFilterInCurrentRecordFilters = + currentRecordFilters.some( + (existingFilter) => + existingFilter.fieldMetadataId === filter.fieldMetadataId, + ); + + if (!foundRecordFilterInCurrentRecordFilters) { + set(currentRecordFiltersCallbackState, [ + ...currentRecordFilters, + filter, + ]); + } else { + set(currentRecordFiltersCallbackState, (currentRecordFilters) => { + const newCurrentRecordFilters = [...currentRecordFilters]; + + const indexOfFilterToUpdate = newCurrentRecordFilters.findIndex( + (existingFilter) => + existingFilter.fieldMetadataId === filter.fieldMetadataId, + ); + + newCurrentRecordFilters[indexOfFilterToUpdate] = { + ...filter, + }; + + return newCurrentRecordFilters; + }); + } + }, + [currentRecordFiltersCallbackState], + ); + + return { + upsertRecordFilter, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext.ts b/packages/twenty-front/src/modules/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext.ts new file mode 100644 index 000000000..e8a6200d6 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext.ts @@ -0,0 +1,4 @@ +import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext'; + +export const RecordFiltersComponentInstanceContext = + createComponentInstanceContext(); diff --git a/packages/twenty-front/src/modules/object-record/record-filter/states/currentRecordFiltersComponentState.ts b/packages/twenty-front/src/modules/object-record/record-filter/states/currentRecordFiltersComponentState.ts new file mode 100644 index 000000000..a2b6d8059 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-filter/states/currentRecordFiltersComponentState.ts @@ -0,0 +1,11 @@ +import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext'; +import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2'; +import { RecordFilter } from '../../record-filter/types/RecordFilter'; + +export const currentRecordFiltersComponentState = createComponentStateV2< + RecordFilter[] +>({ + key: 'currentRecordFiltersComponentState', + defaultValue: [], + componentInstanceContext: RecordFiltersComponentInstanceContext, +}); diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/isMatchingArrayFilter.test.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/isMatchingArrayFilter.test.ts new file mode 100644 index 000000000..6dde97f7b --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/isMatchingArrayFilter.test.ts @@ -0,0 +1,128 @@ +import { isMatchingArrayFilter } from '../isMatchingArrayFilter'; + +describe('isMatchingArrayFilter', () => { + describe('is filter', () => { + it('should return true when checking for NULL and value is null', () => { + expect( + isMatchingArrayFilter({ + arrayFilter: { is: 'NULL' }, + value: null, + }), + ).toBe(true); + }); + + it('should return false when checking for NULL and value is not null', () => { + expect( + isMatchingArrayFilter({ + arrayFilter: { is: 'NULL' }, + value: ['test'], + }), + ).toBe(false); + }); + + it('should return true when checking for NOT_NULL and value is not null', () => { + expect( + isMatchingArrayFilter({ + arrayFilter: { is: 'NOT_NULL' }, + value: ['test'], + }), + ).toBe(true); + }); + + it('should return false when checking for NOT_NULL and value is null', () => { + expect( + isMatchingArrayFilter({ + arrayFilter: { is: 'NOT_NULL' }, + value: null, + }), + ).toBe(false); + }); + }); + + describe('isEmptyArray filter', () => { + it('should return true when array is empty and checking for empty array', () => { + expect( + isMatchingArrayFilter({ + arrayFilter: { isEmptyArray: true }, + value: [], + }), + ).toBe(true); + }); + + it('should return false when array is not empty and checking for empty array', () => { + expect( + isMatchingArrayFilter({ + arrayFilter: { isEmptyArray: true }, + value: ['test'], + }), + ).toBe(false); + }); + + it('should return false when value is null and checking for empty array', () => { + expect( + isMatchingArrayFilter({ + arrayFilter: { isEmptyArray: true }, + value: null, + }), + ).toBe(false); + }); + }); + + describe('containsIlike filter', () => { + it('should return true when array contains item matching case-insensitive search', () => { + expect( + isMatchingArrayFilter({ + arrayFilter: { containsIlike: 'TEST' }, + value: ['test item'], + }), + ).toBe(true); + }); + + it('should return false when array does not contain item matching search', () => { + expect( + isMatchingArrayFilter({ + arrayFilter: { containsIlike: 'missing' }, + value: ['test item'], + }), + ).toBe(false); + }); + + it('should return false when value is null and using containsIlike', () => { + expect( + isMatchingArrayFilter({ + arrayFilter: { containsIlike: 'test' }, + value: null, + }), + ).toBe(false); + }); + + it('should match partial strings case-insensitively', () => { + expect( + isMatchingArrayFilter({ + arrayFilter: { containsIlike: 'TE' }, + value: ['Test Item', 'Another Item'], + }), + ).toBe(true); + }); + }); + + describe('error handling', () => { + it('should throw error for invalid filter', () => { + expect(() => + isMatchingArrayFilter({ + arrayFilter: {}, + value: [], + }), + ).toThrow('Unexpected value for array filter'); + }); + + it('should throw error for unknown filter type', () => { + expect(() => + isMatchingArrayFilter({ + arrayFilter: { unknownFilter: 'test' } as any, + value: [], + }), + ).toThrow('Unexpected value for array filter'); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useFindManyRecordIndexTableParams.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useFindManyRecordIndexTableParams.ts index df15ba371..4e69c157a 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useFindManyRecordIndexTableParams.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useFindManyRecordIndexTableParams.ts @@ -1,10 +1,10 @@ import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy'; import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies'; +import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; import { computeViewRecordGqlOperationFilter } from '@/object-record/record-filter/utils/computeViewRecordGqlOperationFilter'; import { useCurrentRecordGroupDefinition } from '@/object-record/record-group/hooks/useCurrentRecordGroupDefinition'; import { useRecordGroupFilter } from '@/object-record/record-group/hooks/useRecordGroupFilter'; -import { tableFiltersComponentState } from '@/object-record/record-table/states/tableFiltersComponentState'; import { tableSortsComponentState } from '@/object-record/record-table/states/tableSortsComponentState'; import { tableViewFilterGroupsComponentState } from '@/object-record/record-table/states/tableViewFilterGroupsComponentState'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; @@ -27,20 +27,21 @@ export const useFindManyRecordIndexTableParams = ( tableViewFilterGroupsComponentState, recordTableId, ); - const tableFilters = useRecoilComponentValueV2( - tableFiltersComponentState, - recordTableId, - ); + const tableSorts = useRecoilComponentValueV2( tableSortsComponentState, recordTableId, ); + const currentRecordFilters = useRecoilComponentValueV2( + currentRecordFiltersComponentState, + ); + const { filterValueDependencies } = useFilterValueDependencies(); const stateFilter = computeViewRecordGqlOperationFilter( filterValueDependencies, - tableFilters, + currentRecordFilters, objectMetadataItem?.fields ?? [], tableViewFilterGroups, ); diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleToggleColumnFilter.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleToggleColumnFilter.ts index e573c4cfd..daa0bb001 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleToggleColumnFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleToggleColumnFilter.ts @@ -5,6 +5,7 @@ import { useColumnDefinitionsFromFieldMetadata } from '@/object-metadata/hooks/u import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useSelectFilterDefinitionUsedInDropdown } from '@/object-record/object-filter-dropdown/hooks/useSelectFilterDefinitionUsedInDropdown'; +import { useUpsertRecordFilter } from '@/object-record/record-filter/hooks/useUpsertRecordFilter'; import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; import { getRecordFilterOperandsForRecordFilterDefinition } from '@/object-record/record-filter/utils/getRecordFilterOperandsForRecordFilterDefinition'; import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState'; @@ -33,6 +34,7 @@ export const useHandleToggleColumnFilter = ({ useColumnDefinitionsFromFieldMetadata(objectMetadataItem); const { upsertCombinedViewFilter } = useUpsertCombinedViewFilters(viewBarId); + const { upsertRecordFilter } = useUpsertRecordFilter(); const openDropdown = useRecoilCallback(({ set }) => { return (dropdownId: string) => { @@ -93,6 +95,8 @@ export const useHandleToggleColumnFilter = ({ value: '', }; + upsertRecordFilter(newFilter); + await upsertCombinedViewFilter(newFilter); selectFilterDefinitionUsedInDropdown({ filterDefinition }); @@ -107,6 +111,7 @@ export const useHandleToggleColumnFilter = ({ selectFilterDefinitionUsedInDropdown, currentViewWithCombinedFiltersAndSorts, availableFilterDefinitions, + upsertRecordFilter, ], ); diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleToggleTrashColumnFilter.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleToggleTrashColumnFilter.ts index 4964a26e4..f3471ea3a 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleToggleTrashColumnFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleToggleTrashColumnFilter.ts @@ -4,6 +4,7 @@ import { v4 } from 'uuid'; import { useColumnDefinitionsFromFieldMetadata } from '@/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { getFilterTypeFromFieldType } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions'; +import { useUpsertRecordFilter } from '@/object-record/record-filter/hooks/useUpsertRecordFilter'; import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; import { isSoftDeleteFilterActiveComponentState } from '@/object-record/record-table/states/isSoftDeleteFilterActiveComponentState'; import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; @@ -36,6 +37,8 @@ export const useHandleToggleTrashColumnFilter = ({ viewBarId, ); + const { upsertRecordFilter } = useUpsertRecordFilter(); + const handleToggleTrashColumnFilter = useCallback(() => { const trashFieldMetadata = objectMetadataItem.fields.find( (field: { name: string }) => field.name === 'deletedAt', @@ -69,8 +72,14 @@ export const useHandleToggleTrashColumnFilter = ({ value: '', }; + upsertRecordFilter(newFilter); upsertCombinedViewFilter(newFilter); - }, [columnDefinitions, objectMetadataItem, upsertCombinedViewFilter]); + }, [ + columnDefinitions, + objectMetadataItem, + upsertCombinedViewFilter, + upsertRecordFilter, + ]); const toggleSoftDeleteFilterState = useRecoilCallback( ({ set }) => diff --git a/packages/twenty-front/src/modules/object-record/record-right-drawer/components/RightDrawerRecord.tsx b/packages/twenty-front/src/modules/object-record/record-right-drawer/components/RightDrawerRecord.tsx index 4cd7a5507..0a8757ce6 100644 --- a/packages/twenty-front/src/modules/object-record/record-right-drawer/components/RightDrawerRecord.tsx +++ b/packages/twenty-front/src/modules/object-record/record-right-drawer/components/RightDrawerRecord.tsx @@ -2,6 +2,7 @@ import { useRecoilValue } from 'recoil'; import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext'; import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext'; +import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext'; import { isNewViewableRecordLoadingState } from '@/object-record/record-right-drawer/states/isNewViewableRecordLoading'; import { viewableRecordIdState } from '@/object-record/record-right-drawer/states/viewableRecordIdState'; import { viewableRecordNameSingularState } from '@/object-record/record-right-drawer/states/viewableRecordNameSingularState'; @@ -42,29 +43,33 @@ export const RightDrawerRecord = () => { ); return ( - - - - - {!isNewViewableRecordLoading && ( - - )} - - - - - + + + + {!isNewViewableRecordLoading && ( + + )} + + + + + + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/empty-state/components/RecordTableEmptyStateSoftDelete.tsx b/packages/twenty-front/src/modules/object-record/record-table/empty-state/components/RecordTableEmptyStateSoftDelete.tsx index fd05f3a28..055c9a129 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/empty-state/components/RecordTableEmptyStateSoftDelete.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/empty-state/components/RecordTableEmptyStateSoftDelete.tsx @@ -1,6 +1,7 @@ import { IconFilterOff } from 'twenty-ui'; import { useObjectLabel } from '@/object-metadata/hooks/useObjectLabel'; +import { useRemoveRecordFilter } from '@/object-record/record-filter/hooks/useRemoveRecordFilter'; import { useHandleToggleTrashColumnFilter } from '@/object-record/record-index/hooks/useHandleToggleTrashColumnFilter'; import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; import { RecordTableEmptyStateDisplay } from '@/object-record/record-table/empty-state/components/RecordTableEmptyStateDisplay'; @@ -25,14 +26,22 @@ export const RecordTableEmptyStateSoftDelete = () => { viewBarId: recordTableId, }); + const { removeRecordFilter } = useRemoveRecordFilter(); + const handleButtonClick = async () => { - deleteCombinedViewFilter( - tableFilters.find( - (filter) => - filter.definition.label === 'Deleted' && - filter.operand === 'isNotEmpty', - )?.id ?? '', + const deletedFilter = tableFilters.find( + (filter) => + filter.definition.label === 'Deleted' && + filter.operand === 'isNotEmpty', ); + + if (!deletedFilter) { + throw new Error('Deleted filter not found'); + } + + removeRecordFilter(deletedFilter.fieldMetadataId); + deleteCombinedViewFilter(deletedFilter.id); + toggleSoftDeleteFilterState(false); }; diff --git a/packages/twenty-front/src/modules/prefetch/components/PrefetchRunQueriesEffect.tsx b/packages/twenty-front/src/modules/prefetch/components/PrefetchRunQueriesEffect.tsx index 1808b4c3d..2a6481d29 100644 --- a/packages/twenty-front/src/modules/prefetch/components/PrefetchRunQueriesEffect.tsx +++ b/packages/twenty-front/src/modules/prefetch/components/PrefetchRunQueriesEffect.tsx @@ -7,7 +7,7 @@ import { FavoriteFolder } from '@/favorites/types/FavoriteFolder'; import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; import { useCombinedFindManyRecords } from '@/object-record/multiple-objects/hooks/useCombinedFindManyRecords'; import { PREFETCH_CONFIG } from '@/prefetch/constants/PrefetchConfig'; -import { usePrefetchRunQuery } from '@/prefetch/hooks/internal/usePrefetchRunQuery'; +import { useUpsertRecordsInCacheForPrefetchKey } from '@/prefetch/hooks/internal/useUpsertRecordsInCacheForPrefetchKey'; import { PrefetchKey } from '@/prefetch/types/PrefetchKey'; import { View } from '@/views/types/View'; import { useIsWorkspaceActivationStatusSuspended } from '@/workspace/hooks/useIsWorkspaceActivationStatusSuspended'; @@ -19,16 +19,16 @@ export const PrefetchRunQueriesEffect = () => { const isWorkspaceSuspended = useIsWorkspaceActivationStatusSuspended(); const { upsertRecordsInCache: upsertViewsInCache } = - usePrefetchRunQuery({ + useUpsertRecordsInCacheForPrefetchKey({ prefetchKey: PrefetchKey.AllViews, }); const { upsertRecordsInCache: upsertFavoritesInCache } = - usePrefetchRunQuery({ + useUpsertRecordsInCacheForPrefetchKey({ prefetchKey: PrefetchKey.AllFavorites, }); const { upsertRecordsInCache: upsertFavoritesFoldersInCache } = - usePrefetchRunQuery({ + useUpsertRecordsInCacheForPrefetchKey({ prefetchKey: PrefetchKey.AllFavoritesFolders, }); const { objectMetadataItems } = useObjectMetadataItems(); diff --git a/packages/twenty-front/src/modules/prefetch/hooks/internal/usePrefetchRunQuery.ts b/packages/twenty-front/src/modules/prefetch/hooks/internal/useUpsertRecordsInCacheForPrefetchKey.ts similarity index 94% rename from packages/twenty-front/src/modules/prefetch/hooks/internal/usePrefetchRunQuery.ts rename to packages/twenty-front/src/modules/prefetch/hooks/internal/useUpsertRecordsInCacheForPrefetchKey.ts index a2b18f3ca..2e1da0cb8 100644 --- a/packages/twenty-front/src/modules/prefetch/hooks/internal/usePrefetchRunQuery.ts +++ b/packages/twenty-front/src/modules/prefetch/hooks/internal/useUpsertRecordsInCacheForPrefetchKey.ts @@ -11,7 +11,7 @@ export type UsePrefetchRunQuery = { prefetchKey: PrefetchKey; }; -export const usePrefetchRunQuery = ({ +export const useUpsertRecordsInCacheForPrefetchKey = ({ prefetchKey, }: UsePrefetchRunQuery) => { const setPrefetchDataIsLoaded = useSetRecoilState( @@ -45,7 +45,6 @@ export const usePrefetchRunQuery = ({ return { objectMetadataItem, - setPrefetchDataIsLoaded, upsertRecordsInCache, }; }; diff --git a/packages/twenty-front/src/modules/sign-in-background-mock/components/SignInBackgroundMockContainer.tsx b/packages/twenty-front/src/modules/sign-in-background-mock/components/SignInBackgroundMockContainer.tsx index 7b379dcc2..4c2a25958 100644 --- a/packages/twenty-front/src/modules/sign-in-background-mock/components/SignInBackgroundMockContainer.tsx +++ b/packages/twenty-front/src/modules/sign-in-background-mock/components/SignInBackgroundMockContainer.tsx @@ -3,6 +3,7 @@ import styled from '@emotion/styled'; import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext'; import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext'; import { RecordIndexContextProvider } from '@/object-record/record-index/contexts/RecordIndexContext'; import { RecordTableWithWrappers } from '@/object-record/record-table/components/RecordTableWithWrappers'; import { SignInBackgroundMockContainerEffect } from '@/sign-in-background-mock/components/SignInBackgroundMockContainerEffect'; @@ -41,30 +42,36 @@ export const SignInBackgroundMockContainer = () => { - - - {}} - optionsDropdownButton={<>} - /> - - {}} - /> - - + + {}} + optionsDropdownButton={<>} + /> + + {}} + /> + + + diff --git a/packages/twenty-front/src/modules/views/components/EditableFilterDropdownButton.tsx b/packages/twenty-front/src/modules/views/components/EditableFilterDropdownButton.tsx index 7f6769cb2..de1211f3e 100644 --- a/packages/twenty-front/src/modules/views/components/EditableFilterDropdownButton.tsx +++ b/packages/twenty-front/src/modules/views/components/EditableFilterDropdownButton.tsx @@ -11,6 +11,7 @@ import { ObjectFilterOperandSelectAndInput } from '@/object-record/object-filter import { filterDefinitionUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/filterDefinitionUsedInDropdownComponentState'; import { selectedFilterComponentState } from '@/object-record/object-filter-dropdown/states/selectedFilterComponentState'; import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState'; +import { useRemoveRecordFilter } from '@/object-record/record-filter/hooks/useRemoveRecordFilter'; import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand'; import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; import { useDeleteCombinedViewFilters } from '@/views/hooks/useDeleteCombinedViewFilters'; @@ -73,14 +74,17 @@ export const EditableFilterDropdownButton = ({ viewFilterDropdownId, ]); + const { removeRecordFilter } = useRemoveRecordFilter(); + const handleRemove = () => { closeDropdown(); deleteCombinedViewFilter(viewFilter.id); + removeRecordFilter(viewFilter.fieldMetadataId); }; const handleDropdownClickOutside = useCallback(() => { - const { id: fieldId, value, operand } = viewFilter; + const { id: fieldId, value, operand, fieldMetadataId } = viewFilter; if ( !value && ![ @@ -91,9 +95,10 @@ export const EditableFilterDropdownButton = ({ RecordFilterOperand.IsToday, ].includes(operand) ) { + removeRecordFilter(fieldMetadataId); deleteCombinedViewFilter(fieldId); } - }, [viewFilter, deleteCombinedViewFilter]); + }, [viewFilter, deleteCombinedViewFilter, removeRecordFilter]); return ( { const { resetUnsavedViewStates } = useResetUnsavedViewStates(); + const { applyViewFiltersToCurrentRecordFilters } = + useApplyViewFiltersToCurrentRecordFilters(); + useEffect(() => { if (!hasFiltersQueryParams) { return; @@ -27,10 +31,12 @@ export const QueryParamsFiltersEffect = () => { getFiltersFromQueryParams().then((filtersFromParams) => { if (Array.isArray(filtersFromParams)) { + applyViewFiltersToCurrentRecordFilters(filtersFromParams); setUnsavedViewFilter(filtersFromParams); } }); }, [ + applyViewFiltersToCurrentRecordFilters, getFiltersFromQueryParams, hasFiltersQueryParams, resetUnsavedViewStates, diff --git a/packages/twenty-front/src/modules/views/components/VariantFilterChip.tsx b/packages/twenty-front/src/modules/views/components/VariantFilterChip.tsx index feaa0a460..71f4fe02d 100644 --- a/packages/twenty-front/src/modules/views/components/VariantFilterChip.tsx +++ b/packages/twenty-front/src/modules/views/components/VariantFilterChip.tsx @@ -1,6 +1,7 @@ import { useIcons } from 'twenty-ui'; import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural'; +import { useRemoveRecordFilter } from '@/object-record/record-filter/hooks/useRemoveRecordFilter'; import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; import { useHandleToggleTrashColumnFilter } from '@/object-record/record-index/hooks/useHandleToggleTrashColumnFilter'; import { SortOrFilterChip } from '@/views/components/SortOrFilterChip'; @@ -29,10 +30,14 @@ export const VariantFilterChip = ({ viewBarId, }); + const { removeRecordFilter } = useRemoveRecordFilter(); + const { getIcon } = useIcons(); const handleRemoveClick = () => { deleteCombinedViewFilter(viewFilter.id); + removeRecordFilter(viewFilter.fieldMetadataId); + if ( viewFilter.definition.label === 'Deleted' && viewFilter.operand === 'isNotEmpty' diff --git a/packages/twenty-front/src/modules/views/components/ViewBar.tsx b/packages/twenty-front/src/modules/views/components/ViewBar.tsx index 00a80b918..73f84fdb7 100644 --- a/packages/twenty-front/src/modules/views/components/ViewBar.tsx +++ b/packages/twenty-front/src/modules/views/components/ViewBar.tsx @@ -21,6 +21,7 @@ import { ViewsHotkeyScope } from '../types/ViewsHotkeyScope'; import { FiltersHotkeyScope } from '@/object-record/object-filter-dropdown/types/FiltersHotkeyScope'; 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 { ViewBarRecordFilterEffect } from '@/views/components/ViewBarRecordFilterEffect'; import { ViewEventContext } from '@/views/events/contexts/ViewEventContext'; import { UpdateViewButtonGroup } from './UpdateViewButtonGroup'; import { ViewBarDetails } from './ViewBarDetails'; @@ -53,6 +54,7 @@ export const ViewBar = ({ value={{ instanceId: VIEW_SORT_DROPDOWN_ID }} > + diff --git a/packages/twenty-front/src/modules/views/components/ViewBarDetails.tsx b/packages/twenty-front/src/modules/views/components/ViewBarDetails.tsx index 3f9cfc17f..d81bacc00 100644 --- a/packages/twenty-front/src/modules/views/components/ViewBarDetails.tsx +++ b/packages/twenty-front/src/modules/views/components/ViewBarDetails.tsx @@ -14,6 +14,8 @@ import { EditableFilterDropdownButton } from '@/views/components/EditableFilterD import { EditableSortChip } from '@/views/components/EditableSortChip'; import { ViewBarFilterEffect } from '@/views/components/ViewBarFilterEffect'; import { useViewFromQueryParams } from '@/views/hooks/internal/useViewFromQueryParams'; + +import { useApplyCurrentViewFiltersToCurrentRecordFilters } from '@/views/hooks/useApplyCurrentViewFiltersToCurrentRecordFilters'; import { useGetCurrentView } from '@/views/hooks/useGetCurrentView'; import { useResetUnsavedViewStates } from '@/views/hooks/useResetUnsavedViewStates'; import { availableFilterDefinitionsComponentState } from '@/views/states/availableFilterDefinitionsComponentState'; @@ -167,9 +169,13 @@ export const ViewBarDetails = ({ }; }, [currentViewWithCombinedFiltersAndSorts]); + const { applyCurrentViewFiltersToCurrentRecordFilters } = + useApplyCurrentViewFiltersToCurrentRecordFilters(); + const handleCancelClick = () => { if (isDefined(viewId)) { resetUnsavedViewStates(viewId); + applyCurrentViewFiltersToCurrentRecordFilters(); toggleSoftDeleteFilterState(false); } }; diff --git a/packages/twenty-front/src/modules/views/components/ViewBarFilterEffect.tsx b/packages/twenty-front/src/modules/views/components/ViewBarFilterEffect.tsx index d30be6932..61f69af49 100644 --- a/packages/twenty-front/src/modules/views/components/ViewBarFilterEffect.tsx +++ b/packages/twenty-front/src/modules/views/components/ViewBarFilterEffect.tsx @@ -1,16 +1,13 @@ import { isNonEmptyString } from '@sniptt/guards'; import { useEffect } from 'react'; -import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; import { filterDefinitionUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/filterDefinitionUsedInDropdownComponentState'; import { objectFilterDropdownSelectedOptionValuesComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSelectedOptionValuesComponentState'; import { objectFilterDropdownSelectedRecordIdsComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSelectedRecordIdsComponentState'; -import { onFilterSelectComponentState } from '@/object-record/object-filter-dropdown/states/onFilterSelectComponentState'; import { useGetCurrentView } from '@/views/hooks/useGetCurrentView'; -import { useUpsertCombinedViewFilters } from '@/views/hooks/useUpsertCombinedViewFilters'; import { availableFilterDefinitionsComponentState } from '@/views/states/availableFilterDefinitionsComponentState'; import { jsonRelationFilterValueSchema } from '@/views/view-filter-value/validation-schemas/jsonRelationFilterValueSchema'; import { simpleRelationFilterValueSchema } from '@/views/view-filter-value/validation-schemas/simpleRelationFilterValueSchema'; @@ -23,19 +20,12 @@ type ViewBarFilterEffectProps = { export const ViewBarFilterEffect = ({ filterDropdownId, }: ViewBarFilterEffectProps) => { - const { upsertCombinedViewFilter } = useUpsertCombinedViewFilters(); - const { currentViewWithCombinedFiltersAndSorts } = useGetCurrentView(); const availableFilterDefinitions = useRecoilComponentValueV2( availableFilterDefinitionsComponentState, ); - const setOnFilterSelect = useSetRecoilComponentStateV2( - onFilterSelectComponentState, - filterDropdownId, - ); - const filterDefinitionUsedInDropdown = useRecoilComponentValueV2( filterDefinitionUsedInDropdownComponentState, filterDropdownId, @@ -62,17 +52,7 @@ export const ViewBarFilterEffect = ({ if (isDefined(availableFilterDefinitions)) { setAvailableFilterDefinitions(availableFilterDefinitions); } - setOnFilterSelect(() => (filter: RecordFilter | null) => { - if (isDefined(filter)) { - upsertCombinedViewFilter(filter); - } - }); - }, [ - availableFilterDefinitions, - setAvailableFilterDefinitions, - setOnFilterSelect, - upsertCombinedViewFilter, - ]); + }, [availableFilterDefinitions, setAvailableFilterDefinitions]); useEffect(() => { if (filterDefinitionUsedInDropdown?.type === 'RELATION') { diff --git a/packages/twenty-front/src/modules/views/components/ViewBarRecordFilterEffect.tsx b/packages/twenty-front/src/modules/views/components/ViewBarRecordFilterEffect.tsx new file mode 100644 index 000000000..e5b781432 --- /dev/null +++ b/packages/twenty-front/src/modules/views/components/ViewBarRecordFilterEffect.tsx @@ -0,0 +1,50 @@ +import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; +import { usePrefetchedData } from '@/prefetch/hooks/usePrefetchedData'; +import { PrefetchKey } from '@/prefetch/types/PrefetchKey'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; +import { availableFilterDefinitionsComponentState } from '@/views/states/availableFilterDefinitionsComponentState'; +import { currentViewIdComponentState } from '@/views/states/currentViewIdComponentState'; +import { View } from '@/views/types/View'; +import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters'; +import { useEffect } from 'react'; +import { isDefined } from 'twenty-ui'; + +export const ViewBarRecordFilterEffect = () => { + const { records: views, isDataPrefetched } = usePrefetchedData( + PrefetchKey.AllViews, + ); + + const currentViewId = useRecoilComponentValueV2(currentViewIdComponentState); + + const setCurrentRecordFilters = useSetRecoilComponentStateV2( + currentRecordFiltersComponentState, + ); + + const availableFilterDefinitions = useRecoilComponentValueV2( + availableFilterDefinitionsComponentState, + ); + + useEffect(() => { + if (isDataPrefetched) { + const currentView = views.find((view) => view.id === currentViewId); + + if (isDefined(currentView)) { + setCurrentRecordFilters( + mapViewFiltersToFilters( + currentView.viewFilters, + availableFilterDefinitions, + ), + ); + } + } + }, [ + isDataPrefetched, + views, + availableFilterDefinitions, + currentViewId, + setCurrentRecordFilters, + ]); + + return null; +}; diff --git a/packages/twenty-front/src/modules/views/hooks/__tests__/useApplyCurrentViewFiltersToCurrentRecordFilters.test.tsx b/packages/twenty-front/src/modules/views/hooks/__tests__/useApplyCurrentViewFiltersToCurrentRecordFilters.test.tsx new file mode 100644 index 000000000..07f8074cc --- /dev/null +++ b/packages/twenty-front/src/modules/views/hooks/__tests__/useApplyCurrentViewFiltersToCurrentRecordFilters.test.tsx @@ -0,0 +1,198 @@ +import { act, renderHook } from '@testing-library/react'; + +import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; +import { RecordFilterDefinition } from '@/object-record/record-filter/types/RecordFilterDefinition'; +import { usePrefetchedData } from '@/prefetch/hooks/usePrefetchedData'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { availableFilterDefinitionsComponentState } from '@/views/states/availableFilterDefinitionsComponentState'; +import { currentViewIdComponentState } from '@/views/states/currentViewIdComponentState'; +import { ViewFilter } from '@/views/types/ViewFilter'; +import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; +import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper'; +import { useApplyCurrentViewFiltersToCurrentRecordFilters } from '../useApplyCurrentViewFiltersToCurrentRecordFilters'; + +jest.mock('@/prefetch/hooks/usePrefetchedData'); + +describe('useApplyCurrentViewFiltersToCurrentRecordFilters', () => { + const mockFilterDefinition: RecordFilterDefinition = { + fieldMetadataId: 'field-1', + label: 'Test Field', + type: 'TEXT', + iconName: 'IconText', + }; + + const mockViewFilter: ViewFilter = { + __typename: 'ViewFilter', + id: 'filter-1', + fieldMetadataId: 'field-1', + operand: ViewFilterOperand.Contains, + value: 'test', + displayValue: 'test', + viewFilterGroupId: 'group-1', + positionInViewFilterGroup: 0, + definition: mockFilterDefinition, + }; + + const mockView = { + id: 'view-1', + name: 'Test View', + objectMetadataId: 'object-1', + viewFilters: [mockViewFilter], + }; + + beforeEach(() => { + (usePrefetchedData as jest.Mock).mockReturnValue({ + records: [mockView], + }); + }); + + it('should apply filters from current view', () => { + const { result } = renderHook( + () => { + const { applyCurrentViewFiltersToCurrentRecordFilters } = + useApplyCurrentViewFiltersToCurrentRecordFilters(); + + const currentFilters = useRecoilComponentValueV2( + currentRecordFiltersComponentState, + ); + + return { + applyCurrentViewFiltersToCurrentRecordFilters, + currentFilters, + }; + }, + { + wrapper: getJestMetadataAndApolloMocksWrapper({ + onInitializeRecoilSnapshot: (snapshot) => { + snapshot.set( + currentViewIdComponentState.atomFamily({ + instanceId: 'instanceId', + }), + mockView.id, + ); + snapshot.set( + availableFilterDefinitionsComponentState.atomFamily({ + instanceId: 'instanceId', + }), + [mockFilterDefinition], + ); + }, + }), + }, + ); + + act(() => { + result.current.applyCurrentViewFiltersToCurrentRecordFilters(); + }); + + expect(result.current.currentFilters).toEqual([ + { + id: mockViewFilter.id, + fieldMetadataId: mockViewFilter.fieldMetadataId, + value: mockViewFilter.value, + displayValue: mockViewFilter.displayValue, + operand: mockViewFilter.operand, + viewFilterGroupId: mockViewFilter.viewFilterGroupId, + positionInViewFilterGroup: mockViewFilter.positionInViewFilterGroup, + definition: mockFilterDefinition, + }, + ]); + }); + + it('should not apply filters when current view is not found', () => { + (usePrefetchedData as jest.Mock).mockReturnValue({ + records: [], + }); + + const { result } = renderHook( + () => { + const { applyCurrentViewFiltersToCurrentRecordFilters } = + useApplyCurrentViewFiltersToCurrentRecordFilters(); + + const currentFilters = useRecoilComponentValueV2( + currentRecordFiltersComponentState, + ); + + return { + applyCurrentViewFiltersToCurrentRecordFilters, + currentFilters, + }; + }, + { + wrapper: getJestMetadataAndApolloMocksWrapper({ + onInitializeRecoilSnapshot: (snapshot) => { + snapshot.set( + currentViewIdComponentState.atomFamily({ + instanceId: 'instanceId', + }), + mockView.id, + ); + snapshot.set( + availableFilterDefinitionsComponentState.atomFamily({ + instanceId: 'instanceId', + }), + [mockFilterDefinition], + ); + }, + }), + }, + ); + + act(() => { + result.current.applyCurrentViewFiltersToCurrentRecordFilters(); + }); + + expect(result.current.currentFilters).toEqual([]); + }); + + it('should handle view with empty filters', () => { + const viewWithNoFilters = { + ...mockView, + viewFilters: [], + }; + + (usePrefetchedData as jest.Mock).mockReturnValue({ + records: [viewWithNoFilters], + }); + + const { result } = renderHook( + () => { + const { applyCurrentViewFiltersToCurrentRecordFilters } = + useApplyCurrentViewFiltersToCurrentRecordFilters(); + + const currentFilters = useRecoilComponentValueV2( + currentRecordFiltersComponentState, + ); + + return { + applyCurrentViewFiltersToCurrentRecordFilters, + currentFilters, + }; + }, + { + wrapper: getJestMetadataAndApolloMocksWrapper({ + onInitializeRecoilSnapshot: (snapshot) => { + snapshot.set( + currentViewIdComponentState.atomFamily({ + instanceId: 'instanceId', + }), + mockView.id, + ); + snapshot.set( + availableFilterDefinitionsComponentState.atomFamily({ + instanceId: 'instanceId', + }), + [mockFilterDefinition], + ); + }, + }), + }, + ); + + act(() => { + result.current.applyCurrentViewFiltersToCurrentRecordFilters(); + }); + + expect(result.current.currentFilters).toEqual([]); + }); +}); diff --git a/packages/twenty-front/src/modules/views/hooks/__tests__/useApplyViewFiltersToCurrentRecordFilters.test.tsx b/packages/twenty-front/src/modules/views/hooks/__tests__/useApplyViewFiltersToCurrentRecordFilters.test.tsx new file mode 100644 index 000000000..d4b74ac83 --- /dev/null +++ b/packages/twenty-front/src/modules/views/hooks/__tests__/useApplyViewFiltersToCurrentRecordFilters.test.tsx @@ -0,0 +1,108 @@ +import { act, renderHook } from '@testing-library/react'; + +import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; +import { RecordFilterDefinition } from '@/object-record/record-filter/types/RecordFilterDefinition'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { availableFilterDefinitionsComponentState } from '@/views/states/availableFilterDefinitionsComponentState'; +import { ViewFilter } from '@/views/types/ViewFilter'; +import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; +import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper'; +import { useApplyViewFiltersToCurrentRecordFilters } from '../useApplyViewFiltersToCurrentRecordFilters'; + +describe('useApplyViewFiltersToCurrentRecordFilters', () => { + const mockAvailableFilterDefinition: RecordFilterDefinition = { + fieldMetadataId: 'field-1', + label: 'Test Field', + type: 'TEXT', + iconName: 'IconText', + }; + + const mockViewFilter: ViewFilter = { + __typename: 'ViewFilter', + id: 'filter-1', + fieldMetadataId: 'field-1', + operand: ViewFilterOperand.Contains, + value: 'test', + displayValue: 'test', + viewFilterGroupId: 'group-1', + positionInViewFilterGroup: 0, + definition: mockAvailableFilterDefinition, + }; + + it('should apply view filters to current record filters', () => { + const { result } = renderHook( + () => { + const { applyViewFiltersToCurrentRecordFilters } = + useApplyViewFiltersToCurrentRecordFilters(); + + const currentFilters = useRecoilComponentValueV2( + currentRecordFiltersComponentState, + ); + + return { applyViewFiltersToCurrentRecordFilters, currentFilters }; + }, + { + wrapper: getJestMetadataAndApolloMocksWrapper({ + onInitializeRecoilSnapshot: (snapshot) => { + snapshot.set( + availableFilterDefinitionsComponentState.atomFamily({ + instanceId: 'instanceId', + }), + [mockAvailableFilterDefinition], + ); + }, + }), + }, + ); + + act(() => { + result.current.applyViewFiltersToCurrentRecordFilters([mockViewFilter]); + }); + + expect(result.current.currentFilters).toEqual([ + { + id: mockViewFilter.id, + fieldMetadataId: mockViewFilter.fieldMetadataId, + value: mockViewFilter.value, + displayValue: mockViewFilter.displayValue, + operand: mockViewFilter.operand, + viewFilterGroupId: mockViewFilter.viewFilterGroupId, + positionInViewFilterGroup: mockViewFilter.positionInViewFilterGroup, + definition: mockAvailableFilterDefinition, + }, + ]); + }); + + it('should handle empty view filters array', () => { + const { result } = renderHook( + () => { + const { applyViewFiltersToCurrentRecordFilters } = + useApplyViewFiltersToCurrentRecordFilters(); + + const currentFilters = useRecoilComponentValueV2( + currentRecordFiltersComponentState, + ); + + return { applyViewFiltersToCurrentRecordFilters, currentFilters }; + }, + { + wrapper: getJestMetadataAndApolloMocksWrapper({ + onInitializeRecoilSnapshot: (snapshot) => { + snapshot.set( + availableFilterDefinitionsComponentState.atomFamily({ + instanceId: 'instanceId', + }), + [mockAvailableFilterDefinition], + ); + }, + }), + }, + ); + + act(() => { + result.current.applyViewFiltersToCurrentRecordFilters([]); + }); + + expect(result.current.currentFilters).toEqual([]); + }); +}); diff --git a/packages/twenty-front/src/modules/views/hooks/useApplyCurrentViewFiltersToCurrentRecordFilters.ts b/packages/twenty-front/src/modules/views/hooks/useApplyCurrentViewFiltersToCurrentRecordFilters.ts new file mode 100644 index 000000000..1bbb5e366 --- /dev/null +++ b/packages/twenty-front/src/modules/views/hooks/useApplyCurrentViewFiltersToCurrentRecordFilters.ts @@ -0,0 +1,42 @@ +import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; +import { usePrefetchedData } from '@/prefetch/hooks/usePrefetchedData'; +import { PrefetchKey } from '@/prefetch/types/PrefetchKey'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; +import { availableFilterDefinitionsComponentState } from '@/views/states/availableFilterDefinitionsComponentState'; +import { currentViewIdComponentState } from '@/views/states/currentViewIdComponentState'; +import { View } from '@/views/types/View'; +import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters'; + +import { isDefined } from 'twenty-ui'; + +export const useApplyCurrentViewFiltersToCurrentRecordFilters = () => { + const { records: views } = usePrefetchedData(PrefetchKey.AllViews); + + const currentViewId = useRecoilComponentValueV2(currentViewIdComponentState); + + const setCurrentRecordFilters = useSetRecoilComponentStateV2( + currentRecordFiltersComponentState, + ); + + const availableFilterDefinitions = useRecoilComponentValueV2( + availableFilterDefinitionsComponentState, + ); + + const applyCurrentViewFiltersToCurrentRecordFilters = () => { + const currentView = views.find((view) => view.id === currentViewId); + + if (isDefined(currentView)) { + setCurrentRecordFilters( + mapViewFiltersToFilters( + currentView.viewFilters, + availableFilterDefinitions, + ), + ); + } + }; + + return { + applyCurrentViewFiltersToCurrentRecordFilters, + }; +}; diff --git a/packages/twenty-front/src/modules/views/hooks/useApplyViewFiltersToCurrentRecordFilters.ts b/packages/twenty-front/src/modules/views/hooks/useApplyViewFiltersToCurrentRecordFilters.ts new file mode 100644 index 000000000..ceba489a8 --- /dev/null +++ b/packages/twenty-front/src/modules/views/hooks/useApplyViewFiltersToCurrentRecordFilters.ts @@ -0,0 +1,31 @@ +import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; +import { availableFilterDefinitionsComponentState } from '@/views/states/availableFilterDefinitionsComponentState'; +import { ViewFilter } from '@/views/types/ViewFilter'; +import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters'; + +export const useApplyViewFiltersToCurrentRecordFilters = () => { + const setCurrentRecordFilters = useSetRecoilComponentStateV2( + currentRecordFiltersComponentState, + ); + + const availableFilterDefinitions = useRecoilComponentValueV2( + availableFilterDefinitionsComponentState, + ); + + const applyViewFiltersToCurrentRecordFilters = ( + viewFilters: ViewFilter[], + ) => { + const recordFiltersToApply = mapViewFiltersToFilters( + viewFilters, + availableFilterDefinitions, + ); + + setCurrentRecordFilters(recordFiltersToApply); + }; + + return { + applyViewFiltersToCurrentRecordFilters, + }; +}; diff --git a/packages/twenty-front/src/pages/object-record/RecordIndexPage.tsx b/packages/twenty-front/src/pages/object-record/RecordIndexPage.tsx index 67e818ec4..36bb2df80 100644 --- a/packages/twenty-front/src/pages/object-record/RecordIndexPage.tsx +++ b/packages/twenty-front/src/pages/object-record/RecordIndexPage.tsx @@ -9,6 +9,7 @@ import { ContextStoreComponentInstanceContext } from '@/context-store/states/con import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural'; import { lastShowPageRecordIdState } from '@/object-record/record-field/states/lastShowPageRecordId'; +import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext'; import { RecordIndexContainer } from '@/object-record/record-index/components/RecordIndexContainer'; import { RecordIndexContainerContextStoreNumberOfSelectedRecordsEffect } from '@/object-record/record-index/components/RecordIndexContainerContextStoreNumberOfSelectedRecordsEffect'; import { RecordIndexContainerContextStoreObjectMetadataEffect } from '@/object-record/record-index/components/RecordIndexContainerContextStoreObjectMetadataEffect'; @@ -81,28 +82,32 @@ export const RecordIndexPage = () => { - - - - - - - - - - - - - - + + + + + + + + + + + + + + diff --git a/packages/twenty-front/src/pages/object-record/RecordShowPage.tsx b/packages/twenty-front/src/pages/object-record/RecordShowPage.tsx index 3b9b8b961..e0f62e4bb 100644 --- a/packages/twenty-front/src/pages/object-record/RecordShowPage.tsx +++ b/packages/twenty-front/src/pages/object-record/RecordShowPage.tsx @@ -5,6 +5,7 @@ import { ActionMenuComponentInstanceContext } from '@/action-menu/states/context import { TimelineActivityContext } from '@/activities/timeline-activities/contexts/TimelineActivityContext'; import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext'; import { RecordShowContainer } from '@/object-record/record-show/components/RecordShowContainer'; import { useRecordShowPage } from '@/object-record/record-show/hooks/useRecordShowPage'; import { RecordValueSetterEffect } from '@/object-record/record-store/components/RecordValueSetterEffect'; @@ -46,62 +47,68 @@ export const RecordShowPage = () => { return ( - - - - - - - <> - {!isCommandMenuV2Enabled && - objectNameSingular === CoreObjectNameSingular.Workflow && ( - - )} - {!isCommandMenuV2Enabled && - objectNameSingular === - CoreObjectNameSingular.WorkflowVersion && ( - + + + + + <> + {!isCommandMenuV2Enabled && + objectNameSingular === CoreObjectNameSingular.Workflow && ( + + )} + {!isCommandMenuV2Enabled && + objectNameSingular === + CoreObjectNameSingular.WorkflowVersion && ( + + )} + {(isCommandMenuV2Enabled || + (objectNameSingular !== CoreObjectNameSingular.Workflow && + objectNameSingular !== + CoreObjectNameSingular.WorkflowVersion)) && ( + )} - {(isCommandMenuV2Enabled || - (objectNameSingular !== CoreObjectNameSingular.Workflow && - objectNameSingular !== - CoreObjectNameSingular.WorkflowVersion)) && ( - + + + + - )} - - - - - - - - - - + + + + + + ); }; diff --git a/packages/twenty-front/src/testing/decorators/PageDecorator.tsx b/packages/twenty-front/src/testing/decorators/PageDecorator.tsx index 0d7e8ab2f..35fb49458 100644 --- a/packages/twenty-front/src/testing/decorators/PageDecorator.tsx +++ b/packages/twenty-front/src/testing/decorators/PageDecorator.tsx @@ -22,6 +22,7 @@ import { mockedApolloClient } from '~/testing/mockedApolloClient'; import { RecoilDebugObserverEffect } from '@/debug/components/RecoilDebugObserver'; import { ObjectMetadataItemsProvider } from '@/object-metadata/components/ObjectMetadataItemsProvider'; +import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext'; import { PrefetchDataProvider } from '@/prefetch/components/PrefetchDataProvider'; import { WorkspaceProviderEffect } from '@/workspace/components/WorkspaceProviderEffect'; import { i18n } from '@lingui/core'; @@ -88,7 +89,13 @@ const Providers = () => { - + + + diff --git a/packages/twenty-front/src/testing/jest/getJestMetadataAndApolloMocksAndContextStoreWrapper.tsx b/packages/twenty-front/src/testing/jest/getJestMetadataAndApolloMocksAndContextStoreWrapper.tsx index adb6799e8..894cd7795 100644 --- a/packages/twenty-front/src/testing/jest/getJestMetadataAndApolloMocksAndContextStoreWrapper.tsx +++ b/packages/twenty-front/src/testing/jest/getJestMetadataAndApolloMocksAndContextStoreWrapper.tsx @@ -1,6 +1,7 @@ import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext'; import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext'; import { ContextStoreTargetedRecordsRule } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; +import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext'; import { MockedResponse } from '@apollo/client/testing'; import { ReactNode } from 'react'; import { MutableSnapshot } from 'recoil'; @@ -33,25 +34,33 @@ export const getJestMetadataAndApolloMocksAndActionMenuWrapper = ({ return ({ children }: { children: ReactNode }) => ( - - - - {children} - - - + + {children} + + + + ); }; diff --git a/packages/twenty-front/src/testing/jest/getJestMetadataAndApolloMocksWrapper.tsx b/packages/twenty-front/src/testing/jest/getJestMetadataAndApolloMocksWrapper.tsx index ea0e6528f..7665d4d05 100644 --- a/packages/twenty-front/src/testing/jest/getJestMetadataAndApolloMocksWrapper.tsx +++ b/packages/twenty-front/src/testing/jest/getJestMetadataAndApolloMocksWrapper.tsx @@ -2,14 +2,16 @@ import { MockedProvider, MockedResponse } from '@apollo/client/testing'; import { ReactNode } from 'react'; import { MutableSnapshot, RecoilRoot } from 'recoil'; +import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext'; import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope'; +import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext'; import { JestObjectMetadataItemSetter } from '~/testing/jest/JestObjectMetadataItemSetter'; export const getJestMetadataAndApolloMocksWrapper = ({ apolloMocks, onInitializeRecoilSnapshot, }: { - apolloMocks: + apolloMocks?: | readonly MockedResponse, Record>[] | undefined; onInitializeRecoilSnapshot?: (snapshot: MutableSnapshot) => void; @@ -18,9 +20,17 @@ export const getJestMetadataAndApolloMocksWrapper = ({ - - {children} - + + + + {children} + + +