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} + + +