Files
twenty/packages/twenty-front/src/modules/layout-customization/hooks/useSaveLayoutCustomization.ts
T
nitin ab881350b2 refactor: unify layout customization mode (record pages + navigation) (#18640)
### What

Unifies record page layout editing and navigation menu editing into a
single global "layout customization" session. Dashboard editing stays
separate.

  ### How it works

  **Two edit mode systems, one context-based read:**
- `isLayoutCustomizationModeEnabledState` -- global atom for record
pages + navigation
- `isDashboardInEditModeComponentState` -- dashboard-only, independent
per-component atom
- `PageLayoutEditModeProvider` -- context that dispatches to
`RecordPageLayoutEditModeProvider` (reads global atom) or
`DashboardPageLayoutEditModeProvider` (reads component atom), one
component per file

  **Session registry + independent atoms:**
- `activeCustomizationPageLayoutIdsState` -- accumulates page layout IDs
as user navigates during customization (`string[]`)
- Save/cancel iterate the ID list and read each layout's draft/persisted
atoms independently
- Follows the same pattern as `settingsRoleIdsState` +
`settingsDraftRoleFamilyState`

  **Unified UI:**
- `LayoutCustomizationBar` replaces the old `NavigationMenuEditModeBar`
- Enter once -- edit record layouts + navigation -- save/cancel
everything together
- `useSaveLayoutCustomization` orchestrates sequential save: navigation
draft -- page layouts -- field widget groups
- Error snackbar on partial save failure (with TODO for future atomic
server mutation)

  **Draft protection during customization:**
- `PageLayoutRelationWidgetsSyncEffect` guarded -- only updates
persisted state from server, skips draft/currentLayouts while
customization is active
- `useExecuteTasksOnAnyLocationChange` skips draft reset when
customization mode is enabled
  - Command execution blocked during layout customization

  ### Cleanup
- Deleted `NavigationMenuEditModeBar`,
`isNavigationMenuInEditModeState`,
`isPageLayoutInEditModeComponentState`,
`useIsGlobalLayoutCustomizationActive`
- `DraftPageLayout` type changed from `Omit` to `Pick` (explicit fields)
- Removed save/cancel from `DefaultRecordCommandMenuItemsConfig` (bar
handles it now)
  - Extracted `useSaveFieldsWidgetGroups` from save orchestration
- Split `PageLayoutEditModeProvider` into 3 separate files (one
component per file, Twenty convention)

  ### Known issues
- **Stale deleted widget after save (pre-existing on `main`)**: Delete
widget -- save -- exit customization -- Apollo cache stale -- sync
effect overwrites Jotai from stale data -- widget reappears until
refresh. Separate PR needed, likely tied to the planned server-side
`saveLayoutCustomization` atomic endpoint.

  ### Open questions
- **Module location**: Layout customization hooks/states live in `/app`
-- should they move to their own `modules/layout-customization/`?
- **Atomic server mutation**: All save mutations are on metadata schema
(`createNavigationMenuItem`, `deleteNavigationMenuItem`,
`updateNavigationMenuItem`, `updatePageLayoutWithTabsAndWidgets`,
`upsertFieldsWidget`). A single `saveLayoutCustomization` endpoint could
make saves truly atomic.


https://github.com/user-attachments/assets/036ef542-97f3-485b-a68f-3726002c81fb
2026-03-17 16:24:09 +01:00

168 lines
6.5 KiB
TypeScript

import { useExitLayoutCustomizationMode } from '@/layout-customization/hooks/useExitLayoutCustomizationMode';
import { activeCustomizationPageLayoutIdsState } from '@/layout-customization/states/activeCustomizationPageLayoutIdsState';
import { useSaveNavigationMenuItemsDraft } from '@/navigation-menu-item/edit/hooks/useSaveNavigationMenuItemsDraft';
import { navigationMenuItemsDraftState } from '@/navigation-menu-item/common/states/navigationMenuItemsDraftState';
import { navigationMenuItemsSelector } from '@/navigation-menu-item/common/states/navigationMenuItemsSelector';
import { filterWorkspaceNavigationMenuItems } from '@/navigation-menu-item/common/utils/filterWorkspaceNavigationMenuItems';
import { useSaveFieldsWidgetGroups } from '@/page-layout/hooks/useSaveFieldsWidgetGroups';
import { useUpdatePageLayoutWithTabsAndWidgets } from '@/page-layout/hooks/useUpdatePageLayoutWithTabsAndWidgets';
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutPersistedComponentState } from '@/page-layout/states/pageLayoutPersistedComponentState';
import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout';
import { type PageLayout } from '@/page-layout/types/PageLayout';
import { convertPageLayoutDraftToUpdateInput } from '@/page-layout/utils/convertPageLayoutDraftToUpdateInput';
import { convertPageLayoutToTabLayouts } from '@/page-layout/utils/convertPageLayoutToTabLayouts';
import { reInjectDynamicRelationWidgetsFromDraft } from '@/page-layout/utils/reInjectDynamicRelationWidgetsFromDraft';
import { transformPageLayout } from '@/page-layout/utils/transformPageLayout';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useLingui } from '@lingui/react/macro';
import { useStore } from 'jotai';
import { useCallback, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { PageLayoutType } from '~/generated-metadata/graphql';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { logError } from '~/utils/logError';
export const useSaveLayoutCustomization = () => {
const [isSaving, setIsSaving] = useState(false);
const store = useStore();
const { t } = useLingui();
const { saveDraft } = useSaveNavigationMenuItemsDraft();
const { enqueueErrorSnackBar } = useSnackBar();
const { updatePageLayoutWithTabsAndWidgets } =
useUpdatePageLayoutWithTabsAndWidgets();
const { exitLayoutCustomizationMode } = useExitLayoutCustomizationMode();
const { saveFieldsWidgetGroups } = useSaveFieldsWidgetGroups();
const save = useCallback(async () => {
setIsSaving(true);
try {
const navigationDraft = store.get(navigationMenuItemsDraftState.atom);
const prefetchItems = store.get(navigationMenuItemsSelector.atom);
const workspaceItems = filterWorkspaceNavigationMenuItems(prefetchItems);
const isNavigationDirty =
isDefined(navigationDraft) &&
!isDeeplyEqual(navigationDraft, workspaceItems);
// TODO: consider a single server mutation (e.g. saveLayoutCustomization)
// that saves navigation + page layouts + field widgets in one transaction.
// Currently, partial failure leaves mixed state — navigation may commit
// while page layouts fail.
if (isNavigationDirty) {
await saveDraft();
}
const activePageLayoutIds = store.get(
activeCustomizationPageLayoutIdsState.atom,
);
let hasAnyFailure = false;
for (const pageLayoutId of activePageLayoutIds) {
const draft = store.get(
pageLayoutDraftComponentState.atomFamily({
instanceId: pageLayoutId,
}),
);
const persisted = store.get(
pageLayoutPersistedComponentState.atomFamily({
instanceId: pageLayoutId,
}),
);
if (!isDefined(draft) || !isDefined(persisted)) {
continue;
}
const persistedAsDraft: DraftPageLayout = {
id: persisted.id,
name: persisted.name,
type: persisted.type,
objectMetadataId: persisted.objectMetadataId,
tabs: persisted.tabs,
defaultTabToFocusOnMobileAndSidePanelId:
persisted.defaultTabToFocusOnMobileAndSidePanelId,
};
const isPageLayoutStructureDirty = !isDeeplyEqual(
draft,
persistedAsDraft,
);
if (isPageLayoutStructureDirty) {
const updateInput = convertPageLayoutDraftToUpdateInput(draft);
const result = await updatePageLayoutWithTabsAndWidgets(
pageLayoutId,
updateInput,
);
if (result.status === 'successful') {
const updatedPageLayout =
result.response.data?.updatePageLayoutWithTabsAndWidgets;
if (isDefined(updatedPageLayout)) {
const persistedLayout: PageLayout =
transformPageLayout(updatedPageLayout);
const pageLayoutToPersist =
persistedLayout.type === PageLayoutType.RECORD_PAGE
? reInjectDynamicRelationWidgetsFromDraft(
persistedLayout,
draft,
)
: persistedLayout;
store.set(
pageLayoutPersistedComponentState.atomFamily({
instanceId: pageLayoutId,
}),
pageLayoutToPersist,
);
store.set(
pageLayoutCurrentLayoutsComponentState.atomFamily({
instanceId: pageLayoutId,
}),
convertPageLayoutToTabLayouts(pageLayoutToPersist),
);
}
} else {
// goes away with a single server mutation (see TODO above)
hasAnyFailure = true;
continue;
}
}
await saveFieldsWidgetGroups(pageLayoutId);
}
if (hasAnyFailure) {
enqueueErrorSnackBar({
message: t`Some layout changes could not be saved`,
});
return;
}
exitLayoutCustomizationMode();
} catch (error) {
logError(error);
enqueueErrorSnackBar({
message: t`Failed to save layout customization`,
});
} finally {
setIsSaving(false);
}
}, [
saveDraft,
updatePageLayoutWithTabsAndWidgets,
saveFieldsWidgetGroups,
exitLayoutCustomizationMode,
enqueueErrorSnackBar,
store,
t,
]);
return { save, isSaving };
};