Fix duplicate Fields widget (#19696)
## Context Tab duplication was broken after the view creation logic was deferred and moved to the FE. - Duplicating a tab or a FIELDS widget now produces a fully independent copy: new view, new view field groups, new view fields — all with fresh IDs — while preserving any unsaved edits from the source widget. - Removed the backend auto-seed of default view fields / view field groups in ViewService.createOne for FIELDS_WIDGET views. The frontend always sends the complete layout via upsertFieldsWidget, so the auto-seed was both redundant and the source of potential bugs. - Extracted a shared useDuplicateFieldsWidgetForPageLayout hook used by both tab and widget duplication paths, plus a small useCloneViewInMetadataStore helper that clones the FlatView in the metadata store and returns the copied flat view fields/groups for the caller.
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
import { useUpdateMetadataStoreDraft } from '@/metadata-store/hooks/useUpdateMetadataStoreDraft';
|
||||
import { metadataStoreState } from '@/metadata-store/states/metadataStoreState';
|
||||
import { type FlatView } from '@/metadata-store/types/FlatView';
|
||||
import { type FlatViewField } from '@/metadata-store/types/FlatViewField';
|
||||
import { type FlatViewFieldGroup } from '@/metadata-store/types/FlatViewFieldGroup';
|
||||
import { useStore } from 'jotai';
|
||||
import { useCallback } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export type CloneViewResult = {
|
||||
newViewId: string;
|
||||
copiedViewFieldGroups: FlatViewFieldGroup[];
|
||||
copiedViewFields: FlatViewField[];
|
||||
};
|
||||
|
||||
export const useCloneViewInMetadataStore = () => {
|
||||
const store = useStore();
|
||||
const { addToDraft, applyChanges } = useUpdateMetadataStoreDraft();
|
||||
|
||||
const cloneView = useCallback(
|
||||
(sourceViewId: string): CloneViewResult | null => {
|
||||
const flatViews = store.get(metadataStoreState.atomFamily('views'))
|
||||
.current as FlatView[];
|
||||
const allFlatViewFields = store.get(
|
||||
metadataStoreState.atomFamily('viewFields'),
|
||||
).current as FlatViewField[];
|
||||
const allFlatViewFieldGroups = store.get(
|
||||
metadataStoreState.atomFamily('viewFieldGroups'),
|
||||
).current as FlatViewFieldGroup[];
|
||||
|
||||
const sourceView = flatViews.find((view) => view.id === sourceViewId);
|
||||
|
||||
if (!isDefined(sourceView)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sourceViewFields = allFlatViewFields.filter(
|
||||
(field) => field.viewId === sourceViewId && field.isActive,
|
||||
);
|
||||
const sourceViewFieldGroups = allFlatViewFieldGroups.filter(
|
||||
(group) => group.viewId === sourceViewId && group.isActive,
|
||||
);
|
||||
|
||||
const newViewId = uuidv4();
|
||||
|
||||
const oldGroupIdToNewGroupId = new Map<string, string>();
|
||||
|
||||
for (const group of sourceViewFieldGroups) {
|
||||
oldGroupIdToNewGroupId.set(group.id, uuidv4());
|
||||
}
|
||||
|
||||
const copiedView: FlatView = {
|
||||
...sourceView,
|
||||
id: newViewId,
|
||||
};
|
||||
|
||||
const copiedViewFieldGroups: FlatViewFieldGroup[] =
|
||||
sourceViewFieldGroups.map((group) => ({
|
||||
...group,
|
||||
id: oldGroupIdToNewGroupId.get(group.id) ?? uuidv4(),
|
||||
viewId: newViewId,
|
||||
}));
|
||||
|
||||
const copiedViewFields: FlatViewField[] = sourceViewFields.map(
|
||||
(field) => ({
|
||||
...field,
|
||||
id: uuidv4(),
|
||||
viewId: newViewId,
|
||||
viewFieldGroupId: isDefined(field.viewFieldGroupId)
|
||||
? (oldGroupIdToNewGroupId.get(field.viewFieldGroupId) ?? null)
|
||||
: field.viewFieldGroupId,
|
||||
}),
|
||||
);
|
||||
|
||||
addToDraft({ key: 'views', items: [copiedView] });
|
||||
|
||||
applyChanges();
|
||||
|
||||
return { newViewId, copiedViewFieldGroups, copiedViewFields };
|
||||
},
|
||||
[addToDraft, applyChanges, store],
|
||||
);
|
||||
|
||||
return { cloneView };
|
||||
};
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
|
||||
import { useCloneViewInMetadataStore } from '@/page-layout/hooks/useCloneViewInMetadataStore';
|
||||
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 { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
|
||||
import { buildFieldsWidgetGroupsFromFlatViewData } from '@/page-layout/utils/buildFieldsWidgetGroupsFromFlatViewData';
|
||||
import { getWidgetConfigurationViewId } from '@/page-layout/utils/getWidgetConfigurationViewId';
|
||||
import { type FieldsWidgetEditorMode } from '@/page-layout/widgets/fields/types/FieldsWidgetEditorMode';
|
||||
import {
|
||||
type FieldsWidgetGroup,
|
||||
type FieldsWidgetGroupField,
|
||||
} from '@/page-layout/widgets/fields/types/FieldsWidgetGroup';
|
||||
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 as uuidv4 } from 'uuid';
|
||||
import { WidgetType } from '~/generated-metadata/graphql';
|
||||
|
||||
type DuplicateFieldsWidgetParams = {
|
||||
sourceWidget: PageLayoutWidget;
|
||||
newWidgetId: string;
|
||||
};
|
||||
|
||||
type DuplicateFieldsWidgetResult = {
|
||||
newViewId: string;
|
||||
};
|
||||
|
||||
const stripViewFieldId = (
|
||||
field: FieldsWidgetGroupField,
|
||||
): FieldsWidgetGroupField => {
|
||||
const { viewFieldId: _omit, ...rest } = field;
|
||||
|
||||
return rest;
|
||||
};
|
||||
|
||||
export const useDuplicateFieldsWidgetForPageLayout = ({
|
||||
pageLayoutId,
|
||||
}: {
|
||||
pageLayoutId: string;
|
||||
}) => {
|
||||
const store = useStore();
|
||||
|
||||
const { cloneView } = useCloneViewInMetadataStore();
|
||||
|
||||
const pageLayoutDraftState = useAtomComponentStateCallbackState(
|
||||
pageLayoutDraftComponentState,
|
||||
pageLayoutId,
|
||||
);
|
||||
|
||||
const fieldsWidgetGroupsDraftState = useAtomComponentStateCallbackState(
|
||||
fieldsWidgetGroupsDraftComponentState,
|
||||
pageLayoutId,
|
||||
);
|
||||
|
||||
const fieldsWidgetUngroupedFieldsDraftState =
|
||||
useAtomComponentStateCallbackState(
|
||||
fieldsWidgetUngroupedFieldsDraftComponentState,
|
||||
pageLayoutId,
|
||||
);
|
||||
|
||||
const fieldsWidgetEditorModeDraftState = useAtomComponentStateCallbackState(
|
||||
fieldsWidgetEditorModeDraftComponentState,
|
||||
pageLayoutId,
|
||||
);
|
||||
|
||||
const duplicateFieldsWidget = useCallback(
|
||||
({
|
||||
sourceWidget,
|
||||
newWidgetId,
|
||||
}: DuplicateFieldsWidgetParams): DuplicateFieldsWidgetResult | null => {
|
||||
if (sourceWidget.type !== WidgetType.FIELDS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sourceViewId = getWidgetConfigurationViewId(
|
||||
sourceWidget.configuration,
|
||||
);
|
||||
|
||||
if (!isDefined(sourceViewId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const copyResult = cloneView(sourceViewId);
|
||||
|
||||
if (!isDefined(copyResult)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sourceGroups = store.get(fieldsWidgetGroupsDraftState)[
|
||||
sourceWidget.id
|
||||
];
|
||||
const sourceUngroupedFields = store.get(
|
||||
fieldsWidgetUngroupedFieldsDraftState,
|
||||
)[sourceWidget.id];
|
||||
const sourceEditorMode = store.get(fieldsWidgetEditorModeDraftState)[
|
||||
sourceWidget.id
|
||||
];
|
||||
|
||||
let newGroups: FieldsWidgetGroup[] = [];
|
||||
let newUngroupedFields: FieldsWidgetGroupField[] = [];
|
||||
let newEditorMode: FieldsWidgetEditorMode;
|
||||
|
||||
if (isDefined(sourceEditorMode)) {
|
||||
newEditorMode = sourceEditorMode;
|
||||
|
||||
if (newEditorMode === 'grouped') {
|
||||
newGroups = (sourceGroups ?? []).map((group) => ({
|
||||
...group,
|
||||
id: uuidv4(),
|
||||
fields: group.fields.map(stripViewFieldId),
|
||||
}));
|
||||
} else {
|
||||
newUngroupedFields = (sourceUngroupedFields ?? []).map(
|
||||
stripViewFieldId,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const pageLayoutDraft = store.get(pageLayoutDraftState);
|
||||
const objectMetadataId = pageLayoutDraft.objectMetadataId;
|
||||
const objectMetadataItems = store.get(objectMetadataItemsSelector.atom);
|
||||
const fieldMetadataItems = isDefined(objectMetadataId)
|
||||
? (objectMetadataItems.find((item) => item.id === objectMetadataId)
|
||||
?.fields ?? [])
|
||||
: [];
|
||||
|
||||
const built = buildFieldsWidgetGroupsFromFlatViewData({
|
||||
flatViewFieldGroups: copyResult.copiedViewFieldGroups,
|
||||
flatViewFields: copyResult.copiedViewFields,
|
||||
fieldMetadataItems,
|
||||
});
|
||||
|
||||
newEditorMode = built.editorMode;
|
||||
newGroups = built.groups;
|
||||
newUngroupedFields = built.ungroupedFields;
|
||||
}
|
||||
|
||||
store.set(fieldsWidgetGroupsDraftState, (prev) => ({
|
||||
...prev,
|
||||
[newWidgetId]: newGroups,
|
||||
}));
|
||||
|
||||
store.set(fieldsWidgetUngroupedFieldsDraftState, (prev) => ({
|
||||
...prev,
|
||||
[newWidgetId]: newUngroupedFields,
|
||||
}));
|
||||
|
||||
store.set(fieldsWidgetEditorModeDraftState, (prev) => ({
|
||||
...prev,
|
||||
[newWidgetId]: newEditorMode,
|
||||
}));
|
||||
|
||||
return { newViewId: copyResult.newViewId };
|
||||
},
|
||||
[
|
||||
cloneView,
|
||||
fieldsWidgetEditorModeDraftState,
|
||||
fieldsWidgetGroupsDraftState,
|
||||
fieldsWidgetUngroupedFieldsDraftState,
|
||||
pageLayoutDraftState,
|
||||
store,
|
||||
],
|
||||
);
|
||||
|
||||
return { duplicateFieldsWidget };
|
||||
};
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useDuplicateFieldsWidgetForPageLayout } from '@/page-layout/hooks/useDuplicateFieldsWidgetForPageLayout';
|
||||
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
|
||||
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
|
||||
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
|
||||
import { pageLayoutTabSettingsOpenTabIdComponentState } from '@/page-layout/states/pageLayoutTabSettingsOpenTabIdComponentState';
|
||||
import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab';
|
||||
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
|
||||
import { generateDuplicatedTimestamps } from '@/page-layout/utils/generateDuplicatedTimestamps';
|
||||
import { sortTabsByPosition } from '@/page-layout/utils/sortTabsByPosition';
|
||||
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
|
||||
@@ -56,13 +58,19 @@ export const useDuplicatePageLayoutTab = ({
|
||||
|
||||
const { closeSidePanelMenu } = useSidePanelMenu();
|
||||
|
||||
const { duplicateFieldsWidget } = useDuplicateFieldsWidgetForPageLayout({
|
||||
pageLayoutId,
|
||||
});
|
||||
|
||||
const duplicateTab = useCallback(
|
||||
(tabId: string): string => {
|
||||
const currentPageLayoutDraft = store.get(pageLayoutDraft);
|
||||
|
||||
const allTabLayouts = store.get(pageLayoutCurrentLayouts);
|
||||
|
||||
const sourceTab = currentPageLayoutDraft.tabs.find((t) => t.id === tabId);
|
||||
const sourceTab = currentPageLayoutDraft.tabs.find(
|
||||
(tab) => tab.id === tabId,
|
||||
);
|
||||
|
||||
if (!isDefined(sourceTab)) {
|
||||
throw new Error(`Tab with id ${tabId} not found`);
|
||||
@@ -71,17 +79,32 @@ export const useDuplicatePageLayoutTab = ({
|
||||
const newTabId = uuidv4();
|
||||
const widgetOldIdNewIdMap = new Map<string, string>();
|
||||
|
||||
const clonedWidgets = sourceTab.widgets.map((widget) => {
|
||||
const newWidgetId = uuidv4();
|
||||
widgetOldIdNewIdMap.set(widget.id, newWidgetId);
|
||||
const clonedWidgets: PageLayoutWidget[] = sourceTab.widgets.map(
|
||||
(widget) => {
|
||||
const newWidgetId = uuidv4();
|
||||
widgetOldIdNewIdMap.set(widget.id, newWidgetId);
|
||||
|
||||
return {
|
||||
...widget,
|
||||
id: newWidgetId,
|
||||
pageLayoutTabId: newTabId,
|
||||
...generateDuplicatedTimestamps(),
|
||||
};
|
||||
});
|
||||
const fieldsWidgetCopyResult = duplicateFieldsWidget({
|
||||
sourceWidget: widget,
|
||||
newWidgetId,
|
||||
});
|
||||
|
||||
const clonedConfiguration = isDefined(fieldsWidgetCopyResult)
|
||||
? {
|
||||
...widget.configuration,
|
||||
viewId: fieldsWidgetCopyResult.newViewId,
|
||||
}
|
||||
: widget.configuration;
|
||||
|
||||
return {
|
||||
...widget,
|
||||
id: newWidgetId,
|
||||
pageLayoutTabId: newTabId,
|
||||
configuration: clonedConfiguration,
|
||||
...generateDuplicatedTimestamps(),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const sortedTabs = sortTabsByPosition(currentPageLayoutDraft.tabs);
|
||||
const sourceIndex = sortedTabs.findIndex((t) => t.id === tabId);
|
||||
@@ -144,6 +167,7 @@ export const useDuplicatePageLayoutTab = ({
|
||||
},
|
||||
[
|
||||
closeSidePanelMenu,
|
||||
duplicateFieldsWidget,
|
||||
navigatePageLayoutSidePanel,
|
||||
pageLayoutCurrentLayouts,
|
||||
pageLayoutDraft,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useDuplicateFieldsWidgetForPageLayout } from '@/page-layout/hooks/useDuplicateFieldsWidgetForPageLayout';
|
||||
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
|
||||
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
|
||||
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
|
||||
@@ -45,6 +46,10 @@ export const useDuplicatePageLayoutWidget = (
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const { duplicateFieldsWidget } = useDuplicateFieldsWidgetForPageLayout({
|
||||
pageLayoutId,
|
||||
});
|
||||
|
||||
const duplicateWidget = useCallback(
|
||||
(widgetId: string): string => {
|
||||
const pageLayoutDraft = store.get(pageLayoutDraftState);
|
||||
@@ -71,10 +76,23 @@ export const useDuplicatePageLayoutWidget = (
|
||||
|
||||
const newWidgetId = uuidv4();
|
||||
|
||||
const fieldsWidgetCopyResult = duplicateFieldsWidget({
|
||||
sourceWidget,
|
||||
newWidgetId,
|
||||
});
|
||||
|
||||
const clonedConfiguration = isDefined(fieldsWidgetCopyResult)
|
||||
? {
|
||||
...sourceWidget.configuration,
|
||||
viewId: fieldsWidgetCopyResult.newViewId,
|
||||
}
|
||||
: sourceWidget.configuration;
|
||||
|
||||
const clonedWidget: PageLayoutWidget = {
|
||||
...sourceWidget,
|
||||
id: newWidgetId,
|
||||
title: appendCopySuffix(sourceWidget.title),
|
||||
configuration: clonedConfiguration,
|
||||
...generateDuplicatedTimestamps(),
|
||||
};
|
||||
|
||||
@@ -137,6 +155,7 @@ export const useDuplicatePageLayoutWidget = (
|
||||
return newWidgetId;
|
||||
},
|
||||
[
|
||||
duplicateFieldsWidget,
|
||||
pageLayoutCurrentLayoutsState,
|
||||
pageLayoutDraftState,
|
||||
setPageLayoutEditingWidgetId,
|
||||
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
import { type FlatViewField } from '@/metadata-store/types/FlatViewField';
|
||||
import { type FlatViewFieldGroup } from '@/metadata-store/types/FlatViewFieldGroup';
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import {
|
||||
type FieldsWidgetGroup,
|
||||
type FieldsWidgetGroupField,
|
||||
} from '@/page-layout/widgets/fields/types/FieldsWidgetGroup';
|
||||
import { type FieldsWidgetEditorMode } from '@/page-layout/widgets/fields/types/FieldsWidgetEditorMode';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type BuildResult = {
|
||||
editorMode: FieldsWidgetEditorMode;
|
||||
groups: FieldsWidgetGroup[];
|
||||
ungroupedFields: FieldsWidgetGroupField[];
|
||||
};
|
||||
|
||||
export const buildFieldsWidgetGroupsFromFlatViewData = ({
|
||||
flatViewFieldGroups,
|
||||
flatViewFields,
|
||||
fieldMetadataItems,
|
||||
}: {
|
||||
flatViewFieldGroups: FlatViewFieldGroup[];
|
||||
flatViewFields: FlatViewField[];
|
||||
fieldMetadataItems: FieldMetadataItem[];
|
||||
}): BuildResult => {
|
||||
const fieldMetadataById = new Map(
|
||||
fieldMetadataItems.map((fieldMetadataItem) => [
|
||||
fieldMetadataItem.id,
|
||||
fieldMetadataItem,
|
||||
]),
|
||||
);
|
||||
|
||||
if (flatViewFieldGroups.length > 0) {
|
||||
const groups = flatViewFieldGroups.map((group) => ({
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
position: group.position,
|
||||
isVisible: group.isVisible,
|
||||
fields: flatViewFields
|
||||
.filter((field) => field.viewFieldGroupId === group.id)
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map((field, index) => {
|
||||
const fieldMetadataItem = fieldMetadataById.get(
|
||||
field.fieldMetadataId,
|
||||
);
|
||||
|
||||
if (!isDefined(fieldMetadataItem)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
fieldMetadataItem,
|
||||
position: field.position,
|
||||
isVisible: field.isVisible,
|
||||
globalIndex: index,
|
||||
};
|
||||
})
|
||||
.filter(isDefined),
|
||||
}));
|
||||
|
||||
return { editorMode: 'grouped', groups, ungroupedFields: [] };
|
||||
}
|
||||
|
||||
const ungroupedFields = flatViewFields
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map((field, index) => {
|
||||
const fieldMetadataItem = fieldMetadataById.get(field.fieldMetadataId);
|
||||
|
||||
if (!isDefined(fieldMetadataItem)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
fieldMetadataItem,
|
||||
position: field.position,
|
||||
isVisible: field.isVisible,
|
||||
globalIndex: index,
|
||||
};
|
||||
})
|
||||
.filter(isDefined);
|
||||
|
||||
return { editorMode: 'ungrouped', groups: [], ungroupedFields };
|
||||
};
|
||||
Reference in New Issue
Block a user