From 4d6ef9bc3485b20a6e9c00d56d476581d4ae3aec Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Wed, 10 Sep 2025 15:43:40 +0200 Subject: [PATCH] Implement ViewGroups optimistic rendering (#14388) # Context We have recently migrated from workspace.views to core.views. While doing it, we've lost the optimistic rendering on views in frontend. This is an issue for viewField and viewGroups that are persisted without having an intermediate storing layer (viewFilters, viewSorts, viewFilterGroups are not directly persisted when we do the changes, therefore we have an underlying layer to keep them and the optimistic was implemented there already). ViewFields have been treated in a previous PR, this PR focus on ViewGroups ## What Optimistic on ViewGroups ## Other + fix a bug in the sequencing to persist viewFilter + viewFilterGroups --- .../useSetViewTypeFromLayoutOptionsMenu.ts | 43 +++++- .../hooks/useHandleRecordGroupField.ts | 14 +- .../internal/usePersistViewGroupRecords.ts | 57 ++++--- .../useSaveCurrentViewFiltersAndSorts.ts | 2 +- .../useTriggerViewFieldOptimisticEffect.ts | 66 ++++---- .../useTriggerViewGroupOptimisticEffect.ts | 142 ++++++++++++++++++ 6 files changed, 256 insertions(+), 68 deletions(-) create mode 100644 packages/twenty-front/src/modules/views/optimistic-effects/hooks/useTriggerViewGroupOptimisticEffect.ts diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useSetViewTypeFromLayoutOptionsMenu.ts b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useSetViewTypeFromLayoutOptionsMenu.ts index 0261438bf4..42dd5cc124 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useSetViewTypeFromLayoutOptionsMenu.ts +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useSetViewTypeFromLayoutOptionsMenu.ts @@ -6,10 +6,12 @@ import { useLoadRecordIndexStates } from '@/object-record/record-index/hooks/use import { recordIndexViewTypeState } from '@/object-record/record-index/states/recordIndexViewTypeState'; import { usePersistViewGroupRecords } from '@/views/hooks/internal/usePersistViewGroupRecords'; import { useUpdateCurrentView } from '@/views/hooks/useUpdateCurrentView'; -import { coreViewFromViewIdFamilySelector } from '@/views/states/selectors/coreViewFromViewIdFamilySelector'; +import { coreViewsState } from '@/views/states/coreViewState'; import { type GraphQLView } from '@/views/types/GraphQLView'; import { type ViewGroup } from '@/views/types/ViewGroup'; import { ViewType, viewTypeIconMapping } from '@/views/types/ViewType'; +import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView'; +import { convertViewTypeToCore } from '@/views/utils/convertViewTypeToCore'; import { useGetAvailableFieldsForKanban } from '@/views/view-picker/hooks/useGetAvailableFieldsForKanban'; import { useCallback } from 'react'; import { useRecoilCallback, useSetRecoilState } from 'recoil'; @@ -63,7 +65,7 @@ export const useSetViewTypeFromLayoutOptionsMenu = () => { ); const setAndPersistViewType = useRecoilCallback( - ({ snapshot }) => + ({ snapshot, set }) => async (viewType: ViewType) => { const currentViewId = snapshot .getLoadable( @@ -73,18 +75,24 @@ export const useSetViewTypeFromLayoutOptionsMenu = () => { ) .getValue(); + const existingCoreViews = snapshot + .getLoadable(coreViewsState) + .getValue(); + if (!isDefined(currentViewId)) { throw new Error('No view id found'); } - const currentView = snapshot - .getLoadable( - coreViewFromViewIdFamilySelector({ viewId: currentViewId }), - ) - .getValue(); - if (!isDefined(currentView)) { + + const currentCoreView = existingCoreViews.find( + (coreView) => coreView.id === currentViewId, + ); + + if (!isDefined(currentCoreView)) { throw new Error('No current view found'); } + const currentView = convertCoreViewToView(currentCoreView); + const updateCurrentViewParams: Partial = {}; updateCurrentViewParams.type = viewType; @@ -105,6 +113,15 @@ export const useSetViewTypeFromLayoutOptionsMenu = () => { ); } setRecordIndexViewType(viewType); + set(coreViewsState, [ + ...existingCoreViews.filter( + (coreView) => coreView.id !== currentView.id, + ), + { + ...currentCoreView, + type: convertViewTypeToCore(viewType), + }, + ]); if (shouldChangeIcon(currentView.icon, currentView.type)) { updateCurrentViewParams.icon = @@ -114,6 +131,16 @@ export const useSetViewTypeFromLayoutOptionsMenu = () => { } case ViewType.Table: setRecordIndexViewType(viewType); + set(coreViewsState, [ + ...existingCoreViews.filter( + (coreView) => coreView.id !== currentView.id, + ), + { + ...currentCoreView, + type: convertViewTypeToCore(viewType), + }, + ]); + if (shouldChangeIcon(currentView.icon, currentView.type)) { updateCurrentViewParams.icon = viewTypeIconMapping(viewType).displayName; diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleRecordGroupField.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleRecordGroupField.ts index f5d06fc980..47ce8cb39e 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleRecordGroupField.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleRecordGroupField.ts @@ -110,7 +110,12 @@ export const useHandleRecordGroupField = () => { } if (viewGroupsToDelete.length > 0) { - await deleteViewGroupRecords(viewGroupsToDelete); + await deleteViewGroupRecords( + viewGroupsToDelete.map((group) => ({ + id: group.id, + viewId: view.id, + })), + ); } }, [ @@ -144,7 +149,12 @@ export const useHandleRecordGroupField = () => { return; } - await deleteViewGroupRecords(view.viewGroups); + await deleteViewGroupRecords( + view.viewGroups.map((group) => ({ + id: group.id, + viewId: view.id, + })), + ); setRecordGroupsFromViewGroups(view.id, [], objectMetadataItem); }, diff --git a/packages/twenty-front/src/modules/views/hooks/internal/usePersistViewGroupRecords.ts b/packages/twenty-front/src/modules/views/hooks/internal/usePersistViewGroupRecords.ts index 64b210e257..65dc65965e 100644 --- a/packages/twenty-front/src/modules/views/hooks/internal/usePersistViewGroupRecords.ts +++ b/packages/twenty-front/src/modules/views/hooks/internal/usePersistViewGroupRecords.ts @@ -3,8 +3,10 @@ import { useCallback } from 'react'; import { CREATE_CORE_VIEW_GROUP } from '@/views/graphql/mutations/createCoreViewGroup'; import { DESTROY_CORE_VIEW_GROUP } from '@/views/graphql/mutations/destroyCoreViewGroup'; import { UPDATE_CORE_VIEW_GROUP } from '@/views/graphql/mutations/updateCoreViewGroup'; +import { useTriggerViewGroupOptimisticEffect } from '@/views/optimistic-effects/hooks/useTriggerViewGroupOptimisticEffect'; import { type ViewGroup } from '@/views/types/ViewGroup'; import { useApolloClient } from '@apollo/client'; +import { type CoreViewGroup } from '~/generated/graphql'; type CreateViewGroupRecordsArgs = { viewGroupsToCreate: ViewGroup[]; @@ -14,6 +16,9 @@ type CreateViewGroupRecordsArgs = { export const usePersistViewGroupRecords = () => { const apolloClient = useApolloClient(); + const { triggerViewGroupOptimisticEffect } = + useTriggerViewGroupOptimisticEffect(); + const createCoreViewGroupRecords = useCallback( ({ viewGroupsToCreate, viewId }: CreateViewGroupRecordsArgs) => { if (viewGroupsToCreate.length === 0) return; @@ -32,11 +37,19 @@ export const usePersistViewGroupRecords = () => { position: viewGroup.position, }, }, + update: (_cache, { data }) => { + const record = data?.['createCoreViewGroup']; + if (!record) return; + + triggerViewGroupOptimisticEffect({ + createdViewGroups: [record], + }); + }, }), ), ); }, - [apolloClient], + [apolloClient, triggerViewGroupOptimisticEffect], ); const updateCoreViewGroupRecords = useCallback( @@ -44,7 +57,7 @@ export const usePersistViewGroupRecords = () => { if (!viewGroupsToUpdate.length) return; const mutationPromises = viewGroupsToUpdate.map((viewGroup) => - apolloClient.mutate<{ updateCoreViewGroup: ViewGroup }>({ + apolloClient.mutate<{ updateCoreViewGroup: CoreViewGroup }>({ mutation: UPDATE_CORE_VIEW_GROUP, variables: { id: viewGroup.id, @@ -55,35 +68,24 @@ export const usePersistViewGroupRecords = () => { }, // Avoid cache being updated with stale data fetchPolicy: 'no-cache', + update: (_cache, { data }) => { + const record = data?.['updateCoreViewGroup']; + if (!record) return; + + triggerViewGroupOptimisticEffect({ + updatedViewGroups: [record], + }); + }, }), ); - const mutationResults = await Promise.all(mutationPromises); - - // FixMe: Using useUpdateOneRecord hook that call triggerUpdateRecordsOptimisticEffect is actaully causing multiple records to be created - // This is a temporary fix - mutationResults.forEach(({ data }) => { - const record = data?.['updateCoreViewGroup']; - - if (!record) return; - - apolloClient.cache.modify({ - id: apolloClient.cache.identify({ - __typename: 'CoreViewGroup', - id: record.id, - }), - fields: { - isVisible: () => record.isVisible, - position: () => record.position, - }, - }); - }); + return Promise.all(mutationPromises); }, - [apolloClient], + [apolloClient, triggerViewGroupOptimisticEffect], ); const deleteCoreViewGroupRecords = useCallback( - async (viewGroupsToDelete: ViewGroup[]) => { + async (viewGroupsToDelete: Pick[]) => { if (!viewGroupsToDelete.length) return; return Promise.all( @@ -93,11 +95,16 @@ export const usePersistViewGroupRecords = () => { variables: { id: viewGroup.id, }, + update: () => { + triggerViewGroupOptimisticEffect({ + deletedViewGroups: [viewGroup], + }); + }, }), ), ); }, - [apolloClient], + [apolloClient, triggerViewGroupOptimisticEffect], ); return { diff --git a/packages/twenty-front/src/modules/views/hooks/useSaveCurrentViewFiltersAndSorts.ts b/packages/twenty-front/src/modules/views/hooks/useSaveCurrentViewFiltersAndSorts.ts index 1eebfa39fa..0569930d69 100644 --- a/packages/twenty-front/src/modules/views/hooks/useSaveCurrentViewFiltersAndSorts.ts +++ b/packages/twenty-front/src/modules/views/hooks/useSaveCurrentViewFiltersAndSorts.ts @@ -16,8 +16,8 @@ export const useSaveCurrentViewFiltersAndSorts = () => { const saveCurrentViewFilterAndSorts = async () => { await saveRecordSortsToViewSorts(); - await saveRecordFiltersToViewFilters(); await saveRecordFilterGroupsToViewFilterGroups(); + await saveRecordFiltersToViewFilters(); await saveAnyFieldFilterToView(); }; diff --git a/packages/twenty-front/src/modules/views/optimistic-effects/hooks/useTriggerViewFieldOptimisticEffect.ts b/packages/twenty-front/src/modules/views/optimistic-effects/hooks/useTriggerViewFieldOptimisticEffect.ts index b031106f79..64bd5de5b7 100644 --- a/packages/twenty-front/src/modules/views/optimistic-effects/hooks/useTriggerViewFieldOptimisticEffect.ts +++ b/packages/twenty-front/src/modules/views/optimistic-effects/hooks/useTriggerViewFieldOptimisticEffect.ts @@ -21,7 +21,7 @@ export const useTriggerViewFieldOptimisticEffect = () => { }: { createdViewFields?: CoreViewField[]; updatedViewFields?: CoreViewField[]; - deletedViewFields?: CoreViewField[]; + deletedViewFields?: Pick[]; }) => { const coreViews = getSnapshotValue(snapshot, coreViewsState); let newCoreViews = [...coreViews]; @@ -96,38 +96,40 @@ export const useTriggerViewFieldOptimisticEffect = () => { } }); - deletedViewFields.forEach((deletedViewField) => { - cache.modify({ - id: cache.identify({ - __typename: 'CoreView', - id: deletedViewField.viewId, - }), - fields: { - viewFields: (existingViewFields, { readField }) => - existingViewFields.filter( - (viewField) => - readField('id', viewField) !== deletedViewField.id, - ), - }, - }); - const toBeModifiedCoreView = newCoreViews.find( - (coreView) => coreView.id === deletedViewField.viewId, - ); - - if (isDefined(toBeModifiedCoreView)) { - newCoreViews = [ - ...newCoreViews.filter( - (coreView) => coreView.id !== deletedViewField.viewId, - ), - { - ...toBeModifiedCoreView, - viewFields: toBeModifiedCoreView.viewFields.filter( - (viewField) => viewField.id !== deletedViewField.id, - ), + deletedViewFields.forEach( + (deletedViewField: Pick) => { + cache.modify({ + id: cache.identify({ + __typename: 'CoreView', + id: deletedViewField.viewId, + }), + fields: { + viewFields: (existingViewFields, { readField }) => + existingViewFields.filter( + (viewField) => + readField('id', viewField) !== deletedViewField.id, + ), }, - ]; - } - }); + }); + const toBeModifiedCoreView = newCoreViews.find( + (coreView) => coreView.id === deletedViewField.viewId, + ); + + if (isDefined(toBeModifiedCoreView)) { + newCoreViews = [ + ...newCoreViews.filter( + (coreView) => coreView.id !== deletedViewField.viewId, + ), + { + ...toBeModifiedCoreView, + viewFields: toBeModifiedCoreView.viewFields.filter( + (viewField) => viewField.id !== deletedViewField.id, + ), + }, + ]; + } + }, + ); if (!isDeeplyEqual(coreViews, newCoreViews)) { set(coreViewsState, newCoreViews); diff --git a/packages/twenty-front/src/modules/views/optimistic-effects/hooks/useTriggerViewGroupOptimisticEffect.ts b/packages/twenty-front/src/modules/views/optimistic-effects/hooks/useTriggerViewGroupOptimisticEffect.ts new file mode 100644 index 0000000000..c6a6a5bd1d --- /dev/null +++ b/packages/twenty-front/src/modules/views/optimistic-effects/hooks/useTriggerViewGroupOptimisticEffect.ts @@ -0,0 +1,142 @@ +import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; +import { coreViewsState } from '@/views/states/coreViewState'; +import { type CoreViewWithRelations } from '@/views/types/CoreViewWithRelations'; +import { useApolloClient } from '@apollo/client'; +import { useRecoilCallback } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; +import { type CoreViewGroup } from '~/generated/graphql'; +import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; + +export const useTriggerViewGroupOptimisticEffect = () => { + const apolloClient = useApolloClient(); + + const cache = apolloClient.cache; + + const triggerViewGroupOptimisticEffect = useRecoilCallback( + ({ set, snapshot }) => + ({ + createdViewGroups = [], + updatedViewGroups = [], + deletedViewGroups = [], + }: { + createdViewGroups?: CoreViewGroup[]; + updatedViewGroups?: CoreViewGroup[]; + deletedViewGroups?: Pick[]; + }) => { + const coreViews = getSnapshotValue(snapshot, coreViewsState); + let newCoreViews = [...coreViews]; + + createdViewGroups.forEach((createdViewGroup) => { + cache.modify({ + id: cache.identify({ + __typename: 'CoreView', + id: createdViewGroup.viewId, + }), + fields: { + viewGroups: (existingViewGroups, { toReference }) => [ + ...(existingViewGroups ?? []), + toReference(createdViewGroup), + ], + }, + }); + const toBeModifiedCoreView = newCoreViews.find( + (coreView) => coreView.id === createdViewGroup.viewId, + ); + if (isDefined(toBeModifiedCoreView)) { + newCoreViews = [ + ...newCoreViews.filter( + (coreView) => coreView.id !== createdViewGroup.viewId, + ), + { + ...toBeModifiedCoreView, + viewGroups: [ + ...toBeModifiedCoreView.viewGroups, + createdViewGroup, + ], + }, + ]; + } + }); + + updatedViewGroups.forEach((updatedViewGroup) => { + cache.modify({ + id: cache.identify({ + __typename: 'CoreView', + id: updatedViewGroup.viewId, + }), + fields: { + viewGroups: (existingViewGroups, { readField, toReference }) => + existingViewGroups.map((viewGroup) => { + const viewGroupId = readField('id', viewGroup); + if (viewGroupId === updatedViewGroup.id) { + return toReference(updatedViewGroup); + } + return viewGroup; + }), + }, + }); + const toBeModifiedCoreView = newCoreViews.find( + (coreView) => coreView.id === updatedViewGroup.viewId, + ); + if (isDefined(toBeModifiedCoreView)) { + newCoreViews = [ + ...newCoreViews.filter( + (coreView) => coreView.id !== updatedViewGroup.viewId, + ), + { + ...toBeModifiedCoreView, + viewGroups: [ + ...toBeModifiedCoreView.viewGroups.filter( + (viewGroup) => viewGroup.id !== updatedViewGroup.id, + ), + updatedViewGroup, + ], + }, + ]; + } + }); + + deletedViewGroups.forEach((deletedViewGroup) => { + cache.modify({ + id: cache.identify({ + __typename: 'CoreView', + id: deletedViewGroup.viewId, + }), + fields: { + viewGroups: (existingViewGroups, { readField }) => + existingViewGroups.filter( + (viewGroup) => + readField('id', viewGroup) !== deletedViewGroup.id, + ), + }, + }); + const toBeModifiedCoreView = newCoreViews.find( + (coreView) => coreView.id === deletedViewGroup.viewId, + ); + + if (isDefined(toBeModifiedCoreView)) { + newCoreViews = [ + ...newCoreViews.filter( + (coreView) => coreView.id !== deletedViewGroup.viewId, + ), + { + ...toBeModifiedCoreView, + viewGroups: toBeModifiedCoreView.viewGroups.filter( + (viewGroup) => viewGroup.id !== deletedViewGroup.id, + ), + }, + ]; + } + }); + + if (!isDeeplyEqual(coreViews, newCoreViews)) { + set(coreViewsState, newCoreViews); + } + }, + [cache], + ); + + return { + triggerViewGroupOptimisticEffect, + }; +};