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:
Weiko
2026-04-15 14:29:44 +02:00
committed by GitHub
parent 2df32f7003
commit e12b55a951
9 changed files with 402 additions and 607 deletions
@@ -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 };
};
@@ -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,
@@ -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 };
};
@@ -174,6 +174,7 @@ export class PageLayoutUpdateService {
});
const orphanedViewIds = this.collectOrphanedViewIdsFromRemovedWidgets({
widgetsToCreate,
widgetsToUpdate,
widgetsToDelete,
tabsToUpdate,
@@ -868,12 +869,14 @@ export class PageLayoutUpdateService {
}
private collectOrphanedViewIdsFromRemovedWidgets({
widgetsToCreate,
widgetsToUpdate,
widgetsToDelete,
tabsToUpdate,
tabsToDelete,
flatPageLayoutWidgetMaps,
}: {
widgetsToCreate: FlatPageLayoutWidget[];
widgetsToUpdate: FlatPageLayoutWidget[];
widgetsToDelete: FlatPageLayoutWidget[];
tabsToUpdate: FlatPageLayoutTab[];
@@ -934,6 +937,14 @@ export class PageLayoutUpdateService {
}
}
for (const widget of widgetsToCreate) {
const viewId = this.getViewIdFromFieldsWidget(widget);
if (isDefined(viewId)) {
viewIdsToDelete.delete(viewId);
}
}
return [...viewIdsToDelete];
}
@@ -31,12 +31,9 @@ import { DestroyViewInput } from 'src/engine/metadata-modules/view/dtos/inputs/d
import { UpdateViewInput } from 'src/engine/metadata-modules/view/dtos/inputs/update-view.input';
import { ViewDTO } from 'src/engine/metadata-modules/view/dtos/view.dto';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { computeFieldsWidgetViewFieldsAndGroupsToCreate } from 'src/engine/metadata-modules/view/utils/compute-fields-widget-view-fields-and-groups-to-create.util';
import { fromFlatViewToViewDto } from 'src/engine/metadata-modules/view/utils/from-flat-view-to-view-dto.util';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
import { type UniversalFlatViewFieldGroup } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view-field-group.type';
import { type UniversalFlatViewField } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view-field.type';
@Injectable()
export class ViewService {
@@ -85,39 +82,6 @@ export class ViewService {
flatObjectMetadataMaps: existingFlatObjectMetadataMaps,
});
let flatViewFieldGroupsToCreate: UniversalFlatViewFieldGroup[] = [];
let flatViewFieldsToCreate: UniversalFlatViewField[] = [];
if (flatViewToCreate.type === ViewType.FIELDS_WIDGET) {
const objectFlatFieldMetadatas = Object.values(
existingFlatFieldMetadataMaps.byUniversalIdentifier,
).filter(
(field): field is NonNullable<typeof field> =>
field !== undefined &&
field.objectMetadataUniversalIdentifier ===
flatViewToCreate.objectMetadataUniversalIdentifier,
);
const objectFlatMetadata = findFlatEntityByUniversalIdentifierOrThrow({
flatEntityMaps: existingFlatObjectMetadataMaps,
universalIdentifier: flatViewToCreate.objectMetadataUniversalIdentifier,
});
const fieldsWidgetResult = computeFieldsWidgetViewFieldsAndGroupsToCreate(
{
objectFlatFieldMetadatas,
viewUniversalIdentifier: flatViewToCreate.universalIdentifier,
flatApplication: workspaceCustomFlatApplication,
labelIdentifierFieldMetadataUniversalIdentifier:
objectFlatMetadata.labelIdentifierFieldMetadataUniversalIdentifier,
},
);
flatViewFieldGroupsToCreate =
fieldsWidgetResult.flatViewFieldGroupsToCreate;
flatViewFieldsToCreate = fieldsWidgetResult.flatViewFieldsToCreate;
}
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
@@ -133,18 +97,6 @@ export class ViewService {
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
viewFieldGroup: {
flatEntityToCreate: flatViewFieldGroupsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
viewField: {
flatEntityToCreate: flatViewFieldsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
},
workspaceId,
isSystemBuild: false,
@@ -1,406 +0,0 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { v4 } from 'uuid';
import { DEFAULT_VIEW_FIELD_SIZE } from 'src/engine/metadata-modules/flat-view-field/constants/default-view-field-size.constant';
import { type UniversalFlatFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-metadata.type';
import { computeFieldsWidgetViewFieldsAndGroupsToCreate } from '../compute-fields-widget-view-fields-and-groups-to-create.util';
const makeFieldMetadata = (
overrides: Partial<UniversalFlatFieldMetadata> & {
name: string;
type: FieldMetadataType;
},
): UniversalFlatFieldMetadata => {
const universalIdentifier = overrides.universalIdentifier ?? v4();
return {
universalIdentifier,
objectMetadataUniversalIdentifier: 'object-uid',
applicationUniversalIdentifier: 'app-uid',
name: overrides.name,
label: overrides.label ?? overrides.name,
type: overrides.type,
isCustom: overrides.isCustom ?? false,
isActive: true,
isSystem: false,
isUIReadOnly: false,
isNullable: true,
isUnique: false,
isLabelSyncedWithName: false,
defaultValue: null,
description: null,
icon: null,
options: null,
settings: null,
standardOverrides: null,
relationTargetObjectMetadataUniversalIdentifier: null,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
deletedAt: null,
} as unknown as UniversalFlatFieldMetadata;
};
const flatApplication = {
universalIdentifier: 'app-uid',
id: 'app-id',
} as never;
const viewUniversalIdentifier = 'view-uid';
describe('computeFieldsWidgetViewFieldsAndGroupsToCreate', () => {
it('should create a "General" group with standard fields', () => {
const fields = [
makeFieldMetadata({
name: 'name',
type: FieldMetadataType.TEXT,
isCustom: false,
}),
makeFieldMetadata({
name: 'createdAt',
type: FieldMetadataType.DATE_TIME,
isCustom: false,
}),
];
const result = computeFieldsWidgetViewFieldsAndGroupsToCreate({
objectFlatFieldMetadatas: fields,
viewUniversalIdentifier,
flatApplication,
labelIdentifierFieldMetadataUniversalIdentifier: null,
});
expect(result.flatViewFieldGroupsToCreate).toHaveLength(1);
expect(result.flatViewFieldGroupsToCreate[0].name).toBe('General');
expect(result.flatViewFieldGroupsToCreate[0].position).toBe(0);
expect(result.flatViewFieldGroupsToCreate[0].isVisible).toBe(true);
expect(result.flatViewFieldsToCreate).toHaveLength(2);
result.flatViewFieldsToCreate.forEach((vf) => {
expect(vf.viewFieldGroupUniversalIdentifier).toBe(
result.flatViewFieldGroupsToCreate[0].universalIdentifier,
);
expect(vf.isVisible).toBe(true);
expect(vf.size).toBe(DEFAULT_VIEW_FIELD_SIZE);
expect(vf.viewUniversalIdentifier).toBe(viewUniversalIdentifier);
});
expect(result.flatViewFieldsToCreate[0].position).toBe(0);
expect(result.flatViewFieldsToCreate[1].position).toBe(1);
});
it('should create an "Other" group when custom fields exist', () => {
const fields = [
makeFieldMetadata({
name: 'name',
type: FieldMetadataType.TEXT,
isCustom: false,
}),
makeFieldMetadata({
name: 'customRating',
type: FieldMetadataType.NUMBER,
isCustom: true,
}),
makeFieldMetadata({
name: 'customTag',
type: FieldMetadataType.TEXT,
isCustom: true,
}),
];
const result = computeFieldsWidgetViewFieldsAndGroupsToCreate({
objectFlatFieldMetadatas: fields,
viewUniversalIdentifier,
flatApplication,
labelIdentifierFieldMetadataUniversalIdentifier: null,
});
expect(result.flatViewFieldGroupsToCreate).toHaveLength(2);
expect(result.flatViewFieldGroupsToCreate[0].name).toBe('General');
expect(result.flatViewFieldGroupsToCreate[0].position).toBe(0);
expect(result.flatViewFieldGroupsToCreate[1].name).toBe('Other');
expect(result.flatViewFieldGroupsToCreate[1].position).toBe(1);
const generalGroupUid =
result.flatViewFieldGroupsToCreate[0].universalIdentifier;
const otherGroupUid =
result.flatViewFieldGroupsToCreate[1].universalIdentifier;
const generalFields = result.flatViewFieldsToCreate.filter(
(vf) => vf.viewFieldGroupUniversalIdentifier === generalGroupUid,
);
const otherFields = result.flatViewFieldsToCreate.filter(
(vf) => vf.viewFieldGroupUniversalIdentifier === otherGroupUid,
);
expect(generalFields).toHaveLength(1);
expect(otherFields).toHaveLength(2);
// Positions are sequential within each group
expect(otherFields[0].position).toBe(0);
expect(otherFields[1].position).toBe(1);
});
it('should not create an "Other" group when there are no custom fields', () => {
const fields = [
makeFieldMetadata({
name: 'name',
type: FieldMetadataType.TEXT,
isCustom: false,
}),
];
const result = computeFieldsWidgetViewFieldsAndGroupsToCreate({
objectFlatFieldMetadatas: fields,
viewUniversalIdentifier,
flatApplication,
labelIdentifierFieldMetadataUniversalIdentifier: null,
});
expect(result.flatViewFieldGroupsToCreate).toHaveLength(1);
expect(result.flatViewFieldGroupsToCreate[0].name).toBe('General');
});
it('should exclude deletedAt field', () => {
const fields = [
makeFieldMetadata({
name: 'name',
type: FieldMetadataType.TEXT,
isCustom: false,
}),
makeFieldMetadata({
name: 'deletedAt',
type: FieldMetadataType.DATE_TIME,
isCustom: false,
}),
];
const result = computeFieldsWidgetViewFieldsAndGroupsToCreate({
objectFlatFieldMetadatas: fields,
viewUniversalIdentifier,
flatApplication,
labelIdentifierFieldMetadataUniversalIdentifier: null,
});
expect(result.flatViewFieldsToCreate).toHaveLength(1);
expect(
result.flatViewFieldsToCreate[0].fieldMetadataUniversalIdentifier,
).toBe(fields[0].universalIdentifier);
});
it('should exclude TS_VECTOR and POSITION fields', () => {
const fields = [
makeFieldMetadata({
name: 'name',
type: FieldMetadataType.TEXT,
isCustom: false,
}),
makeFieldMetadata({
name: 'searchVector',
type: FieldMetadataType.TS_VECTOR,
isCustom: false,
}),
makeFieldMetadata({
name: 'position',
type: FieldMetadataType.POSITION,
isCustom: false,
}),
];
const result = computeFieldsWidgetViewFieldsAndGroupsToCreate({
objectFlatFieldMetadatas: fields,
viewUniversalIdentifier,
flatApplication,
labelIdentifierFieldMetadataUniversalIdentifier: null,
});
expect(result.flatViewFieldsToCreate).toHaveLength(1);
});
it('should always exclude id field even when it is the label identifier', () => {
const idUid = v4();
const fields = [
makeFieldMetadata({
name: 'id',
type: FieldMetadataType.UUID,
isCustom: false,
universalIdentifier: idUid,
}),
makeFieldMetadata({
name: 'name',
type: FieldMetadataType.TEXT,
isCustom: false,
}),
];
// Without label identifier match: id excluded
const resultWithout = computeFieldsWidgetViewFieldsAndGroupsToCreate({
objectFlatFieldMetadatas: fields,
viewUniversalIdentifier,
flatApplication,
labelIdentifierFieldMetadataUniversalIdentifier: null,
});
expect(resultWithout.flatViewFieldsToCreate).toHaveLength(1);
// With id as label identifier: id still excluded (label identifier fields are excluded)
const resultWith = computeFieldsWidgetViewFieldsAndGroupsToCreate({
objectFlatFieldMetadatas: fields,
viewUniversalIdentifier,
flatApplication,
labelIdentifierFieldMetadataUniversalIdentifier: idUid,
});
expect(resultWith.flatViewFieldsToCreate).toHaveLength(1);
});
it('should set correct applicationUniversalIdentifier on all entities', () => {
const fields = [
makeFieldMetadata({
name: 'name',
type: FieldMetadataType.TEXT,
isCustom: false,
}),
makeFieldMetadata({
name: 'custom',
type: FieldMetadataType.TEXT,
isCustom: true,
}),
];
const result = computeFieldsWidgetViewFieldsAndGroupsToCreate({
objectFlatFieldMetadatas: fields,
viewUniversalIdentifier,
flatApplication,
labelIdentifierFieldMetadataUniversalIdentifier: null,
});
result.flatViewFieldGroupsToCreate.forEach((group) => {
expect(group.applicationUniversalIdentifier).toBe('app-uid');
});
result.flatViewFieldsToCreate.forEach((field) => {
expect(field.applicationUniversalIdentifier).toBe('app-uid');
});
});
it('should return empty fields when all fields are excluded', () => {
const fields = [
makeFieldMetadata({
name: 'deletedAt',
type: FieldMetadataType.DATE_TIME,
isCustom: false,
}),
makeFieldMetadata({
name: 'searchVector',
type: FieldMetadataType.TS_VECTOR,
isCustom: false,
}),
];
const result = computeFieldsWidgetViewFieldsAndGroupsToCreate({
objectFlatFieldMetadatas: fields,
viewUniversalIdentifier,
flatApplication,
labelIdentifierFieldMetadataUniversalIdentifier: null,
});
// Still creates the "General" group, just with no fields
expect(result.flatViewFieldGroupsToCreate).toHaveLength(1);
expect(result.flatViewFieldsToCreate).toHaveLength(0);
});
it('should place label identifier field at position 0', () => {
const labelUid = v4();
const fields = [
makeFieldMetadata({
name: 'createdAt',
type: FieldMetadataType.DATE_TIME,
isCustom: false,
}),
makeFieldMetadata({
name: 'updatedAt',
type: FieldMetadataType.DATE_TIME,
isCustom: false,
}),
makeFieldMetadata({
name: 'name',
type: FieldMetadataType.TEXT,
isCustom: false,
universalIdentifier: labelUid,
}),
];
const result = computeFieldsWidgetViewFieldsAndGroupsToCreate({
objectFlatFieldMetadatas: fields,
viewUniversalIdentifier,
flatApplication,
labelIdentifierFieldMetadataUniversalIdentifier: labelUid,
});
// Label identifier field is excluded by isFieldMetadataEligibleForFieldsWidget
expect(result.flatViewFieldsToCreate).toHaveLength(2);
// The label identifier field should not be in the results
const labelField = result.flatViewFieldsToCreate.find(
(vf) => vf.fieldMetadataUniversalIdentifier === labelUid,
);
expect(labelField).toBeUndefined();
});
it('should make RELATION and MORPH_RELATION fields invisible by default', () => {
const fields = [
makeFieldMetadata({
name: 'name',
type: FieldMetadataType.TEXT,
isCustom: false,
}),
makeFieldMetadata({
name: 'company',
type: FieldMetadataType.RELATION,
isCustom: false,
}),
makeFieldMetadata({
name: 'target',
type: FieldMetadataType.MORPH_RELATION,
isCustom: false,
}),
makeFieldMetadata({
name: 'customRelation',
type: FieldMetadataType.RELATION,
isCustom: true,
}),
];
const result = computeFieldsWidgetViewFieldsAndGroupsToCreate({
objectFlatFieldMetadatas: fields,
viewUniversalIdentifier,
flatApplication,
labelIdentifierFieldMetadataUniversalIdentifier: null,
});
const viewFieldForName = result.flatViewFieldsToCreate.find(
(vf) =>
vf.fieldMetadataUniversalIdentifier === fields[0].universalIdentifier,
);
const viewFieldForCompany = result.flatViewFieldsToCreate.find(
(vf) =>
vf.fieldMetadataUniversalIdentifier === fields[1].universalIdentifier,
);
const viewFieldForTarget = result.flatViewFieldsToCreate.find(
(vf) =>
vf.fieldMetadataUniversalIdentifier === fields[2].universalIdentifier,
);
const viewFieldForCustomRelation = result.flatViewFieldsToCreate.find(
(vf) =>
vf.fieldMetadataUniversalIdentifier === fields[3].universalIdentifier,
);
expect(viewFieldForName!.isVisible).toBe(true);
expect(viewFieldForCompany!.isVisible).toBe(false);
expect(viewFieldForTarget!.isVisible).toBe(false);
expect(viewFieldForCustomRelation!.isVisible).toBe(false);
});
});
@@ -1,142 +0,0 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { v4 } from 'uuid';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { DEFAULT_VIEW_FIELD_SIZE } from 'src/engine/metadata-modules/flat-view-field/constants/default-view-field-size.constant';
import { type UniversalFlatFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-metadata.type';
import { type UniversalFlatViewFieldGroup } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view-field-group.type';
import { type UniversalFlatViewField } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view-field.type';
import { isFieldMetadataEligibleForFieldsWidget } from 'twenty-shared/utils';
export const computeFieldsWidgetViewFieldsAndGroupsToCreate = ({
objectFlatFieldMetadatas,
viewUniversalIdentifier,
flatApplication,
labelIdentifierFieldMetadataUniversalIdentifier,
}: {
objectFlatFieldMetadatas: UniversalFlatFieldMetadata[];
viewUniversalIdentifier: string;
flatApplication: FlatApplication;
labelIdentifierFieldMetadataUniversalIdentifier: string | null;
}): {
flatViewFieldGroupsToCreate: UniversalFlatViewFieldGroup[];
flatViewFieldsToCreate: UniversalFlatViewField[];
} => {
const createdAt = new Date().toISOString();
const applicationUniversalIdentifier = flatApplication.universalIdentifier;
const eligibleFields = objectFlatFieldMetadatas.filter((field) =>
isFieldMetadataEligibleForFieldsWidget({
fieldName: field.name,
fieldType: field.type,
isLabelIdentifierField:
field.universalIdentifier ===
labelIdentifierFieldMetadataUniversalIdentifier,
}),
);
const standardFields = eligibleFields.filter((field) => !field.isCustom);
const customFields = eligibleFields.filter((field) => field.isCustom);
const sortedStandardFields = [...standardFields].sort((a, b) => {
const aIsLabel =
a.universalIdentifier === labelIdentifierFieldMetadataUniversalIdentifier;
const bIsLabel =
b.universalIdentifier === labelIdentifierFieldMetadataUniversalIdentifier;
if (aIsLabel && !bIsLabel) return -1;
if (!aIsLabel && bIsLabel) return 1;
return 0;
});
const flatViewFieldGroupsToCreate: UniversalFlatViewFieldGroup[] = [];
const flatViewFieldsToCreate: UniversalFlatViewField[] = [];
const generalGroupUniversalIdentifier = v4();
flatViewFieldGroupsToCreate.push({
universalIdentifier: generalGroupUniversalIdentifier,
applicationUniversalIdentifier,
viewUniversalIdentifier,
name: 'General',
position: 0,
isVisible: true,
isActive: true,
overrides: null,
viewFieldUniversalIdentifiers: [],
createdAt,
updatedAt: createdAt,
deletedAt: null,
});
sortedStandardFields.forEach((field, index) => {
const isVisible =
field.type !== FieldMetadataType.RELATION &&
field.type !== FieldMetadataType.MORPH_RELATION;
flatViewFieldsToCreate.push({
fieldMetadataUniversalIdentifier: field.universalIdentifier,
viewUniversalIdentifier,
viewFieldGroupUniversalIdentifier: generalGroupUniversalIdentifier,
createdAt,
updatedAt: createdAt,
deletedAt: null,
universalIdentifier: v4(),
isVisible,
isActive: true,
size: DEFAULT_VIEW_FIELD_SIZE,
position: index,
aggregateOperation: null,
universalOverrides: null,
applicationUniversalIdentifier,
});
});
if (customFields.length > 0) {
const otherGroupUniversalIdentifier = v4();
flatViewFieldGroupsToCreate.push({
universalIdentifier: otherGroupUniversalIdentifier,
applicationUniversalIdentifier,
viewUniversalIdentifier,
name: 'Other',
position: 1,
isVisible: true,
isActive: true,
overrides: null,
viewFieldUniversalIdentifiers: [],
createdAt,
updatedAt: createdAt,
deletedAt: null,
});
customFields.forEach((field, index) => {
const isVisible =
field.type !== FieldMetadataType.RELATION &&
field.type !== FieldMetadataType.MORPH_RELATION;
flatViewFieldsToCreate.push({
fieldMetadataUniversalIdentifier: field.universalIdentifier,
viewUniversalIdentifier,
viewFieldGroupUniversalIdentifier: otherGroupUniversalIdentifier,
createdAt,
updatedAt: createdAt,
deletedAt: null,
universalIdentifier: v4(),
isVisible,
isActive: true,
size: DEFAULT_VIEW_FIELD_SIZE,
position: index,
aggregateOperation: null,
universalOverrides: null,
applicationUniversalIdentifier,
});
});
}
return {
flatViewFieldGroupsToCreate,
flatViewFieldsToCreate,
};
};