Compare commits

...
Author SHA1 Message Date
Devessier dd174a6099 fix: save groups before widget config 2026-03-06 19:04:46 +01:00
Devessier a06b35c70d feat: initial draft 2026-03-06 18:53:19 +01:00
8 changed files with 183 additions and 72 deletions
@@ -1,10 +1,10 @@
import { Action } from '@/action-menu/actions/components/Action';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { useRecordPageLayoutIdFromRecordStoreOrThrow } from '@/page-layout/hooks/useRecordPageLayoutIdFromRecordStoreOrThrow';
import { useSaveFieldsWidgetGroups } from '@/page-layout/hooks/useSaveFieldsWidgetGroups';
import { useSavePageLayout } from '@/page-layout/hooks/useSavePageLayout';
import { useSetIsPageLayoutInEditMode } from '@/page-layout/hooks/useSetIsPageLayoutInEditMode';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
export const SaveRecordPageLayoutSingleRecordAction = () => {
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
@@ -24,11 +24,11 @@ export const SaveRecordPageLayoutSingleRecordAction = () => {
const { closeSidePanelMenu } = useSidePanelMenu();
const handleClick = async () => {
await saveFieldsWidgetGroups();
const result = await savePageLayout();
if (result.status === 'successful') {
await saveFieldsWidgetGroups();
closeSidePanelMenu();
setIsPageLayoutInEditMode(false);
}
@@ -1,10 +1,16 @@
import { fieldsWidgetEditorModeDraftComponentState } from '@/page-layout/states/fieldsWidgetEditorModeDraftComponentState';
import { fieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/fieldsWidgetGroupsDraftComponentState';
import { fieldsWidgetUngroupedFieldsDraftComponentState } from '@/page-layout/states/fieldsWidgetUngroupedFieldsDraftComponentState';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import {
type FieldsConfiguration,
WidgetConfigurationType,
} from '~/generated-metadata/graphql';
type UseCreateFieldsWidgetEditorGroupParams = {
pageLayoutId: string;
@@ -31,6 +37,11 @@ export const useCreateFieldsWidgetEditorGroup = ({
pageLayoutId,
);
const pageLayoutDraftState = useAtomComponentStateCallbackState(
pageLayoutDraftComponentState,
pageLayoutId,
);
const store = useStore();
const createGroup = useCallback(
@@ -74,6 +85,42 @@ export const useCreateFieldsWidgetEditorGroup = ({
...prev,
[widgetId]: 'grouped' as const,
}));
const pageLayoutDraft = store.get(pageLayoutDraftState);
const widget = pageLayoutDraft.tabs
.flatMap((tab) => tab.widgets)
.find((w) => w.id === widgetId);
const fieldsConfiguration =
isDefined(widget?.configuration) &&
widget.configuration.configurationType ===
WidgetConfigurationType.FIELDS
? (widget.configuration as FieldsConfiguration)
: null;
if (isDefined(fieldsConfiguration)) {
store.set(pageLayoutDraftState, (prev) => ({
...prev,
tabs: prev.tabs.map((tab) => ({
...tab,
widgets: tab.widgets.map((w) =>
w.id === widgetId
? {
...w,
configuration: {
...fieldsConfiguration,
newFieldDefaultConfiguration: {
isVisible: true,
viewFieldGroupId: newId,
},
},
}
: w,
),
})),
}));
}
} else {
const allDraftGroups = store.get(fieldsWidgetGroupsDraftState);
@@ -118,6 +165,7 @@ export const useCreateFieldsWidgetEditorGroup = ({
fieldsWidgetGroupsDraftState,
fieldsWidgetUngroupedFieldsDraftState,
fieldsWidgetEditorModeDraftState,
pageLayoutDraftState,
widgetId,
store,
],
@@ -1,9 +1,15 @@
import { fieldsWidgetEditorModeDraftComponentState } from '@/page-layout/states/fieldsWidgetEditorModeDraftComponentState';
import { fieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/fieldsWidgetGroupsDraftComponentState';
import { fieldsWidgetUngroupedFieldsDraftComponentState } from '@/page-layout/states/fieldsWidgetUngroupedFieldsDraftComponentState';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
import {
type FieldsConfiguration,
WidgetConfigurationType,
} from '~/generated-metadata/graphql';
type UseDeleteFieldsWidgetEditorGroupParams = {
pageLayoutId: string;
@@ -30,6 +36,11 @@ export const useDeleteFieldsWidgetEditorGroup = ({
pageLayoutId,
);
const pageLayoutDraftState = useAtomComponentStateCallbackState(
pageLayoutDraftComponentState,
pageLayoutId,
);
const store = useStore();
const deleteGroup = useCallback(
@@ -44,6 +55,25 @@ export const useDeleteFieldsWidgetEditorGroup = ({
return;
}
const pageLayoutDraft = store.get(pageLayoutDraftState);
const widget = pageLayoutDraft.tabs
.flatMap((tab) => tab.widgets)
.find((w) => w.id === widgetId);
const fieldsConfiguration =
isDefined(widget?.configuration) &&
widget.configuration.configurationType ===
WidgetConfigurationType.FIELDS
? (widget.configuration as FieldsConfiguration)
: null;
const currentViewFieldGroupId =
fieldsConfiguration?.newFieldDefaultConfiguration?.viewFieldGroupId ??
null;
const isReferencedGroupBeingDeleted = currentViewFieldGroupId === groupId;
const deletedGroupFields = deletedGroup.fields;
const remainingGroups = currentGroups.filter(
(group) => group.id !== groupId,
@@ -69,6 +99,26 @@ export const useDeleteFieldsWidgetEditorGroup = ({
[widgetId]: 'ungrouped' as const,
}));
if (isDefined(fieldsConfiguration)) {
store.set(pageLayoutDraftState, (prev) => ({
...prev,
tabs: prev.tabs.map((tab) => ({
...tab,
widgets: tab.widgets.map((w) =>
w.id === widgetId
? {
...w,
configuration: {
...fieldsConfiguration,
newFieldDefaultConfiguration: null,
},
}
: w,
),
})),
}));
}
return;
}
@@ -112,11 +162,37 @@ export const useDeleteFieldsWidgetEditorGroup = ({
};
}),
}));
if (isReferencedGroupBeingDeleted && isDefined(fieldsConfiguration)) {
store.set(pageLayoutDraftState, (prev) => ({
...prev,
tabs: prev.tabs.map((tab) => ({
...tab,
widgets: tab.widgets.map((w) =>
w.id === widgetId
? {
...w,
configuration: {
...fieldsConfiguration,
newFieldDefaultConfiguration: {
isVisible:
fieldsConfiguration.newFieldDefaultConfiguration
?.isVisible ?? true,
viewFieldGroupId: targetGroup.id,
},
},
}
: w,
),
})),
}));
}
},
[
fieldsWidgetGroupsDraftState,
fieldsWidgetUngroupedFieldsDraftState,
fieldsWidgetEditorModeDraftState,
pageLayoutDraftState,
widgetId,
store,
],
@@ -1,6 +1,4 @@
import { fieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/fieldsWidgetGroupsDraftComponentState';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { getLastGroupId } from '@/page-layout/widgets/fields/utils/getLastGroupId';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { isDefined } from 'twenty-shared/utils';
import {
@@ -22,13 +20,6 @@ export const useGetNewFieldDefaultConfiguration = ({
pageLayoutId,
);
const fieldsWidgetGroupsDraft = useAtomComponentStateValue(
fieldsWidgetGroupsDraftComponentState,
pageLayoutId,
);
const draftGroups = fieldsWidgetGroupsDraft[widgetId] ?? [];
const widget = pageLayoutDraft.tabs
.flatMap((tab) => tab.widgets)
.find((w) => w.id === widgetId);
@@ -39,13 +30,11 @@ export const useGetNewFieldDefaultConfiguration = ({
? (widget.configuration as FieldsConfiguration)
: null;
const lastGroupId = getLastGroupId(draftGroups);
const persisted = fieldsConfiguration?.newFieldDefaultConfiguration;
const newFieldDefaultConfiguration = {
isVisible: persisted?.isVisible ?? true,
viewFieldGroupId: persisted?.viewFieldGroupId ?? lastGroupId,
viewFieldGroupId: persisted?.viewFieldGroupId ?? null,
};
return { newFieldDefaultConfiguration, fieldsConfiguration };
@@ -1,46 +0,0 @@
import { type FieldsWidgetGroup } from '@/page-layout/widgets/fields/types/FieldsWidgetGroup';
import { getLastGroupId } from '@/page-layout/widgets/fields/utils/getLastGroupId';
const makeGroup = (
overrides: Partial<FieldsWidgetGroup> & { id: string },
): FieldsWidgetGroup => ({
name: 'Group',
position: 0,
isVisible: true,
fields: [],
...overrides,
});
describe('getLastGroupId', () => {
it('should return null for empty array', () => {
expect(getLastGroupId([])).toBeNull();
});
it('should return the id of a single group', () => {
const groups = [makeGroup({ id: 'g1', position: 0 })];
expect(getLastGroupId(groups)).toBe('g1');
});
it('should return the id of the group with the highest position', () => {
const groups = [
makeGroup({ id: 'g1', position: 0 }),
makeGroup({ id: 'g2', position: 2 }),
makeGroup({ id: 'g3', position: 1 }),
];
expect(getLastGroupId(groups)).toBe('g2');
});
it('should not mutate the original array', () => {
const groups = [
makeGroup({ id: 'g2', position: 2 }),
makeGroup({ id: 'g1', position: 0 }),
];
getLastGroupId(groups);
expect(groups[0].id).toBe('g2');
expect(groups[1].id).toBe('g1');
});
});
@@ -1,11 +0,0 @@
import { type FieldsWidgetGroup } from '@/page-layout/widgets/fields/types/FieldsWidgetGroup';
export const getLastGroupId = (groups: FieldsWidgetGroup[]): string | null => {
if (groups.length === 0) {
return null;
}
const sortedGroups = [...groups].sort((a, b) => a.position - b.position);
return sortedGroups[sortedGroups.length - 1].id;
};
@@ -0,0 +1,35 @@
import { isDefined } from 'twenty-shared/utils';
import { type FlatViewFieldGroupMaps } from 'src/engine/metadata-modules/flat-view-field-group/types/flat-view-field-group-maps.type';
import { type FieldsConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/fields-configuration.dto';
import {
PageLayoutWidgetException,
PageLayoutWidgetExceptionCode,
} from 'src/engine/metadata-modules/page-layout-widget/exceptions/page-layout-widget.exception';
export const validateFieldsWidgetNewFieldDefaultConfiguration = ({
configuration,
flatViewFieldGroupMaps,
}: {
configuration: FieldsConfigurationDTO;
flatViewFieldGroupMaps: FlatViewFieldGroupMaps;
}): void => {
const { viewId, newFieldDefaultConfiguration } = configuration;
if (!isDefined(viewId)) {
return;
}
const hasViewFieldGroups = Object.values(
flatViewFieldGroupMaps.byUniversalIdentifier,
)
.filter(isDefined)
.some((group) => !isDefined(group.deletedAt) && group.viewId === viewId);
if (hasViewFieldGroups && !isDefined(newFieldDefaultConfiguration)) {
throw new PageLayoutWidgetException(
'Fields widget has view field groups but newFieldDefaultConfiguration is not defined. It must be set when groups exist.',
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
}
};
@@ -20,7 +20,10 @@ import { fromPageLayoutWidgetConfigurationToUniversalConfiguration } from 'src/e
import { type FlatPageLayout } from 'src/engine/metadata-modules/flat-page-layout/types/flat-page-layout.type';
import { reconstructFlatPageLayoutWithTabsAndWidgets } from 'src/engine/metadata-modules/flat-page-layout/utils/reconstruct-flat-page-layout-with-tabs-and-widgets.util';
import { UpdatePageLayoutTabWithWidgetsInput } from 'src/engine/metadata-modules/page-layout-tab/dtos/inputs/update-page-layout-tab-with-widgets.input';
import { type FieldsConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/fields-configuration.dto';
import { UpdatePageLayoutWidgetWithIdInput } from 'src/engine/metadata-modules/page-layout-widget/dtos/inputs/update-page-layout-widget-with-id.input';
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
import { validateFieldsWidgetNewFieldDefaultConfiguration } from 'src/engine/metadata-modules/page-layout-widget/utils/validate-fields-widget-new-field-default-configuration.util';
import { UpdatePageLayoutWithTabsInput } from 'src/engine/metadata-modules/page-layout/dtos/inputs/update-page-layout-with-tabs.input';
import { PageLayoutDTO } from 'src/engine/metadata-modules/page-layout/dtos/page-layout.dto';
import {
@@ -477,6 +480,23 @@ export class PageLayoutUpdateService {
const now = new Date();
for (const widgetInput of [
...entitiesToCreate,
...entitiesToUpdate,
...entitiesToRestoreAndUpdate,
]) {
if (
isDefined(widgetInput.configuration) &&
widgetInput.configuration.configurationType ===
WidgetConfigurationType.FIELDS
) {
validateFieldsWidgetNewFieldDefaultConfiguration({
configuration: widgetInput.configuration as FieldsConfigurationDTO,
flatViewFieldGroupMaps,
});
}
}
const widgetsToCreate: FlatPageLayoutWidget[] = entitiesToCreate.map(
(widgetInput) => {
const widgetId = widgetInput.id ?? v4();