Compare commits

...
Author SHA1 Message Date
sonarly-bot 691fd2a3ab fix(page-layout): handle fields widgets missing viewId on save
https://sonarly.com/issue/38244?type=bug

Saving Record Page Layout fails when reordering field sections because the fields-widget upsert path hard-fails if the widget has no linked view.

Fix: I implemented a user-impacting fix so saves no longer fail when legacy FIELDS widgets are missing `configuration.viewId`:

1) `useCreatePendingFieldsWidgetViews` now repairs missing `viewId` values for draft FIELDS widgets before save by generating UUIDs, writing them back to draft layout state, and creating the corresponding pending views.
2) It now persists views not only for brand-new FIELDS widgets, but also for existing widgets whose persisted `viewId` differs (including `null -> generated-id` repair).
3) In `useSaveLayoutCustomization`, I moved the dirty-check to run *after* pending view creation and draft refresh, so repaired `viewId` changes are included in `updatePageLayoutWithTabsAndWidgets` before `upsertFieldsWidget` executes. This prevents server-side `viewId` null failures on reorder/save.

This keeps behavior aligned with existing save architecture (create pending views first, then persist layout, then save widget data) while making legacy null-`viewId` widgets recoverable during normal save.

Authored by Sonarly by autonomous analysis (run 43654).
2026-05-18 06:45:00 +00:00
3 changed files with 69 additions and 12 deletions
@@ -93,6 +93,19 @@ export const useSaveLayoutCustomization = () => {
continue;
}
await createPendingFieldsWidgetViews(pageLayoutId);
await createPendingRecordTableWidgetViews(pageLayoutId);
const refreshedDraft = store.get(
pageLayoutDraftComponentState.atomFamily({
instanceId: pageLayoutId,
}),
);
if (!isDefined(refreshedDraft)) {
continue;
}
const persistedAsDraft: DraftPageLayout = {
id: persisted.id,
name: persisted.name,
@@ -104,15 +117,12 @@ export const useSaveLayoutCustomization = () => {
};
const isPageLayoutStructureDirty = !isDeeplyEqual(
draft,
refreshedDraft,
persistedAsDraft,
);
await createPendingFieldsWidgetViews(pageLayoutId);
await createPendingRecordTableWidgetViews(pageLayoutId);
if (isPageLayoutStructureDirty) {
const updateInput = convertPageLayoutDraftToUpdateInput(draft);
const updateInput = convertPageLayoutDraftToUpdateInput(refreshedDraft);
const result = await updatePageLayoutWithTabsAndWidgets(
pageLayoutId,
updateInput,
@@ -6,6 +6,7 @@ import { usePerformViewAPIPersist } from '@/views/hooks/internal/usePerformViewA
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { v4 as uuidv4 } from 'uuid';
import { ViewType, WidgetType } from '~/generated-metadata/graphql';
export const useCreatePendingFieldsWidgetViews = () => {
@@ -25,26 +26,71 @@ export const useCreatePendingFieldsWidgetViews = () => {
}),
);
const persistedWidgetIds = new Set(
persisted?.tabs.flatMap((tab) =>
tab.widgets.map((widget) => widget.id),
) ?? [],
const persistedFieldsWidgetViewIdsByWidgetId = new Map(
(persisted?.tabs ?? [])
.flatMap((tab) => tab.widgets)
.filter((widget) => widget.type === WidgetType.FIELDS)
.map((widget) => [
widget.id,
getWidgetConfigurationViewId(widget.configuration),
]),
);
const draftWithGeneratedViewIds = {
...draft,
tabs: draft.tabs.map((tab) => ({
...tab,
widgets: tab.widgets.map((widget) => {
if (widget.type !== WidgetType.FIELDS) {
return widget;
}
const viewId = getWidgetConfigurationViewId(widget.configuration);
if (isDefined(viewId)) {
return widget;
}
return {
...widget,
configuration: {
...widget.configuration,
viewId: uuidv4(),
},
};
}),
})),
};
store.set(
pageLayoutDraftComponentState.atomFamily({
instanceId: pageLayoutId,
}),
draftWithGeneratedViewIds,
);
const objectMetadataItems = store.get(objectMetadataItemsSelector.atom);
const newFieldsWidgets = draft.tabs
const fieldsWidgetsToPersist = draftWithGeneratedViewIds.tabs
.flatMap((tab) => tab.widgets)
.filter((widget) => {
if (widget.type !== WidgetType.FIELDS) {
return false;
}
const viewId = getWidgetConfigurationViewId(widget.configuration);
return isDefined(viewId) && !persistedWidgetIds.has(widget.id);
if (!isDefined(viewId)) {
return false;
}
const persistedViewId =
persistedFieldsWidgetViewIdsByWidgetId.get(widget.id);
return persistedViewId !== viewId;
});
for (const widget of newFieldsWidgets) {
for (const widget of fieldsWidgetsToPersist) {
const viewId = getWidgetConfigurationViewId(widget.configuration);
if (!isDefined(viewId)) {
@@ -18,6 +18,7 @@ import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager
@Catch(
ViewException,
ViewFieldException,
ViewFieldGroupException,
ViewFilterException,
ViewFilterGroupException,
ViewGroupException,