From 86df188e834260ce75a7aba15f4d6d8f48a605a7 Mon Sep 17 00:00:00 2001 From: Marie <51697796+ijreilly@users.noreply.github.com> Date: Mon, 8 Dec 2025 15:05:05 +0100 Subject: [PATCH] [fix] Fix switch view type from "Layout" switcher (#16382) In this previous work aiming at eliminating the creation of viewGroups being separated from the views, I removed the creation of viewGroups from the FE at view creation, but forgot to do it at view update when the user chooses "change Layout" option (from Table/Kanban/Calendar to another). In this PR - I removed creation, deletion and destroy of viewGroups from usePersistViewGroupRecords. I left update because it useful for visibility and position - since usePersistViewGroups record was also handling optimistic, I moved optimistic logic to a new hook useViewsSideEffectsOnViewGroups. I realized that so far we optimistic is only useful for creation. For the update though we do need to compute the view groups that will be created to be able to call loadRecordIndexState, so I created a function for that, but I did not seek to perform real optimistic, with writing and deleting groups from the cache as it was a bit heavier and not in use for now and I want to merge this asap as it fixes the creation of viewGroups. --- .../useSetViewTypeFromLayoutOptionsMenu.ts | 99 +++------- .../components/RecordBoardQueryEffect.tsx | 11 +- ...SwitchingToKanbanViewTypeComponentState.ts | 9 + .../hooks/useHandleRecordGroupField.ts | 39 ++-- .../views/hooks/internal/usePersistView.ts | 40 +++- .../hooks/internal/usePersistViewGroup.ts | 182 ------------------ .../hooks/useCreateViewFromCurrentView.ts | 143 ++++---------- .../views/hooks/useSaveCurrentViewFields.ts | 7 +- .../views/hooks/useSaveCurrentViewGroups.ts | 23 +-- .../views/hooks/useUpdateCurrentView.ts | 98 +++++++++- .../hooks/useViewsSideEffectsOnViewGroups.ts | 87 +++++++++ .../states/isPersistingViewFieldsState.ts | 5 - .../utils/convertUpdateViewInputToCore.ts | 3 + 13 files changed, 326 insertions(+), 420 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/record-board/states/isSwitchingToKanbanViewTypeComponentState.ts create mode 100644 packages/twenty-front/src/modules/views/hooks/useViewsSideEffectsOnViewGroups.ts delete mode 100644 packages/twenty-front/src/modules/views/states/isPersistingViewFieldsState.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 923ab48db2..d29ec98be8 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 @@ -1,75 +1,35 @@ import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId'; import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState'; +import { isSwitchingToKanbanViewTypeComponentState } from '@/object-record/record-board/states/isSwitchingToKanbanViewTypeComponentState'; import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; import { useLoadRecordIndexStates } from '@/object-record/record-index/hooks/useLoadRecordIndexStates'; import { recordIndexViewTypeState } from '@/object-record/record-index/states/recordIndexViewTypeState'; -import { usePersistViewGroupRecords } from '@/views/hooks/internal/usePersistViewGroup'; +import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; import { useUpdateCurrentView } from '@/views/hooks/useUpdateCurrentView'; 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 { useGetAvailableFieldsForCalendar } from '@/views/view-picker/hooks/useGetAvailableFieldsForCalendar'; import { useGetAvailableFieldsForKanban } from '@/views/view-picker/hooks/useGetAvailableFieldsForKanban'; -import { useCallback } from 'react'; import { useRecoilCallback, useSetRecoilState } from 'recoil'; import { assertUnreachable, isDefined } from 'twenty-shared/utils'; -import { v4 } from 'uuid'; import { ViewCalendarLayout } from '~/generated/graphql'; export const useSetViewTypeFromLayoutOptionsMenu = () => { const { updateCurrentView } = useUpdateCurrentView(); const setRecordIndexViewType = useSetRecoilState(recordIndexViewTypeState); + const isSwitchingToKanbanViewTypeCallbackState = + useRecoilComponentCallbackState(isSwitchingToKanbanViewTypeComponentState); const { availableFieldsForKanban } = useGetAvailableFieldsForKanban(); const { objectMetadataItem } = useRecordIndexContextOrThrow(); const { loadRecordIndexStates } = useLoadRecordIndexStates(); - const { createViewGroups } = usePersistViewGroupRecords(); - const { availableFieldsForCalendar } = useGetAvailableFieldsForCalendar(); - const createViewGroupAssociatedWithKanbanField = useCallback( - async (randomFieldForKanban: string, currentViewId: string) => { - const viewGroupsToCreate = - objectMetadataItem.fields - ?.find((field) => field.id === randomFieldForKanban) - ?.options?.map( - (option, index) => - ({ - id: v4(), - __typename: 'ViewGroup', - fieldValue: option.value, - isVisible: true, - position: index, - }) satisfies ViewGroup, - ) ?? []; - - viewGroupsToCreate.push({ - __typename: 'ViewGroup', - id: v4(), - fieldValue: '', - position: viewGroupsToCreate.length, - isVisible: true, - } satisfies ViewGroup); - - await createViewGroups({ - createCoreViewGroupInputs: { - inputs: viewGroupsToCreate.map(({ __typename, ...viewGroup }) => ({ - ...viewGroup, - viewId: currentViewId, - })), - }, - }); - - return viewGroupsToCreate; - }, - [objectMetadataItem, createViewGroups], - ); - const setAndPersistViewType = useRecoilCallback( ({ snapshot, set }) => async (viewType: ViewType) => { @@ -104,20 +64,20 @@ export const useSetViewTypeFromLayoutOptionsMenu = () => { switch (viewType) { case ViewType.Kanban: { + set(isSwitchingToKanbanViewTypeCallbackState, true); if (availableFieldsForKanban.length === 0) { throw new Error('No fields for kanban - should not happen'); } - if (currentView.viewGroups.length === 0) { - const viewGroups = await createViewGroupAssociatedWithKanbanField( - availableFieldsForKanban[0].id, - currentView.id, - ); - loadRecordIndexStates( - { ...currentView, viewGroups }, - objectMetadataItem, - ); + const mainGroupByFieldMetadataId = availableFieldsForKanban[0].id; + updateCurrentViewParams.mainGroupByFieldMetadataId = + mainGroupByFieldMetadataId; + + if (shouldChangeIcon(currentView.icon, currentView.type)) { + updateCurrentViewParams.icon = + viewTypeIconMapping(viewType).displayName; } + setRecordIndexViewType(viewType); set(coreViewsState, [ ...existingCoreViews.filter( @@ -126,16 +86,19 @@ export const useSetViewTypeFromLayoutOptionsMenu = () => { { ...currentCoreView, type: convertViewTypeToCore(viewType), + mainGroupByFieldMetadataId, }, ]); - - if (shouldChangeIcon(currentView.icon, currentView.type)) { - updateCurrentViewParams.icon = - viewTypeIconMapping(viewType).displayName; - } - return await updateCurrentView(updateCurrentViewParams); + await updateCurrentView(updateCurrentViewParams); + return; } case ViewType.Table: { + if (shouldChangeIcon(currentView.icon, currentView.type)) { + updateCurrentViewParams.icon = + viewTypeIconMapping(viewType).displayName; + } + updateCurrentViewParams.mainGroupByFieldMetadataId = null; + await updateCurrentView(updateCurrentViewParams); setRecordIndexViewType(viewType); set(coreViewsState, [ ...existingCoreViews.filter( @@ -143,15 +106,11 @@ export const useSetViewTypeFromLayoutOptionsMenu = () => { ), { ...currentCoreView, + mainGroupByFieldMetadataId: null, type: convertViewTypeToCore(viewType), }, ]); - - if (shouldChangeIcon(currentView.icon, currentView.type)) { - updateCurrentViewParams.icon = - viewTypeIconMapping(viewType).displayName; - } - return await updateCurrentView(updateCurrentViewParams); + return; } case ViewType.Calendar: { if (availableFieldsForCalendar.length === 0) { @@ -167,6 +126,7 @@ export const useSetViewTypeFromLayoutOptionsMenu = () => { ), { ...currentCoreView, + mainGroupByFieldMetadataId: null, type: convertViewTypeToCore(viewType), calendarLayout: ViewCalendarLayout.MONTH, calendarFieldMetadataId, @@ -190,6 +150,7 @@ export const useSetViewTypeFromLayoutOptionsMenu = () => { updateCurrentViewParams.calendarLayout = ViewCalendarLayout.MONTH; updateCurrentViewParams.calendarFieldMetadataId = calendarFieldMetadataId; + updateCurrentViewParams.mainGroupByFieldMetadataId = null; return await updateCurrentView(updateCurrentViewParams); } default: { @@ -198,13 +159,13 @@ export const useSetViewTypeFromLayoutOptionsMenu = () => { } }, [ + isSwitchingToKanbanViewTypeCallbackState, availableFieldsForKanban, - objectMetadataItem, - updateCurrentView, setRecordIndexViewType, - createViewGroupAssociatedWithKanbanField, - loadRecordIndexStates, + updateCurrentView, availableFieldsForCalendar, + loadRecordIndexStates, + objectMetadataItem, ], ); diff --git a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardQueryEffect.tsx b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardQueryEffect.tsx index f162185b2a..a9f99eb80a 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardQueryEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardQueryEffect.tsx @@ -1,5 +1,6 @@ import { useTriggerRecordBoardFetchMore } from '@/object-record/record-board/hooks/useTriggerRecordBoardFetchMore'; import { useTriggerRecordBoardInitialQuery } from '@/object-record/record-board/hooks/useTriggerRecordBoardInitialQuery'; +import { isSwitchingToKanbanViewTypeComponentState } from '@/object-record/record-board/states/isSwitchingToKanbanViewTypeComponentState'; import { lastRecordBoardQueryIdentifierComponentState } from '@/object-record/record-board/states/lastRecordBoardQueryIdentifierComponentState'; import { recordBoardCurrentGroupByQueryOffsetComponentState } from '@/object-record/record-board/states/recordBoardCurrentGroupByQueryOffsetComponentState'; import { recordBoardIsFetchingMoreComponentState } from '@/object-record/record-board/states/recordBoardIsFetchingMoreComponentState'; @@ -22,6 +23,11 @@ export const RecordBoardQueryEffect = () => { const [lastRecordBoardQueryIdentifier, setLastRecordBoardQueryIdentifier] = useRecoilComponentState(lastRecordBoardQueryIdentifierComponentState); + const [ + isSwitchingToKanbanViewTypeCallbackState, + setIsSwitchingToKanbanViewTypeComponentState, + ] = useRecoilComponentState(isSwitchingToKanbanViewTypeComponentState); + const [recordIndexRecordGroupsAreInInitialLoading] = useRecoilComponentState( recordIndexRecordGroupsAreInInitialLoadingComponentState, ); @@ -60,9 +66,10 @@ export const RecordBoardQueryEffect = () => { useEffect(() => { if ( !recordIndexRecordGroupsAreInInitialLoading && - queryIdentifierHasChanged + (queryIdentifierHasChanged || isSwitchingToKanbanViewTypeCallbackState) ) { triggerRecordBoardInitialQuery(); + setIsSwitchingToKanbanViewTypeComponentState(false); } else if ( !recordIndexRecordGroupsAreInInitialLoading && shouldFetchMore && @@ -82,6 +89,8 @@ export const RecordBoardQueryEffect = () => { shouldFetchMore, recordBoardIsFetchingMore, triggerRecordBoardFetchMore, + isSwitchingToKanbanViewTypeCallbackState, + setIsSwitchingToKanbanViewTypeComponentState, ]); return null; diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/isSwitchingToKanbanViewTypeComponentState.ts b/packages/twenty-front/src/modules/object-record/record-board/states/isSwitchingToKanbanViewTypeComponentState.ts new file mode 100644 index 0000000000..74bdeb91f1 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/states/isSwitchingToKanbanViewTypeComponentState.ts @@ -0,0 +1,9 @@ +import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext'; +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const isSwitchingToKanbanViewTypeComponentState = + createComponentState({ + key: 'isSwitchingToKanbanViewTypeComponentState', + componentInstanceContext: ContextStoreComponentInstanceContext, + defaultValue: false, + }); 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 47a99f92d0..58a3543b5d 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 @@ -5,8 +5,8 @@ import { useSetRecordGroups } from '@/object-record/record-group/hooks/useSetRec import { useLoadRecordIndexStates } from '@/object-record/record-index/hooks/useLoadRecordIndexStates'; import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; import { usePersistView } from '@/views/hooks/internal/usePersistView'; -import { usePersistViewGroupRecords } from '@/views/hooks/internal/usePersistViewGroup'; import { useGetViewFromPrefetchState } from '@/views/hooks/useGetViewFromPrefetchState'; +import { useRefreshCoreViewsByObjectMetadataId } from '@/views/hooks/useRefreshCoreViewsByObjectMetadataId'; import { type ViewGroup } from '@/views/types/ViewGroup'; import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView'; import { useRecoilCallback } from 'recoil'; @@ -16,8 +16,6 @@ import { type CoreView } from '~/generated/graphql'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; export const useHandleRecordGroupField = () => { - const { deleteViewGroups } = usePersistViewGroupRecords(); - const currentViewIdCallbackState = useRecoilComponentCallbackState( contextStoreCurrentViewIdComponentState, ); @@ -30,6 +28,8 @@ export const useHandleRecordGroupField = () => { const { updateView } = usePersistView(); const { loadRecordIndexStates } = useLoadRecordIndexStates(); + const { refreshCoreViewsByObjectMetadataId } = + useRefreshCoreViewsByObjectMetadataId(); const handleRecordGroupFieldChange = useRecoilCallback( ({ snapshot }) => @@ -127,6 +127,8 @@ export const useHandleRecordGroupField = () => { viewGroups: newViewGroupsList, objectMetadataItem, }); + + await refreshCoreViewsByObjectMetadataId(objectMetadataItem.id); }, [ currentViewIdCallbackState, @@ -134,6 +136,7 @@ export const useHandleRecordGroupField = () => { updateView, setRecordGroupsFromViewGroups, objectMetadataItem, + refreshCoreViewsByObjectMetadataId, loadRecordIndexStates, ], ); @@ -159,32 +162,14 @@ export const useHandleRecordGroupField = () => { return; } - await deleteViewGroups( - view.viewGroups.map((group) => ({ - input: { - id: group.id, - }, - })), - ); - - if (!isDefined(view.mainGroupByFieldMetadataId)) { - throw new Error('mainGroupByFieldMetadataId is required'); - } - - setRecordGroupsFromViewGroups({ - viewId: view.id, - mainGroupByFieldMetadataId: view.mainGroupByFieldMetadataId, - viewGroups: [], - objectMetadataItem, + await updateView({ + id: view.id, + input: { + mainGroupByFieldMetadataId: null, + }, }); }, - [ - deleteViewGroups, - currentViewIdCallbackState, - getViewFromPrefetchState, - setRecordGroupsFromViewGroups, - objectMetadataItem, - ], + [currentViewIdCallbackState, getViewFromPrefetchState, updateView], ); return { handleRecordGroupFieldChange, resetRecordGroupField }; diff --git a/packages/twenty-front/src/modules/views/hooks/internal/usePersistView.ts b/packages/twenty-front/src/modules/views/hooks/internal/usePersistView.ts index 4ab299bf4f..f2c4c6f328 100644 --- a/packages/twenty-front/src/modules/views/hooks/internal/usePersistView.ts +++ b/packages/twenty-front/src/modules/views/hooks/internal/usePersistView.ts @@ -3,8 +3,11 @@ import { useCallback } from 'react'; import { useMetadataErrorHandler } from '@/metadata-error-handler/hooks/useMetadataErrorHandler'; import { type MetadataRequestResult } from '@/object-metadata/types/MetadataRequestResult.type'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { useViewsSideEffectsOnViewGroups } from '@/views/hooks/useViewsSideEffectsOnViewGroups'; import { ApolloError } from '@apollo/client'; import { t } from '@lingui/core/macro'; +import { isDefined } from 'twenty-shared/utils'; +import { v4 } from 'uuid'; import { type CreateCoreViewMutationVariables, type DeleteCoreViewMutationVariables, @@ -12,12 +15,15 @@ import { useCreateCoreViewMutation, useDeleteCoreViewMutation, useUpdateCoreViewMutation, + ViewType, } from '~/generated/graphql'; export const usePersistView = () => { const [createCoreViewMutation] = useCreateCoreViewMutation(); const [updateCoreViewMutation] = useUpdateCoreViewMutation(); const [deleteCoreViewMutation] = useDeleteCoreViewMutation(); + const { triggerViewGroupOptimisticEffectAtViewCreation } = + useViewsSideEffectsOnViewGroups(); const { handleMetadataError } = useMetadataErrorHandler(); const { enqueueErrorSnackBar } = useSnackBar(); @@ -25,14 +31,39 @@ export const usePersistView = () => { const createView = useCallback( async ( variables: CreateCoreViewMutationVariables, + objectMetadataItemId: string, ): Promise< MetadataRequestResult>> > => { try { + const newViewId = variables.input.id ?? v4(); + if (variables.input.type === ViewType.KANBAN) { + triggerViewGroupOptimisticEffectAtViewCreation({ + newViewId, + objectMetadataItemId: objectMetadataItemId, + mainGroupByFieldMetadataId: + variables.input.mainGroupByFieldMetadataId, + }); + } + const result = await createCoreViewMutation({ - variables, + variables: { + input: { + ...variables.input, + id: newViewId, + }, + }, }); + const newView = result.data?.createCoreView; + + if (!isDefined(newView)) { + return { + status: 'failed', + error: new Error('Failed to create view'), + }; + } + return { status: 'successful', response: result, @@ -52,7 +83,12 @@ export const usePersistView = () => { }; } }, - [createCoreViewMutation, handleMetadataError, enqueueErrorSnackBar], + [ + createCoreViewMutation, + triggerViewGroupOptimisticEffectAtViewCreation, + handleMetadataError, + enqueueErrorSnackBar, + ], ); const updateView = useCallback( diff --git a/packages/twenty-front/src/modules/views/hooks/internal/usePersistViewGroup.ts b/packages/twenty-front/src/modules/views/hooks/internal/usePersistViewGroup.ts index bd728989e2..eca0fff275 100644 --- a/packages/twenty-front/src/modules/views/hooks/internal/usePersistViewGroup.ts +++ b/packages/twenty-front/src/modules/views/hooks/internal/usePersistViewGroup.ts @@ -8,13 +8,7 @@ import { ApolloError } from '@apollo/client'; import { t } from '@lingui/core/macro'; import { isDefined } from 'twenty-shared/utils'; import { - type CreateManyCoreViewGroupsMutationVariables, - type DeleteCoreViewGroupMutationVariables, - type DestroyCoreViewGroupMutationVariables, type UpdateCoreViewGroupMutationVariables, - useCreateManyCoreViewGroupsMutation, - useDeleteCoreViewGroupMutation, - useDestroyCoreViewGroupMutation, useUpdateCoreViewGroupMutation, } from '~/generated/graphql'; @@ -22,77 +16,11 @@ export const usePersistViewGroupRecords = () => { const { triggerViewGroupOptimisticEffect } = useTriggerViewGroupOptimisticEffect(); - const [createManyCoreViewGroupsMutation] = - useCreateManyCoreViewGroupsMutation(); const [updateCoreViewGroupMutation] = useUpdateCoreViewGroupMutation(); - const [deleteCoreViewGroupMutation] = useDeleteCoreViewGroupMutation(); - const [destroyCoreViewGroupMutation] = useDestroyCoreViewGroupMutation(); const { handleMetadataError } = useMetadataErrorHandler(); const { enqueueErrorSnackBar } = useSnackBar(); - const createViewGroups = useCallback( - async ({ - createCoreViewGroupInputs, - }: { - createCoreViewGroupInputs: CreateManyCoreViewGroupsMutationVariables; - }): Promise< - MetadataRequestResult - > | null> - > => { - if ( - !Array.isArray(createCoreViewGroupInputs.inputs) || - createCoreViewGroupInputs.inputs.length === 0 - ) { - return { - status: 'successful', - response: null, - }; - } - - try { - const result = await createManyCoreViewGroupsMutation({ - variables: createCoreViewGroupInputs, - update: (_cache, { data }) => { - const createdViewGroups = data?.createManyCoreViewGroups; - if (!isDefined(createdViewGroups)) { - return; - } - - triggerViewGroupOptimisticEffect({ - createdViewGroups, - }); - }, - }); - - return { - status: 'successful', - response: result, - }; - } catch (error) { - if (error instanceof ApolloError) { - handleMetadataError(error, { - primaryMetadataName: 'viewGroup', - }); - } else { - enqueueErrorSnackBar({ message: t`An error occurred.` }); - } - - return { - status: 'failed', - error, - }; - } - }, - [ - triggerViewGroupOptimisticEffect, - createManyCoreViewGroupsMutation, - handleMetadataError, - enqueueErrorSnackBar, - ], - ); - const updateViewGroups = useCallback( async ( updateCoreViewGroupInputs: UpdateCoreViewGroupMutationVariables[], @@ -154,117 +82,7 @@ export const usePersistViewGroupRecords = () => { ], ); - const deleteViewGroups = useCallback( - async ( - deleteCoreViewGroupInputs: DeleteCoreViewGroupMutationVariables[], - ): Promise< - MetadataRequestResult< - Awaited>[] - > - > => { - if (deleteCoreViewGroupInputs.length === 0) { - return { - status: 'successful', - response: [], - }; - } - - try { - const results = await Promise.all( - deleteCoreViewGroupInputs.map((variables) => - deleteCoreViewGroupMutation({ - variables, - update: (_cache, { data }) => { - const deletedViewGroup = data?.deleteCoreViewGroup; - if (!isDefined(deletedViewGroup)) { - return; - } - - triggerViewGroupOptimisticEffect({ - deletedViewGroups: [deletedViewGroup], - }); - }, - }), - ), - ); - - return { - status: 'successful', - response: results, - }; - } catch (error) { - if (error instanceof ApolloError) { - handleMetadataError(error, { - primaryMetadataName: 'viewGroup', - }); - } else { - enqueueErrorSnackBar({ message: t`An error occurred.` }); - } - - return { - status: 'failed', - error, - }; - } - }, - [ - triggerViewGroupOptimisticEffect, - deleteCoreViewGroupMutation, - handleMetadataError, - enqueueErrorSnackBar, - ], - ); - - const destroyViewGroups = useCallback( - async ( - destroyCoreViewGroupInputs: DestroyCoreViewGroupMutationVariables[], - ): Promise< - MetadataRequestResult< - Awaited>[] - > - > => { - if (destroyCoreViewGroupInputs.length === 0) { - return { - status: 'successful', - response: [], - }; - } - - try { - const results = await Promise.all( - destroyCoreViewGroupInputs.map((variables) => - destroyCoreViewGroupMutation({ - variables, - }), - ), - ); - - return { - status: 'successful', - response: results, - }; - } catch (error) { - if (error instanceof ApolloError) { - handleMetadataError(error, { - primaryMetadataName: 'viewGroup', - }); - } else { - enqueueErrorSnackBar({ message: t`An error occurred.` }); - } - - return { - status: 'failed', - error, - }; - } - }, - [destroyCoreViewGroupMutation, handleMetadataError, enqueueErrorSnackBar], - ); - return { - createViewGroups, updateViewGroups, - deleteViewGroups, - destroyViewGroups, }; }; diff --git a/packages/twenty-front/src/modules/views/hooks/useCreateViewFromCurrentView.ts b/packages/twenty-front/src/modules/views/hooks/useCreateViewFromCurrentView.ts index 64d7e2c568..b17abae067 100644 --- a/packages/twenty-front/src/modules/views/hooks/useCreateViewFromCurrentView.ts +++ b/packages/twenty-front/src/modules/views/hooks/useCreateViewFromCurrentView.ts @@ -13,11 +13,8 @@ import { usePersistViewFilterRecords } from '@/views/hooks/internal/usePersistVi import { usePersistViewFilterGroupRecords } from '@/views/hooks/internal/usePersistViewFilterGroup'; import { usePersistViewSortRecords } from '@/views/hooks/internal/usePersistViewSort'; import { useRefreshCoreViewsByObjectMetadataId } from '@/views/hooks/useRefreshCoreViewsByObjectMetadataId'; -import { useTriggerViewGroupOptimisticEffect } from '@/views/optimistic-effects/hooks/useTriggerViewGroupOptimisticEffect'; -import { isPersistingViewFieldsState } from '@/views/states/isPersistingViewFieldsState'; import { coreViewFromViewIdFamilySelector } from '@/views/states/selectors/coreViewFromViewIdFamilySelector'; import { type GraphQLView } from '@/views/types/GraphQLView'; -import { type ViewGroup } from '@/views/types/ViewGroup'; import { ViewType } from '@/views/types/ViewType'; import { convertViewOpenRecordInToCore } from '@/views/utils/convertViewOpenRecordInToCore'; import { convertViewTypeToCore } from '@/views/utils/convertViewTypeToCore'; @@ -28,10 +25,7 @@ import { mapRecordSortToViewSort } from '@/views/utils/mapRecordSortToViewSort'; import { useRecoilCallback } from 'recoil'; import { isDefined } from 'twenty-shared/utils'; import { v4 } from 'uuid'; -import { - type CoreViewGroup, - ViewCalendarLayout, -} from '~/generated-metadata/graphql'; +import { ViewCalendarLayout } from '~/generated-metadata/graphql'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => { @@ -50,9 +44,6 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => { const { createViewSorts } = usePersistViewSortRecords(); - const { triggerViewGroupOptimisticEffect } = - useTriggerViewGroupOptimisticEffect(); - const { createViewFilters } = usePersistViewFilterRecords(); const { createViewFilterGroups } = usePersistViewFilterGroupRecords(); @@ -75,7 +66,7 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => { ); const createViewFromCurrentView = useRecoilCallback( - ({ snapshot, set }) => + ({ snapshot }) => async ( { id, @@ -120,53 +111,52 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => { return undefined; } - set(isPersistingViewFieldsState, true); - const viewType = type ?? sourceView.type; - const result = await createView({ - input: { - id: id ?? v4(), - name: name ?? sourceView.name, - icon: icon ?? sourceView.icon, - key: null, - kanbanAggregateOperation: shouldCopyFiltersAndSortsAndAggregate - ? sourceView.kanbanAggregateOperation - : undefined, - kanbanAggregateOperationFieldMetadataId: - shouldCopyFiltersAndSortsAndAggregate - ? sourceView.kanbanAggregateOperationFieldMetadataId + const result = await createView( + { + input: { + id: id ?? v4(), + name: name ?? sourceView.name, + icon: icon ?? sourceView.icon, + key: null, + kanbanAggregateOperation: shouldCopyFiltersAndSortsAndAggregate + ? sourceView.kanbanAggregateOperation : undefined, - mainGroupByFieldMetadataId: shouldCopyFiltersAndSortsAndAggregate - ? sourceView.mainGroupByFieldMetadataId - : mainGroupByFieldMetadataId, - type: convertViewTypeToCore(viewType), - objectMetadataId: sourceView.objectMetadataId, - openRecordIn: convertViewOpenRecordInToCore( - sourceView.openRecordIn, - ), - anyFieldFilterValue: anyFieldFilterValue, - calendarLayout: - viewType === ViewType.Calendar - ? ViewCalendarLayout.MONTH - : undefined, - calendarFieldMetadataId: - viewType === ViewType.Calendar - ? calendarFieldMetadataId - : undefined, - visibility, + kanbanAggregateOperationFieldMetadataId: + shouldCopyFiltersAndSortsAndAggregate + ? sourceView.kanbanAggregateOperationFieldMetadataId + : undefined, + mainGroupByFieldMetadataId: shouldCopyFiltersAndSortsAndAggregate + ? sourceView.mainGroupByFieldMetadataId + : mainGroupByFieldMetadataId, + type: convertViewTypeToCore(viewType), + objectMetadataId: sourceView.objectMetadataId, + openRecordIn: convertViewOpenRecordInToCore( + sourceView.openRecordIn, + ), + anyFieldFilterValue: anyFieldFilterValue, + calendarLayout: + viewType === ViewType.Calendar + ? ViewCalendarLayout.MONTH + : undefined, + calendarFieldMetadataId: + viewType === ViewType.Calendar + ? calendarFieldMetadataId + : undefined, + visibility, + }, }, - }); + objectMetadataItem.id, + ); if (result.status === 'failed') { - set(isPersistingViewFieldsState, false); return undefined; } const newViewId = result.response.data?.createCoreView.id; if (isUndefinedOrNull(newViewId)) { - set(isPersistingViewFieldsState, false); throw new Error('Failed to create view'); } @@ -181,60 +171,9 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => { }); if (fieldResult.status === 'failed') { - set(isPersistingViewFieldsState, false); return undefined; } - if (type === ViewType.Kanban) { - if ( - !isDefined(mainGroupByFieldMetadataId) || - mainGroupByFieldMetadataId === '' - ) { - throw new Error('Kanban view must have a kanban field'); - } - - const viewGroupsToCreate = - objectMetadataItem.fields - ?.find((field) => field.id === mainGroupByFieldMetadataId) - ?.options?.map( - (option, index) => - ({ - id: v4(), - __typename: 'ViewGroup', - fieldValue: option.value, - isVisible: true, - position: index, - }) satisfies ViewGroup, - ) ?? []; - - if ( - objectMetadataItem.fields.find( - (field) => field.id === mainGroupByFieldMetadataId, - )?.isNullable === true - ) { - viewGroupsToCreate.push({ - __typename: 'ViewGroup', - id: v4(), - fieldValue: '', - position: viewGroupsToCreate.length, - isVisible: true, - } satisfies ViewGroup); - } - - triggerViewGroupOptimisticEffect({ - createdViewGroups: viewGroupsToCreate.map( - ({ __typename, ...viewGroup }) => - ({ - ...viewGroup, - viewId: newViewId, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - deletedAt: null, - }) as Omit, - ), - }); - } - if (shouldCopyFiltersAndSortsAndAggregate === true) { const viewFilterGroupsToCopy = currentRecordFilterGroups.map( (recordFilterGroup) => @@ -285,7 +224,6 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => { const filterResult = await createViewFilters(createViewFilterInputs); if (filterResult.status === 'failed') { - set(isPersistingViewFieldsState, false); return undefined; } @@ -294,24 +232,21 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => { await refreshCoreViewsByObjectMetadataId(objectMetadataItem.id); - set(isPersistingViewFieldsState, false); return newViewId; }, [ currentViewIdCallbackState, - createViewFields, createView, anyFieldFilterValue, - objectMetadataItem.fields, - objectMetadataItem.id, + objectMetadataItem, + createViewFields, + refreshCoreViewsByObjectMetadataId, currentRecordFilterGroups, currentRecordFilters, currentRecordSorts, createViewFilterGroups, createViewFilters, createViewSorts, - refreshCoreViewsByObjectMetadataId, - triggerViewGroupOptimisticEffect, ], ); diff --git a/packages/twenty-front/src/modules/views/hooks/useSaveCurrentViewFields.ts b/packages/twenty-front/src/modules/views/hooks/useSaveCurrentViewFields.ts index 521685e23e..261b7c3be6 100644 --- a/packages/twenty-front/src/modules/views/hooks/useSaveCurrentViewFields.ts +++ b/packages/twenty-front/src/modules/views/hooks/useSaveCurrentViewFields.ts @@ -5,7 +5,6 @@ import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component- import { usePersistViewField } from '@/views/hooks/internal/usePersistViewField'; import { useCanPersistViewChanges } from '@/views/hooks/useCanPersistViewChanges'; import { useGetViewFromPrefetchState } from '@/views/hooks/useGetViewFromPrefetchState'; -import { isPersistingViewFieldsState } from '@/views/states/isPersistingViewFieldsState'; import { type ViewField } from '@/views/types/ViewField'; import { type CreateViewFieldInput, @@ -25,7 +24,7 @@ export const useSaveCurrentViewFields = () => { ); const saveViewFields = useRecoilCallback( - ({ set, snapshot }) => + ({ snapshot }) => async (viewFieldsToSave: Omit[]) => { if (!canPersistChanges) { return; @@ -39,8 +38,6 @@ export const useSaveCurrentViewFields = () => { return; } - set(isPersistingViewFieldsState, true); - const view = getViewFromPrefetchState(currentViewId); if (isUndefinedOrNull(view)) { @@ -127,8 +124,6 @@ export const useSaveCurrentViewFields = () => { createViewFields({ inputs: viewFieldsToCreate }), updateViewFields(viewFieldsToUpdate), ]); - - set(isPersistingViewFieldsState, false); }, [ canPersistChanges, diff --git a/packages/twenty-front/src/modules/views/hooks/useSaveCurrentViewGroups.ts b/packages/twenty-front/src/modules/views/hooks/useSaveCurrentViewGroups.ts index fbba95e40a..c7ee2ef48d 100644 --- a/packages/twenty-front/src/modules/views/hooks/useSaveCurrentViewGroups.ts +++ b/packages/twenty-front/src/modules/views/hooks/useSaveCurrentViewGroups.ts @@ -12,7 +12,7 @@ import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; export const useSaveCurrentViewGroups = () => { const { canPersistChanges } = useCanPersistViewChanges(); - const { createViewGroups, updateViewGroups } = usePersistViewGroupRecords(); + const { updateViewGroups } = usePersistViewGroupRecords(); const { getViewFromPrefetchState } = useGetViewFromPrefetchState(); @@ -150,33 +150,14 @@ export const useSaveCurrentViewGroups = () => { }) .filter(isDefined); - const viewGroupsToCreate = viewGroupsToSave.filter( - (viewFieldToSave) => - !currentViewGroups.some( - (currentViewGroup) => - currentViewGroup.fieldValue === viewFieldToSave.fieldValue, - ), - ); - if (!isDefined(view.mainGroupByFieldMetadataId)) { throw new Error('mainGroupByFieldMetadataId is required'); } - await Promise.all([ - createViewGroups({ - createCoreViewGroupInputs: { - inputs: viewGroupsToCreate.map((viewGroupToCreate) => ({ - ...viewGroupToCreate, - viewId: view.id, - })), - }, - }), - updateViewGroups(viewGroupsToUpdate), - ]); + await updateViewGroups(viewGroupsToUpdate); }, [ canPersistChanges, - createViewGroups, currentViewIdCallbackState, getViewFromPrefetchState, updateViewGroups, diff --git a/packages/twenty-front/src/modules/views/hooks/useUpdateCurrentView.ts b/packages/twenty-front/src/modules/views/hooks/useUpdateCurrentView.ts index 3f9fe9ead6..2384114ff5 100644 --- a/packages/twenty-front/src/modules/views/hooks/useUpdateCurrentView.ts +++ b/packages/twenty-front/src/modules/views/hooks/useUpdateCurrentView.ts @@ -1,13 +1,21 @@ -import { useRecoilCallback } from 'recoil'; +import { useRecoilCallback, useSetRecoilState } from 'recoil'; +import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow'; import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState'; +import { useLoadRecordIndexStates } from '@/object-record/record-index/hooks/useLoadRecordIndexStates'; +import { recordIndexViewTypeState } from '@/object-record/record-index/states/recordIndexViewTypeState'; import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; import { useCanPersistViewChanges } from '@/views/hooks/useCanPersistViewChanges'; import { useRefreshCoreViewsByObjectMetadataId } from '@/views/hooks/useRefreshCoreViewsByObjectMetadataId'; import { coreViewFromViewIdFamilySelector } from '@/views/states/selectors/coreViewFromViewIdFamilySelector'; import { type GraphQLView } from '@/views/types/GraphQLView'; +import { type View } from '@/views/types/View'; +import { type ViewGroup } from '@/views/types/ViewGroup'; +import { type ViewType } from '@/views/types/ViewType'; import { convertUpdateViewInputToCore } from '@/views/utils/convertUpdateViewInputToCore'; +import { useMemo } from 'react'; import { isDefined } from 'twenty-shared/utils'; +import { v4 } from 'uuid'; import { useUpdateCoreViewMutation } from '~/generated-metadata/graphql'; export const useUpdateCurrentView = () => { @@ -15,15 +23,70 @@ export const useUpdateCurrentView = () => { const currentViewIdCallbackState = useRecoilComponentCallbackState( contextStoreCurrentViewIdComponentState, ); + const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow(); + const { loadRecordIndexStates } = useLoadRecordIndexStates(); + const setRecordIndexViewType = useSetRecoilState(recordIndexViewTypeState); + const [updateOneCoreView] = useUpdateCoreViewMutation(); const { refreshCoreViewsByObjectMetadataId } = useRefreshCoreViewsByObjectMetadataId(); - const [updateOneCoreView] = useUpdateCoreViewMutation(); + const getViewGroupsToCreateAtViewUpdate = useMemo(() => { + return ({ + existingView, + newMainGroupByFieldMetadataId, + }: { + existingView: View; + newMainGroupByFieldMetadataId?: string | null; + }) => { + if (newMainGroupByFieldMetadataId === undefined) { + return {}; + } + + let viewGroupsToCreate: ViewGroup[] = []; + + if ( + newMainGroupByFieldMetadataId !== + existingView.mainGroupByFieldMetadataId + ) { + if (newMainGroupByFieldMetadataId !== null) { + viewGroupsToCreate = + objectMetadataItem.fields + ?.find((field) => field.id === newMainGroupByFieldMetadataId) + ?.options?.map( + (option, index) => + ({ + id: v4(), + __typename: 'ViewGroup', + fieldValue: option.value, + isVisible: true, + position: index, + }) satisfies ViewGroup, + ) ?? []; + + if ( + objectMetadataItem.fields.find( + (field) => field.id === newMainGroupByFieldMetadataId, + )?.isNullable === true + ) { + viewGroupsToCreate.push({ + __typename: 'ViewGroup', + id: v4(), + fieldValue: '', + position: viewGroupsToCreate.length, + isVisible: true, + } satisfies ViewGroup); + } + } + } + + return { viewGroupsToCreate }; + }; + }, [objectMetadataItem.fields]); const updateCurrentView = useRecoilCallback( ({ snapshot }) => - async (view: Partial) => { + async (view: Partial & { type?: ViewType }) => { if (!canPersistChanges) { return; } @@ -53,6 +116,31 @@ export const useUpdateCurrentView = () => { input, }, }); + + if ( + input.mainGroupByFieldMetadataId !== undefined && + currentView.mainGroupByFieldMetadataId !== + input.mainGroupByFieldMetadataId + ) { + const { viewGroupsToCreate } = getViewGroupsToCreateAtViewUpdate({ + existingView: currentView, + newMainGroupByFieldMetadataId: input.mainGroupByFieldMetadataId, + }); + + loadRecordIndexStates( + { + ...currentView, + mainGroupByFieldMetadataId: input.mainGroupByFieldMetadataId, + viewGroups: viewGroupsToCreate ?? [], + }, + objectMetadataItem, + ); + } + + if (isDefined(view.type)) { + setRecordIndexViewType(view.type); + } + await refreshCoreViewsByObjectMetadataId( currentView.objectMetadataId, ); @@ -61,7 +149,11 @@ export const useUpdateCurrentView = () => { [ canPersistChanges, currentViewIdCallbackState, + getViewGroupsToCreateAtViewUpdate, + loadRecordIndexStates, + objectMetadataItem, refreshCoreViewsByObjectMetadataId, + setRecordIndexViewType, updateOneCoreView, ], ); diff --git a/packages/twenty-front/src/modules/views/hooks/useViewsSideEffectsOnViewGroups.ts b/packages/twenty-front/src/modules/views/hooks/useViewsSideEffectsOnViewGroups.ts new file mode 100644 index 0000000000..db0ae3b00b --- /dev/null +++ b/packages/twenty-front/src/modules/views/hooks/useViewsSideEffectsOnViewGroups.ts @@ -0,0 +1,87 @@ +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { useTriggerViewGroupOptimisticEffect } from '@/views/optimistic-effects/hooks/useTriggerViewGroupOptimisticEffect'; +import { type ViewGroup } from '@/views/types/ViewGroup'; +import { useRecoilValue } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; +import { v4 } from 'uuid'; +import { type CoreViewGroup } from '~/generated/graphql'; + +const useViewsSideEffectsOnViewGroups = () => { + const { triggerViewGroupOptimisticEffect } = + useTriggerViewGroupOptimisticEffect(); + + const objectMetadataItems = useRecoilValue(objectMetadataItemsState); + + const triggerViewGroupOptimisticEffectAtViewCreation = ({ + newViewId, + mainGroupByFieldMetadataId, + objectMetadataItemId, + }: { + newViewId: string; + mainGroupByFieldMetadataId?: string | null; + objectMetadataItemId: string; + }) => { + if (!isDefined(mainGroupByFieldMetadataId)) { + return {}; + } + + const objectMetadataItem = objectMetadataItems.find( + (objectMetadataItem) => objectMetadataItem.id === objectMetadataItemId, + ); + + if (!isDefined(objectMetadataItem)) { + throw new Error('Object metadata item not found'); + } + + let viewGroupsToCreate: ViewGroup[] = []; + + viewGroupsToCreate = + objectMetadataItem.fields + ?.find((field) => field.id === mainGroupByFieldMetadataId) + ?.options?.map( + (option, index) => + ({ + id: v4(), + __typename: 'ViewGroup', + fieldValue: option.value, + isVisible: true, + position: index, + }) satisfies ViewGroup, + ) ?? []; + + if ( + objectMetadataItem.fields.find( + (field) => field.id === mainGroupByFieldMetadataId, + )?.isNullable === true + ) { + viewGroupsToCreate.push({ + __typename: 'ViewGroup', + id: v4(), + fieldValue: '', + position: viewGroupsToCreate.length, + isVisible: true, + } satisfies ViewGroup); + } + + triggerViewGroupOptimisticEffect({ + createdViewGroups: viewGroupsToCreate.map( + ({ __typename, ...viewGroup }) => + ({ + ...viewGroup, + viewId: newViewId, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + deletedAt: null, + }) as Omit, + ), + }); + + return { viewGroupsToCreate }; + }; + + return { + triggerViewGroupOptimisticEffectAtViewCreation, + }; +}; + +export { useViewsSideEffectsOnViewGroups }; diff --git a/packages/twenty-front/src/modules/views/states/isPersistingViewFieldsState.ts b/packages/twenty-front/src/modules/views/states/isPersistingViewFieldsState.ts deleted file mode 100644 index 5d411b6675..0000000000 --- a/packages/twenty-front/src/modules/views/states/isPersistingViewFieldsState.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createState } from 'twenty-ui/utilities'; -export const isPersistingViewFieldsState = createState({ - key: 'isPersistingViewFieldsState', - defaultValue: false, -}); diff --git a/packages/twenty-front/src/modules/views/utils/convertUpdateViewInputToCore.ts b/packages/twenty-front/src/modules/views/utils/convertUpdateViewInputToCore.ts index 87dc96b497..4bac580f7f 100644 --- a/packages/twenty-front/src/modules/views/utils/convertUpdateViewInputToCore.ts +++ b/packages/twenty-front/src/modules/views/utils/convertUpdateViewInputToCore.ts @@ -46,5 +46,8 @@ export const convertUpdateViewInputToCore = ( calendarFieldMetadataId: view.calendarFieldMetadataId, }), ...(isDefined(view.visibility) && { visibility: view.visibility }), + ...(view.mainGroupByFieldMetadataId !== undefined && { + mainGroupByFieldMetadataId: view.mainGroupByFieldMetadataId, + }), }; };