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
This commit is contained in:
Charles Bochet
2025-09-10 15:43:40 +02:00
committed by GitHub
parent 30a2164980
commit 4d6ef9bc34
6 changed files with 256 additions and 68 deletions
@@ -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<GraphQLView> = {};
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;
@@ -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);
},
@@ -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<CoreViewGroup, 'id' | 'viewId'>[]) => {
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 {
@@ -16,8 +16,8 @@ export const useSaveCurrentViewFiltersAndSorts = () => {
const saveCurrentViewFilterAndSorts = async () => {
await saveRecordSortsToViewSorts();
await saveRecordFiltersToViewFilters();
await saveRecordFilterGroupsToViewFilterGroups();
await saveRecordFiltersToViewFilters();
await saveAnyFieldFilterToView();
};
@@ -21,7 +21,7 @@ export const useTriggerViewFieldOptimisticEffect = () => {
}: {
createdViewFields?: CoreViewField[];
updatedViewFields?: CoreViewField[];
deletedViewFields?: CoreViewField[];
deletedViewFields?: Pick<CoreViewField, 'id' | 'viewId'>[];
}) => {
const coreViews = getSnapshotValue(snapshot, coreViewsState);
let newCoreViews = [...coreViews];
@@ -96,38 +96,40 @@ export const useTriggerViewFieldOptimisticEffect = () => {
}
});
deletedViewFields.forEach((deletedViewField) => {
cache.modify<CoreViewWithRelations>({
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<CoreViewField, 'id' | 'viewId'>) => {
cache.modify<CoreViewWithRelations>({
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);
@@ -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<CoreViewGroup, 'id' | 'viewId'>[];
}) => {
const coreViews = getSnapshotValue(snapshot, coreViewsState);
let newCoreViews = [...coreViews];
createdViewGroups.forEach((createdViewGroup) => {
cache.modify<CoreViewWithRelations>({
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<CoreViewWithRelations>({
id: cache.identify({
__typename: 'CoreView',
id: updatedViewGroup.viewId,
}),
fields: {
viewGroups: (existingViewGroups, { readField, toReference }) =>
existingViewGroups.map((viewGroup) => {
const viewGroupId = readField<string>('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<CoreViewWithRelations>({
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,
};
};