From b05d2406eca8067a0c13c391b871b2cb677c824b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Wed, 5 Aug 2026 10:21:28 +0200 Subject: [PATCH] =?UTF-8?q?fix(twenty-front):=20stop=20field=20widget=20la?= =?UTF-8?q?yout=20dropdown=20from=20crashing=20th=E2=80=A6=20(#23784)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …e record page RecordTableWidgetViewDraftInitEffect read the page layout edit mode and the page layout instance id from context, but the widget settings side panel renders outside the page layout tree. Opening the Layout picker on a relation field widget displayed as a table threw "PageLayoutEditModeContext Context not found" and took down the whole record page. Both values are now passed in by the caller. Review in cubic --- .../components/PageLayoutRenderer.tsx | 2 + .../PageLayoutSingleTabRenderer.tsx | 2 + .../hooks/useSaveRecordTableWidgetViews.ts | 17 +- .../RecordTableWidgetRendererContent.tsx | 57 +++--- .../RecordTableWidgetViewDraftInitEffect.tsx | 23 --- ...leWidgetViewDraftsInitializationEffect.tsx | 7 + ...ializeRecordTableWidgetViewDrafts.test.tsx | 165 ++++++++++++++++++ ...useInitializeRecordTableWidgetViewDraft.ts | 60 ------- ...seInitializeRecordTableWidgetViewDrafts.ts | 73 ++++++++ ...ecordTableWidgetViewDraftSnapshots.test.ts | 85 +++++++++ ...singRecordTableWidgetViewDraftSnapshots.ts | 51 ++++++ .../FieldWidgetLayoutDropdownContent.tsx | 11 -- 12 files changed, 424 insertions(+), 129 deletions(-) delete mode 100644 packages/twenty-front/src/modules/page-layout/widgets/record-table/components/RecordTableWidgetViewDraftInitEffect.tsx create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/record-table/components/RecordTableWidgetViewDraftsInitializationEffect.tsx create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/record-table/hooks/__tests__/useInitializeRecordTableWidgetViewDrafts.test.tsx delete mode 100644 packages/twenty-front/src/modules/page-layout/widgets/record-table/hooks/useInitializeRecordTableWidgetViewDraft.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/record-table/hooks/useInitializeRecordTableWidgetViewDrafts.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/__tests__/buildMissingRecordTableWidgetViewDraftSnapshots.test.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/buildMissingRecordTableWidgetViewDraftSnapshots.ts diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutRenderer.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutRenderer.tsx index f55f8f2eab..a1c04b1b48 100644 --- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutRenderer.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutRenderer.tsx @@ -3,6 +3,7 @@ import { PageLayoutInitializationQueryEffect } from '@/page-layout/components/Pa import { PageLayoutRecordPageCustomizationSessionRegistrationEffect } from '@/page-layout/components/PageLayoutRecordPageCustomizationSessionRegistrationEffect'; import { PageLayoutRendererContent } from '@/page-layout/components/PageLayoutRendererContent'; import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; +import { RecordTableWidgetViewDraftsInitializationEffect } from '@/page-layout/widgets/record-table/components/RecordTableWidgetViewDraftsInitializationEffect'; import { getTabListInstanceIdFromPageLayoutAndRecord } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutAndRecord'; import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext'; import { TabListComponentInstanceContext } from '@/ui/layout/tab-list/states/contexts/TabListComponentInstanceContext'; @@ -41,6 +42,7 @@ export const PageLayoutRenderer = ({ > + diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutSingleTabRenderer.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutSingleTabRenderer.tsx index 7881357434..629b621755 100644 --- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutSingleTabRenderer.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutSingleTabRenderer.tsx @@ -10,6 +10,7 @@ import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutIn import { usePageLayoutTabWithVisibleWidgetsOrThrow } from '@/page-layout/hooks/usePageLayoutTabWithVisibleWidgetsOrThrow'; import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; import { pageLayoutIsInitializedComponentState } from '@/page-layout/states/pageLayoutIsInitializedComponentState'; +import { RecordTableWidgetViewDraftsInitializationEffect } from '@/page-layout/widgets/record-table/components/RecordTableWidgetViewDraftsInitializationEffect'; import { getTabLayoutMode } from '@/page-layout/utils/getTabLayoutMode'; import { getTabListInstanceIdFromPageLayoutAndRecord } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutAndRecord'; import { getTabPresentation } from '@/page-layout/utils/getTabPresentation'; @@ -112,6 +113,7 @@ export const PageLayoutSingleTabRenderer = ({ > + diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useSaveRecordTableWidgetViews.ts b/packages/twenty-front/src/modules/page-layout/hooks/useSaveRecordTableWidgetViews.ts index cd9014d161..dbeaa6f909 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/useSaveRecordTableWidgetViews.ts +++ b/packages/twenty-front/src/modules/page-layout/hooks/useSaveRecordTableWidgetViews.ts @@ -74,6 +74,17 @@ export const useSaveRecordTableWidgetViews = () => { continue; } + // Every view-backed widget gets a draft seeded on entering edit mode; + // only the ones the user actually touched need an upsert. + if ( + isDeeplyEqual( + recordTableWidgetViewPersisted[widget.id], + widgetViewDraft, + ) + ) { + continue; + } + const objectMetadataItem = objectMetadataItems.find( (objectMetadataItem) => objectMetadataItem.id === widgetViewDraft.view.objectMetadataId, @@ -93,10 +104,10 @@ export const useSaveRecordTableWidgetViews = () => { }; const persistedView = recordTableWidgetViewPersisted[widget.id]?.view; - const draftView = widgetViewDraft.view; - const draftViewSettings = - buildUpsertViewWidgetViewSettingsInput(draftView); + const draftViewSettings = buildUpsertViewWidgetViewSettingsInput( + widgetViewDraft.view, + ); const hasViewSettingsChanges = !isDefined(persistedView) || diff --git a/packages/twenty-front/src/modules/page-layout/widgets/record-table/components/RecordTableWidgetRendererContent.tsx b/packages/twenty-front/src/modules/page-layout/widgets/record-table/components/RecordTableWidgetRendererContent.tsx index c69b75b29a..d5181318fe 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/record-table/components/RecordTableWidgetRendererContent.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/record-table/components/RecordTableWidgetRendererContent.tsx @@ -6,7 +6,6 @@ import { RecordTableWidget } from '@/object-record/record-table-widget/component import { RecordTableWidgetProvider } from '@/object-record/record-table-widget/components/RecordTableWidgetProvider'; import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutInEditMode'; import { recordTableWidgetViewDraftByWidgetIdComponentFamilySelector } from '@/page-layout/states/selectors/recordTableWidgetViewDraftByWidgetIdComponentFamilySelector'; -import { RecordTableWidgetViewDraftInitEffect } from '@/page-layout/widgets/record-table/components/RecordTableWidgetViewDraftInitEffect'; import { constructViewFromRecordTableWidgetViewSnapshot } from '@/page-layout/widgets/record-table/utils/constructViewFromRecordTableWidgetViewSnapshot'; import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorValue'; import { useViewById } from '@/views/hooks/useViewById'; @@ -83,36 +82,30 @@ export const RecordTableWidgetRendererContent = ({ const calendarIsReadOnly = !canEditCalendar; return ( - <> - - - {isKanbanLayout ? ( - - ) : isCalendarLayout ? ( - - ) : ( - - )} - - + + {isKanbanLayout ? ( + + ) : isCalendarLayout ? ( + + ) : ( + + )} + ); }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/record-table/components/RecordTableWidgetViewDraftInitEffect.tsx b/packages/twenty-front/src/modules/page-layout/widgets/record-table/components/RecordTableWidgetViewDraftInitEffect.tsx deleted file mode 100644 index 9d32b9d24c..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/record-table/components/RecordTableWidgetViewDraftInitEffect.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutInEditMode'; -import { useInitializeRecordTableWidgetViewDraft } from '@/page-layout/widgets/record-table/hooks/useInitializeRecordTableWidgetViewDraft'; -import { useViewById } from '@/views/hooks/useViewById'; - -type RecordTableWidgetViewDraftInitEffectProps = { - widgetId: string; - viewId: string; -}; - -export const RecordTableWidgetViewDraftInitEffect = ({ - widgetId, - viewId, -}: RecordTableWidgetViewDraftInitEffectProps) => { - const isPageLayoutInEditMode = useIsPageLayoutInEditMode(); - const { view } = useViewById(viewId); - - useInitializeRecordTableWidgetViewDraft({ - widgetId, - view: isPageLayoutInEditMode ? view : undefined, - }); - - return null; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/record-table/components/RecordTableWidgetViewDraftsInitializationEffect.tsx b/packages/twenty-front/src/modules/page-layout/widgets/record-table/components/RecordTableWidgetViewDraftsInitializationEffect.tsx new file mode 100644 index 0000000000..fab9ab326c --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/record-table/components/RecordTableWidgetViewDraftsInitializationEffect.tsx @@ -0,0 +1,7 @@ +import { useInitializeRecordTableWidgetViewDrafts } from '@/page-layout/widgets/record-table/hooks/useInitializeRecordTableWidgetViewDrafts'; + +export const RecordTableWidgetViewDraftsInitializationEffect = () => { + useInitializeRecordTableWidgetViewDrafts(); + + return null; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/record-table/hooks/__tests__/useInitializeRecordTableWidgetViewDrafts.test.tsx b/packages/twenty-front/src/modules/page-layout/widgets/record-table/hooks/__tests__/useInitializeRecordTableWidgetViewDrafts.test.tsx new file mode 100644 index 0000000000..683aedd2f2 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/record-table/hooks/__tests__/useInitializeRecordTableWidgetViewDrafts.test.tsx @@ -0,0 +1,165 @@ +import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState'; +import { + PAGE_LAYOUT_TEST_INSTANCE_ID, + PageLayoutTestWrapper, +} from '@/page-layout/hooks/__tests__/PageLayoutTestWrapper'; +import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; +import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState'; +import { recordTableWidgetViewPersistedComponentState } from '@/page-layout/states/recordTableWidgetViewPersistedComponentState'; +import { + makeDraft, + makeTab, + makeWidget, +} from '@/page-layout/testing/pageLayoutDraftFixtures'; +import { useInitializeRecordTableWidgetViewDrafts } from '@/page-layout/widgets/record-table/hooks/useInitializeRecordTableWidgetViewDrafts'; +import { buildRecordTableWidgetViewSnapshot } from '@/page-layout/widgets/record-table/utils/buildRecordTableWidgetViewSnapshot'; +import { constructViewFromRecordTableWidgetViewSnapshot } from '@/page-layout/widgets/record-table/utils/constructViewFromRecordTableWidgetViewSnapshot'; +import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; +import { renderHook } from '@testing-library/react'; +import { createStore } from 'jotai'; +import { type ReactNode } from 'react'; +import { PageLayoutType } from '~/generated-metadata/graphql'; +import { getMockObjectMetadataItemOrThrow } from '~/testing/utils/getMockObjectMetadataItemOrThrow'; + +const view = constructViewFromRecordTableWidgetViewSnapshot( + buildRecordTableWidgetViewSnapshot( + getMockObjectMetadataItemOrThrow('company'), + ), +); + +jest.mock('@/views/states/selectors/viewsSelector', () => { + const { atom } = jest.requireActual('jotai'); + return { + viewsSelector: { + type: 'Selector', + key: 'viewsSelectorTestMock', + atom: atom(() => [view]), + }, + }; +}); + +const WIDGET_ID = 'widget-1'; +const OTHER_WIDGET_ID = 'widget-2'; + +const makeViewBackedWidget = (widgetId: string, index: number) => { + const widget = makeWidget(widgetId, index); + + return { + ...widget, + configuration: { ...widget.configuration, viewId: view.id }, + } as PageLayoutWidget; +}; + +const makeViewBackedDraftLayout = (widgetIds: string[] = [WIDGET_ID]) => ({ + ...makeDraft([ + makeTab( + 'tab-1', + widgetIds.map((widgetId, index) => makeViewBackedWidget(widgetId, index)), + ), + ]), + id: PAGE_LAYOUT_TEST_INSTANCE_ID, +}); + +const getWrapper = + (store: ReturnType) => + ({ children }: { children: ReactNode }) => ( + + {children} + + ); + +describe('useInitializeRecordTableWidgetViewDrafts', () => { + it('should seed the draft and persisted snapshots of view-backed widgets in edit mode', () => { + const store = createStore(); + + store.set(isLayoutCustomizationModeEnabledState.atom, true); + store.set( + pageLayoutDraftComponentState.atomFamily({ + instanceId: PAGE_LAYOUT_TEST_INSTANCE_ID, + }), + makeViewBackedDraftLayout(), + ); + + renderHook(() => useInitializeRecordTableWidgetViewDrafts(), { + wrapper: getWrapper(store), + }); + + const draftSnapshots = store.get( + recordTableWidgetViewDraftComponentState.atomFamily({ + instanceId: PAGE_LAYOUT_TEST_INSTANCE_ID, + }), + ); + const persistedSnapshots = store.get( + recordTableWidgetViewPersistedComponentState.atomFamily({ + instanceId: PAGE_LAYOUT_TEST_INSTANCE_ID, + }), + ); + + expect(draftSnapshots[WIDGET_ID]).toBeDefined(); + expect(draftSnapshots[WIDGET_ID].view.id).toBe(view.id); + expect(persistedSnapshots[WIDGET_ID]).toBe(draftSnapshots[WIDGET_ID]); + }); + + it('should not seed anything outside edit mode', () => { + const store = createStore(); + + store.set(isLayoutCustomizationModeEnabledState.atom, false); + store.set( + pageLayoutDraftComponentState.atomFamily({ + instanceId: PAGE_LAYOUT_TEST_INSTANCE_ID, + }), + makeViewBackedDraftLayout(), + ); + + renderHook(() => useInitializeRecordTableWidgetViewDrafts(), { + wrapper: getWrapper(store), + }); + + const draftSnapshots = store.get( + recordTableWidgetViewDraftComponentState.atomFamily({ + instanceId: PAGE_LAYOUT_TEST_INSTANCE_ID, + }), + ); + + expect(draftSnapshots[WIDGET_ID]).toBeUndefined(); + }); + + it('should keep an existing snapshot over the freshly built one while seeding the missing ones', () => { + const store = createStore(); + + const existingSnapshot = buildRecordTableWidgetViewSnapshot( + getMockObjectMetadataItemOrThrow('company'), + ); + + store.set(isLayoutCustomizationModeEnabledState.atom, true); + store.set( + pageLayoutDraftComponentState.atomFamily({ + instanceId: PAGE_LAYOUT_TEST_INSTANCE_ID, + }), + makeViewBackedDraftLayout([WIDGET_ID, OTHER_WIDGET_ID]), + ); + store.set( + recordTableWidgetViewDraftComponentState.atomFamily({ + instanceId: PAGE_LAYOUT_TEST_INSTANCE_ID, + }), + { [WIDGET_ID]: existingSnapshot }, + ); + + renderHook(() => useInitializeRecordTableWidgetViewDrafts(), { + wrapper: getWrapper(store), + }); + + const draftSnapshots = store.get( + recordTableWidgetViewDraftComponentState.atomFamily({ + instanceId: PAGE_LAYOUT_TEST_INSTANCE_ID, + }), + ); + + expect(draftSnapshots[OTHER_WIDGET_ID]).toBeDefined(); + expect(draftSnapshots[WIDGET_ID]).toBe(existingSnapshot); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/record-table/hooks/useInitializeRecordTableWidgetViewDraft.ts b/packages/twenty-front/src/modules/page-layout/widgets/record-table/hooks/useInitializeRecordTableWidgetViewDraft.ts deleted file mode 100644 index 2f3f77dbc2..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/record-table/hooks/useInitializeRecordTableWidgetViewDraft.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState'; -import { recordTableWidgetViewPersistedComponentState } from '@/page-layout/states/recordTableWidgetViewPersistedComponentState'; -import { buildRecordTableWidgetViewSnapshotFromView } from '@/page-layout/widgets/record-table/utils/buildRecordTableWidgetViewSnapshotFromView'; -import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; -import { type View } from '@/views/types/View'; -import { useStore } from 'jotai'; -import { useCallback, useEffect } from 'react'; - -type UseInitializeRecordTableWidgetViewDraftParams = { - widgetId: string; - view: View | undefined; -}; - -export const useInitializeRecordTableWidgetViewDraft = ({ - widgetId, - view, -}: UseInitializeRecordTableWidgetViewDraftParams) => { - const recordTableWidgetViewDraftState = useAtomComponentStateCallbackState( - recordTableWidgetViewDraftComponentState, - ); - - const recordTableWidgetViewPersistedState = - useAtomComponentStateCallbackState( - recordTableWidgetViewPersistedComponentState, - ); - - const store = useStore(); - - const initializeDraft = useCallback(() => { - const currentDraft = store.get(recordTableWidgetViewDraftState); - - if (widgetId in currentDraft) { - return; - } - - if (!view || view.viewFields.length === 0) { - return; - } - - const snapshot = buildRecordTableWidgetViewSnapshotFromView(view); - - store.set(recordTableWidgetViewDraftState, (prev) => ({ - ...prev, - [widgetId]: snapshot, - })); - - store.set(recordTableWidgetViewPersistedState, (prev) => ({ - ...prev, - [widgetId]: snapshot, - })); - }, [ - recordTableWidgetViewDraftState, - recordTableWidgetViewPersistedState, - widgetId, - view, - store, - ]); - - useEffect(initializeDraft, [initializeDraft]); -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/record-table/hooks/useInitializeRecordTableWidgetViewDrafts.ts b/packages/twenty-front/src/modules/page-layout/widgets/record-table/hooks/useInitializeRecordTableWidgetViewDrafts.ts new file mode 100644 index 0000000000..b6657b208d --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/record-table/hooks/useInitializeRecordTableWidgetViewDrafts.ts @@ -0,0 +1,73 @@ +import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutInEditMode'; +import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; +import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState'; +import { recordTableWidgetViewPersistedComponentState } from '@/page-layout/states/recordTableWidgetViewPersistedComponentState'; +import { buildMissingRecordTableWidgetViewDraftSnapshots } from '@/page-layout/widgets/record-table/utils/buildMissingRecordTableWidgetViewDraftSnapshots'; +import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; +import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { viewsSelector } from '@/views/states/selectors/viewsSelector'; +import { useStore } from 'jotai'; +import { useEffect } from 'react'; + +// Seeds the editable view snapshot of every widget backed by a persisted +// view as soon as the layout is in edit mode, and again whenever a widget +// gains a view (e.g. a field widget configured to table display) or views +// finish loading. The draft is the write target of all widget view settings, +// so it must exist before any of them run — including the ones in the side +// panel, which mounts outside this layout tree and cannot seed it itself. +export const useInitializeRecordTableWidgetViewDrafts = () => { + const isPageLayoutInEditMode = useIsPageLayoutInEditMode(); + + const pageLayoutDraft = useAtomComponentStateValue( + pageLayoutDraftComponentState, + ); + + const views = useAtomStateValue(viewsSelector); + + const recordTableWidgetViewDraftState = useAtomComponentStateCallbackState( + recordTableWidgetViewDraftComponentState, + ); + + const recordTableWidgetViewPersistedState = + useAtomComponentStateCallbackState( + recordTableWidgetViewPersistedComponentState, + ); + + const store = useStore(); + + useEffect(() => { + if (!isPageLayoutInEditMode) { + return; + } + + const missingSnapshotsByWidgetId = + buildMissingRecordTableWidgetViewDraftSnapshots({ + widgets: pageLayoutDraft.tabs.flatMap((tab) => tab.widgets), + existingSnapshotsByWidgetId: store.get(recordTableWidgetViewDraftState), + views, + }); + + if (Object.keys(missingSnapshotsByWidgetId).length === 0) { + return; + } + + // Spread order lets entries added elsewhere in the meantime win over the + // freshly built snapshots. + store.set(recordTableWidgetViewDraftState, (previousSnapshots) => ({ + ...missingSnapshotsByWidgetId, + ...previousSnapshots, + })); + store.set(recordTableWidgetViewPersistedState, (previousSnapshots) => ({ + ...missingSnapshotsByWidgetId, + ...previousSnapshots, + })); + }, [ + isPageLayoutInEditMode, + pageLayoutDraft, + views, + recordTableWidgetViewDraftState, + recordTableWidgetViewPersistedState, + store, + ]); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/__tests__/buildMissingRecordTableWidgetViewDraftSnapshots.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/__tests__/buildMissingRecordTableWidgetViewDraftSnapshots.test.ts new file mode 100644 index 0000000000..62c7a34ba7 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/__tests__/buildMissingRecordTableWidgetViewDraftSnapshots.test.ts @@ -0,0 +1,85 @@ +import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; +import { buildMissingRecordTableWidgetViewDraftSnapshots } from '@/page-layout/widgets/record-table/utils/buildMissingRecordTableWidgetViewDraftSnapshots'; +import { buildRecordTableWidgetViewSnapshot } from '@/page-layout/widgets/record-table/utils/buildRecordTableWidgetViewSnapshot'; +import { constructViewFromRecordTableWidgetViewSnapshot } from '@/page-layout/widgets/record-table/utils/constructViewFromRecordTableWidgetViewSnapshot'; +import { makeWidget } from '@/page-layout/testing/pageLayoutDraftFixtures'; +import { getMockObjectMetadataItemOrThrow } from '~/testing/utils/getMockObjectMetadataItemOrThrow'; + +const view = constructViewFromRecordTableWidgetViewSnapshot( + buildRecordTableWidgetViewSnapshot( + getMockObjectMetadataItemOrThrow('company'), + ), +); + +const makeViewBackedWidget = ( + id: string, + viewId: string | undefined, +): PageLayoutWidget => { + const widget = makeWidget(id, 0); + + return { + ...widget, + configuration: { + ...widget.configuration, + viewId, + }, + } as PageLayoutWidget; +}; + +describe('buildMissingRecordTableWidgetViewDraftSnapshots', () => { + it('should build a snapshot for a view-backed widget with no existing snapshot', () => { + const snapshots = buildMissingRecordTableWidgetViewDraftSnapshots({ + widgets: [makeViewBackedWidget('widget-1', view.id)], + existingSnapshotsByWidgetId: {}, + views: [view], + }); + + expect(snapshots['widget-1']).toBeDefined(); + expect(snapshots['widget-1'].view.id).toBe(view.id); + expect(snapshots['widget-1'].viewFields.length).toBeGreaterThan(0); + }); + + it('should not overwrite an existing snapshot', () => { + const existingSnapshot = buildRecordTableWidgetViewSnapshot( + getMockObjectMetadataItemOrThrow('company'), + ); + + const snapshots = buildMissingRecordTableWidgetViewDraftSnapshots({ + widgets: [makeViewBackedWidget('widget-1', view.id)], + existingSnapshotsByWidgetId: { 'widget-1': existingSnapshot }, + views: [view], + }); + + expect(snapshots).toEqual({}); + }); + + it('should skip widgets whose configuration has no view', () => { + const snapshots = buildMissingRecordTableWidgetViewDraftSnapshots({ + widgets: [makeViewBackedWidget('widget-1', undefined)], + existingSnapshotsByWidgetId: {}, + views: [view], + }); + + expect(snapshots).toEqual({}); + }); + + it('should skip widgets whose view is not loaded yet', () => { + const snapshots = buildMissingRecordTableWidgetViewDraftSnapshots({ + widgets: [makeViewBackedWidget('widget-1', 'not-loaded-view-id')], + existingSnapshotsByWidgetId: {}, + views: [view], + }); + + expect(snapshots).toEqual({}); + }); + + it('should skip views that have no view fields yet', () => { + const snapshots = buildMissingRecordTableWidgetViewDraftSnapshots({ + widgets: [makeViewBackedWidget('widget-1', view.id)], + existingSnapshotsByWidgetId: {}, + views: [{ ...view, viewFields: [] }], + }); + + expect(snapshots).toEqual({}); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/buildMissingRecordTableWidgetViewDraftSnapshots.ts b/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/buildMissingRecordTableWidgetViewDraftSnapshots.ts new file mode 100644 index 0000000000..bbd3a769fc --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/buildMissingRecordTableWidgetViewDraftSnapshots.ts @@ -0,0 +1,51 @@ +import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; +import { getWidgetConfigurationViewId } from '@/page-layout/utils/getWidgetConfigurationViewId'; +import { type RecordTableWidgetViewSnapshot } from '@/page-layout/widgets/record-table/types/RecordTableWidgetViewSnapshot'; +import { buildRecordTableWidgetViewSnapshotFromView } from '@/page-layout/widgets/record-table/utils/buildRecordTableWidgetViewSnapshotFromView'; +import { type View } from '@/views/types/View'; +import { isDefined } from 'twenty-shared/utils'; + +type BuildMissingRecordTableWidgetViewDraftSnapshotsParams = { + widgets: PageLayoutWidget[]; + existingSnapshotsByWidgetId: Record; + views: View[]; +}; + +export const buildMissingRecordTableWidgetViewDraftSnapshots = ({ + widgets, + existingSnapshotsByWidgetId, + views, +}: BuildMissingRecordTableWidgetViewDraftSnapshotsParams): Record< + string, + RecordTableWidgetViewSnapshot +> => { + const missingSnapshotsByWidgetId: Record< + string, + RecordTableWidgetViewSnapshot + > = {}; + + for (const widget of widgets) { + if (widget.id in existingSnapshotsByWidgetId) { + continue; + } + + const viewId = getWidgetConfigurationViewId(widget.configuration); + + if (!isDefined(viewId)) { + continue; + } + + const view = views.find((viewToFind) => viewToFind.id === viewId); + + // A view without fields has not finished being created server-side; + // snapshotting it now would lock an empty column set into the draft. + if (!isDefined(view) || view.viewFields.length === 0) { + continue; + } + + missingSnapshotsByWidgetId[widget.id] = + buildRecordTableWidgetViewSnapshotFromView(view); + } + + return missingSnapshotsByWidgetId; +}; diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/FieldWidgetLayoutDropdownContent.tsx b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/FieldWidgetLayoutDropdownContent.tsx index 3a11927de6..07a66dde0a 100644 --- a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/FieldWidgetLayoutDropdownContent.tsx +++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/FieldWidgetLayoutDropdownContent.tsx @@ -2,7 +2,6 @@ import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetada import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; import { isFieldMetadataItemAvailableAsCalendarField } from '@/object-record/record-calendar/utils/isFieldMetadataItemAvailableAsCalendarField'; import { getFieldWidgetAvailableDisplayModes } from '@/page-layout/widgets/field/utils/getFieldWidgetDisplayModeConfig'; -import { RecordTableWidgetViewDraftInitEffect } from '@/page-layout/widgets/record-table/components/RecordTableWidgetViewDraftInitEffect'; import { useAddDraftViewForFieldRelationTableWidget } from '@/page-layout/widgets/record-table/hooks/useAddDraftViewForFieldRelationTableWidget'; import { type RecordTableWidgetLayoutViewType, @@ -208,16 +207,6 @@ export const FieldWidgetLayoutDropdownContent = () => { return ( - {/* The widget's draft snapshot is normally seeded by the table-family - renderer; while displayed as Field/Card that renderer isn't mounted, - so seed the draft here (idempotent) for direct e.g. Card -> Kanban - switches. */} - {isDefined(currentViewId) && isDefined(widgetInEditMode) && ( - - )}