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:
+167
@@ -0,0 +1,167 @@
|
||||
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 };
|
||||
};
|
||||
Reference in New Issue
Block a user