ab881350b2
### 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
168 lines
6.5 KiB
TypeScript
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 };
|
|
};
|