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
This commit is contained in:
nitin
2026-03-17 20:54:09 +05:30
committed by GitHub
parent f38d72a4c2
commit ab881350b2
102 changed files with 2176 additions and 738 deletions
@@ -8,11 +8,12 @@ import { pageLayoutPersistedComponentState } from '@/page-layout/states/pageLayo
import { type PageLayout } from '@/page-layout/types/PageLayout';
import { convertPageLayoutToTabLayouts } from '@/page-layout/utils/convertPageLayoutToTabLayouts';
import { isPageLayoutEmpty } from '@/page-layout/utils/isPageLayoutEmpty';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useStore } from 'jotai';
import { useCallback, useEffect } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { PageLayoutType } from '~/generated-metadata/graphql';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
type PageLayoutInitializationQueryEffectProps = {
@@ -45,6 +46,8 @@ export const PageLayoutInitializationQueryEffect = ({
const initializePageLayout = useCallback(
(layout: PageLayout) => {
const isRecordPageLayout = layout.type === PageLayoutType.RECORD_PAGE;
const currentPersisted = store.get(
pageLayoutPersistedComponentCallbackState,
);
@@ -59,12 +62,17 @@ export const PageLayoutInitializationQueryEffect = ({
type: layout.type,
objectMetadataId: layout.objectMetadataId,
tabs: layout.tabs,
defaultTabToFocusOnMobileAndSidePanelId:
layout.defaultTabToFocusOnMobileAndSidePanelId,
});
const tabLayouts = convertPageLayoutToTabLayouts(layout);
store.set(pageLayoutCurrentLayoutsComponentCallbackState, tabLayouts);
setIsPageLayoutInEditMode(isPageLayoutEmpty(layout));
if (!isRecordPageLayout) {
const shouldEnterDashboardEditMode = isPageLayoutEmpty(layout);
setIsPageLayoutInEditMode(shouldEnterDashboardEditMode);
}
},
[
pageLayoutCurrentLayoutsComponentCallbackState,