Compare commits

...

2 Commits

Author SHA1 Message Date
Weiko 845b9ff6ba fix 2026-04-13 14:08:21 +02:00
Weiko 992ff40ac3 Fix duplicate tabs and widgets 2026-04-12 22:27:30 +02:00
10 changed files with 269 additions and 173 deletions
@@ -0,0 +1,109 @@
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
import { fieldsWidgetEditorModeDraftComponentState } from '@/page-layout/states/fieldsWidgetEditorModeDraftComponentState';
import { fieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/fieldsWidgetGroupsDraftComponentState';
import { fieldsWidgetUngroupedFieldsDraftComponentState } from '@/page-layout/states/fieldsWidgetUngroupedFieldsDraftComponentState';
import { hasInitializedFieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/hasInitializedFieldsWidgetGroupsDraftComponentState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { v4 as uuidv4 } from 'uuid';
export const useCopyFieldsWidgetDraftState = (
pageLayoutIdFromProps: string,
) => {
const pageLayoutId = useAvailableComponentInstanceIdOrThrow(
PageLayoutComponentInstanceContext,
pageLayoutIdFromProps,
);
const fieldsWidgetGroupsDraftState = useAtomComponentStateCallbackState(
fieldsWidgetGroupsDraftComponentState,
pageLayoutId,
);
const fieldsWidgetUngroupedFieldsDraftState =
useAtomComponentStateCallbackState(
fieldsWidgetUngroupedFieldsDraftComponentState,
pageLayoutId,
);
const fieldsWidgetEditorModeDraftState = useAtomComponentStateCallbackState(
fieldsWidgetEditorModeDraftComponentState,
pageLayoutId,
);
const hasInitializedFieldsWidgetGroupsDraftState =
useAtomComponentStateCallbackState(
hasInitializedFieldsWidgetGroupsDraftComponentState,
pageLayoutId,
);
const store = useStore();
const copyFieldsWidgetDraftState = useCallback(
(sourceWidgetId: string, targetWidgetId: string) => {
const groupsDraft = store.get(fieldsWidgetGroupsDraftState);
const ungroupedFieldsDraft = store.get(
fieldsWidgetUngroupedFieldsDraftState,
);
const editorModeDraft = store.get(fieldsWidgetEditorModeDraftState);
let hasCopiedData = false;
if (sourceWidgetId in groupsDraft) {
const sourceGroups = groupsDraft[sourceWidgetId];
const clonedGroups = sourceGroups.map((group) => ({
...group,
id: uuidv4(),
fields: group.fields.map((field) => ({
...field,
viewFieldId: undefined,
})),
}));
store.set(fieldsWidgetGroupsDraftState, (prev) => ({
...prev,
[targetWidgetId]: clonedGroups,
}));
hasCopiedData = true;
}
if (sourceWidgetId in ungroupedFieldsDraft) {
const sourceUngrouped = ungroupedFieldsDraft[sourceWidgetId];
const clonedUngrouped = sourceUngrouped.map((field) => ({
...field,
viewFieldId: undefined,
}));
store.set(fieldsWidgetUngroupedFieldsDraftState, (prev) => ({
...prev,
[targetWidgetId]: clonedUngrouped,
}));
hasCopiedData = true;
}
if (sourceWidgetId in editorModeDraft) {
store.set(fieldsWidgetEditorModeDraftState, (prev) => ({
...prev,
[targetWidgetId]: editorModeDraft[sourceWidgetId],
}));
hasCopiedData = true;
}
if (hasCopiedData) {
store.set(hasInitializedFieldsWidgetGroupsDraftState, (prev) => ({
...prev,
[targetWidgetId]: true,
}));
}
},
[
fieldsWidgetEditorModeDraftState,
fieldsWidgetGroupsDraftState,
fieldsWidgetUngroupedFieldsDraftState,
hasInitializedFieldsWidgetGroupsDraftState,
store,
],
);
return { copyFieldsWidgetDraftState };
};
@@ -13,7 +13,7 @@ export const useCreatePendingFieldsWidgetViews = () => {
const store = useStore();
const createPendingFieldsWidgetViews = useCallback(
async (pageLayoutId: string) => {
async (pageLayoutId: string): Promise<void> => {
const draft = store.get(
pageLayoutDraftComponentState.atomFamily({
instanceId: pageLayoutId,
@@ -35,15 +35,11 @@ export const useCreatePendingFieldsWidgetViews = () => {
const newFieldsWidgets = draft.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);
});
.filter(
(widget) =>
widget.type === WidgetType.FIELDS &&
!persistedWidgetIds.has(widget.id),
);
for (const widget of newFieldsWidgets) {
const viewId = getWidgetConfigurationViewId(widget.configuration);
@@ -1,8 +1,10 @@
import { useCopyFieldsWidgetDraftState } from '@/page-layout/hooks/useCopyFieldsWidgetDraftState';
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 { duplicateWidgetConfigurationWithNewViewId } from '@/page-layout/utils/duplicateWidgetConfigurationWithNewViewId';
import { generateDuplicatedTimestamps } from '@/page-layout/utils/generateDuplicatedTimestamps';
import { sortTabsByPosition } from '@/page-layout/utils/sortTabsByPosition';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
@@ -17,6 +19,7 @@ import { useCallback } from 'react';
import { SidePanelPages } from 'twenty-shared/types';
import { appendCopySuffix, isDefined } from 'twenty-shared/utils';
import { v4 as uuidv4 } from 'uuid';
import { WidgetConfigurationType } from '~/generated-metadata/graphql';
export const useDuplicatePageLayoutTab = ({
pageLayoutId: pageLayoutIdFromProps,
@@ -40,6 +43,9 @@ export const useDuplicatePageLayoutTab = ({
pageLayoutId,
);
const { copyFieldsWidgetDraftState } =
useCopyFieldsWidgetDraftState(pageLayoutId);
const store = useStore();
const setActiveTabId = useSetAtomComponentState(
@@ -79,6 +85,9 @@ export const useDuplicatePageLayoutTab = ({
...widget,
id: newWidgetId,
pageLayoutTabId: newTabId,
configuration: duplicateWidgetConfigurationWithNewViewId(
widget.configuration,
),
...generateDuplicatedTimestamps(),
};
});
@@ -128,6 +137,19 @@ export const useDuplicatePageLayoutTab = ({
tabs: [...prev.tabs, newTab],
});
for (const [oldWidgetId, newWidgetId] of widgetOldIdNewIdMap) {
const sourceWidget = sourceTab.widgets.find(
(widget) => widget.id === oldWidgetId,
);
if (
sourceWidget?.configuration.configurationType ===
WidgetConfigurationType.FIELDS
) {
copyFieldsWidgetDraftState(oldWidgetId, newWidgetId);
}
}
closeSidePanelMenu();
setActiveTabId(newTabId);
@@ -144,6 +166,7 @@ export const useDuplicatePageLayoutTab = ({
},
[
closeSidePanelMenu,
copyFieldsWidgetDraftState,
navigatePageLayoutSidePanel,
pageLayoutCurrentLayouts,
pageLayoutDraft,
@@ -1,9 +1,11 @@
import { useCopyFieldsWidgetDraftState } from '@/page-layout/hooks/useCopyFieldsWidgetDraftState';
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState';
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { addWidgetToTab } from '@/page-layout/utils/addWidgetToTab';
import { duplicateWidgetConfigurationWithNewViewId } from '@/page-layout/utils/duplicateWidgetConfigurationWithNewViewId';
import { generateDuplicatedTimestamps } from '@/page-layout/utils/generateDuplicatedTimestamps';
import { getScrollWrapperInstanceIdFromPageLayoutId } from '@/page-layout/utils/getScrollWrapperInstanceIdFromPageLayoutId';
import { getUpdatedTabLayouts } from '@/page-layout/utils/getUpdatedTabLayouts';
@@ -15,6 +17,7 @@ import { useStore } from 'jotai';
import { useCallback } from 'react';
import { appendCopySuffix, isDefined } from 'twenty-shared/utils';
import { v4 as uuidv4 } from 'uuid';
import { WidgetConfigurationType } from '~/generated-metadata/graphql';
export const useDuplicatePageLayoutWidget = (
pageLayoutIdFromProps?: string,
@@ -39,6 +42,9 @@ export const useDuplicatePageLayoutWidget = (
pageLayoutId,
);
const { copyFieldsWidgetDraftState } =
useCopyFieldsWidgetDraftState(pageLayoutId);
const { getScrollWrapperElement } = useScrollWrapperHTMLElement(
getScrollWrapperInstanceIdFromPageLayoutId(pageLayoutId),
);
@@ -75,6 +81,9 @@ export const useDuplicatePageLayoutWidget = (
...sourceWidget,
id: newWidgetId,
title: appendCopySuffix(sourceWidget.title),
configuration: duplicateWidgetConfigurationWithNewViewId(
sourceWidget.configuration,
),
...generateDuplicatedTimestamps(),
};
@@ -115,6 +124,13 @@ export const useDuplicatePageLayoutWidget = (
tabs: addWidgetToTab(prev.tabs, sourceTab.id, clonedWidget),
}));
if (
sourceWidget.configuration.configurationType ===
WidgetConfigurationType.FIELDS
) {
copyFieldsWidgetDraftState(widgetId, newWidgetId);
}
setPageLayoutEditingWidgetId(newWidgetId);
const { scrollWrapperElement } = getScrollWrapperElement();
@@ -137,6 +153,7 @@ export const useDuplicatePageLayoutWidget = (
return newWidgetId;
},
[
copyFieldsWidgetDraftState,
pageLayoutCurrentLayoutsState,
pageLayoutDraftState,
setPageLayoutEditingWidgetId,
@@ -0,0 +1,16 @@
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { v4 as uuidv4 } from 'uuid';
import { WidgetConfigurationType } from '~/generated-metadata/graphql';
export const duplicateWidgetConfigurationWithNewViewId = (
configuration: PageLayoutWidget['configuration'],
): PageLayoutWidget['configuration'] => {
if (configuration.configurationType === WidgetConfigurationType.FIELDS) {
return {
...configuration,
viewId: uuidv4(),
};
}
return configuration;
};
@@ -5,7 +5,7 @@ import {
type FieldsWidgetGroup,
type FieldsWidgetGroupField,
} from '@/page-layout/widgets/fields/types/FieldsWidgetGroup';
import { buildDefaultFieldsWidgetGroups } from '@/page-layout/widgets/fields/utils/buildDefaultFieldsWidgetGroups';
import { mapViewFieldsToFieldsWidgetGroupFields } from '@/page-layout/widgets/fields/utils/mapViewFieldsToFieldsWidgetGroupFields';
import { useViewById } from '@/views/hooks/useViewById';
import { useMemo } from 'react';
import {
@@ -106,7 +106,7 @@ export const useFieldsWidgetEditorGroupsData = ({
const fields: FieldsWidgetGroupField[] = groupFields
.map((viewField) => {
const fieldMetadataItem = objectMetadataItem.fields.find(
(f) => f.id === viewField.fieldMetadataId,
(field) => field.id === viewField.fieldMetadataId,
);
if (!isDefined(fieldMetadataItem)) {
@@ -137,7 +137,7 @@ export const useFieldsWidgetEditorGroupsData = ({
const lastGroup = groups[groups.length - 1];
const lastFieldPosition =
lastGroup.fields.length > 0
? Math.max(...lastGroup.fields.map((f) => f.position)) + 1
? Math.max(...lastGroup.fields.map((field) => field.position)) + 1
: 0;
const missingFields = buildMissingFields({
@@ -154,38 +154,23 @@ export const useFieldsWidgetEditorGroupsData = ({
}
if (isDefined(view) && view.viewFields.length > 0) {
let globalIndex = 0;
const existingFieldMetadataIds = new Set<string>();
const fields = mapViewFieldsToFieldsWidgetGroupFields({
viewFields: view.viewFields,
objectMetadataFields: objectMetadataItem.fields,
});
const fields = [...view.viewFields]
.sort((a, b) => a.position - b.position)
.map((viewField) => {
const fieldMetadataItem = objectMetadataItem.fields.find(
(f) => f.id === viewField.fieldMetadataId,
);
if (!isDefined(fieldMetadataItem)) {
return null;
}
existingFieldMetadataIds.add(viewField.fieldMetadataId);
return {
fieldMetadataItem,
position: viewField.position,
isVisible: viewField.isVisible,
globalIndex: globalIndex++,
viewFieldId: viewField.id,
};
})
.filter(isDefined);
const existingFieldMetadataIds = new Set(
fields.map((field) => field.fieldMetadataItem.id),
);
const lastFieldPosition =
fields.length > 0 ? Math.max(...fields.map((f) => f.position)) + 1 : 0;
fields.length > 0
? Math.max(...fields.map((field) => field.position)) + 1
: 0;
const missingFields = buildMissingFields({
existingFieldMetadataIds,
startGlobalIndex: globalIndex,
startGlobalIndex: fields.length,
startPosition: lastFieldPosition,
});
@@ -200,27 +185,13 @@ export const useFieldsWidgetEditorGroupsData = ({
}
}
return {
groups: buildDefaultFieldsWidgetGroups({
fields: objectMetadataItem.fields,
objectNameSingular,
labelIdentifierFieldMetadataItemId:
labelIdentifierFieldMetadataItem?.id,
}),
ungroupedFields: [],
editorMode: 'grouped' as const,
};
}, [
objectMetadataItem,
objectNameSingular,
view,
labelIdentifierFieldMetadataItem,
]);
return { groups: [], ungroupedFields: [], editorMode: 'ungrouped' };
}, [objectMetadataItem, view, labelIdentifierFieldMetadataItem]);
return {
...result,
isFromView:
isDefined(view) &&
(isDefined(view.viewFieldGroups) || view.viewFields.length > 0),
(isNonEmptyArray(view.viewFieldGroups) || view.viewFields.length > 0),
};
};
@@ -1,12 +1,10 @@
import { useLabelIdentifierFieldMetadataItem } from '@/object-metadata/hooks/useLabelIdentifierFieldMetadataItem';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { type FieldsWidgetDisplayMode } from '@/page-layout/widgets/fields/types/FieldsWidgetDisplayMode';
import {
type FieldsWidgetGroup,
type FieldsWidgetGroupField,
} from '@/page-layout/widgets/fields/types/FieldsWidgetGroup';
import { buildDefaultFieldsWidgetGroups } from '@/page-layout/widgets/fields/utils/buildDefaultFieldsWidgetGroups';
import { filterDraftGroupsForDisplay } from '@/page-layout/widgets/fields/utils/filterDraftGroupsForDisplay';
import { mapViewFieldsToFieldsWidgetGroupFields } from '@/page-layout/widgets/fields/utils/mapViewFieldsToFieldsWidgetGroupFields';
import { useViewById } from '@/views/hooks/useViewById';
import { useMemo } from 'react';
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
@@ -24,14 +22,13 @@ export const useFieldsWidgetGroups = ({
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const { labelIdentifierFieldMetadataItem } =
useLabelIdentifierFieldMetadataItem({
objectNameSingular,
});
const groups = useMemo<FieldsWidgetGroup[]>(() => {
const { groups, displayMode } = useMemo<{
groups: FieldsWidgetGroup[];
displayMode: FieldsWidgetDisplayMode;
}>(() => {
if (!isDefined(objectMetadataItem)) {
return [];
return { groups: [], displayMode: 'inline' };
}
if (isDefined(view) && isNonEmptyArray(view.viewFieldGroups)) {
@@ -41,7 +38,7 @@ export const useFieldsWidgetGroups = ({
let globalIndex = 0;
return sortedGroups
const groups = sortedGroups
.filter((group) => group.isVisible)
.map((group) => {
const groupFields = [...(group.viewFields ?? [])].sort(
@@ -52,21 +49,19 @@ export const useFieldsWidgetGroups = ({
.filter((field) => field.isVisible)
.map((viewField) => {
const fieldMetadataItem = objectMetadataItem.fields.find(
(f) => f.id === viewField.fieldMetadataId,
(field) => field.id === viewField.fieldMetadataId,
);
if (!isDefined(fieldMetadataItem)) {
return null;
}
const field: FieldsWidgetGroupField = {
return {
fieldMetadataItem,
position: viewField.position,
isVisible: viewField.isVisible,
globalIndex: globalIndex++,
};
return field;
})
.filter(isDefined);
@@ -79,28 +74,44 @@ export const useFieldsWidgetGroups = ({
};
})
.filter((group) => group.fields.length > 0);
return { groups, displayMode: 'grouped' };
}
return filterDraftGroupsForDisplay(
buildDefaultFieldsWidgetGroups({
fields: objectMetadataItem.fields,
objectNameSingular,
labelIdentifierFieldMetadataItemId:
labelIdentifierFieldMetadataItem?.id,
}),
);
}, [
objectMetadataItem,
objectNameSingular,
labelIdentifierFieldMetadataItem,
view,
]);
if (isDefined(view) && view.viewFields.length > 0) {
const allFields = mapViewFieldsToFieldsWidgetGroupFields({
viewFields: view.viewFields,
objectMetadataFields: objectMetadataItem.fields,
});
const displayMode: FieldsWidgetDisplayMode = 'grouped';
const visibleFields = allFields.filter((field) => field.isVisible);
if (visibleFields.length === 0) {
return { groups: [], displayMode: 'inline' };
}
return {
groups: [
{
id: `${viewId}-ungrouped`,
name: '',
position: 0,
isVisible: true,
fields: visibleFields,
},
],
displayMode: 'inline',
};
}
return { groups: [], displayMode: 'inline' };
}, [objectMetadataItem, view, viewId]);
return {
groups,
displayMode,
isFromView: isDefined(view) && isNonEmptyArray(view.viewFieldGroups),
isFromView:
isDefined(view) &&
(isNonEmptyArray(view.viewFieldGroups) || view.viewFields.length > 0),
};
};
@@ -4,6 +4,7 @@ import {
type FieldsWidgetGroupField,
} from '@/page-layout/widgets/fields/types/FieldsWidgetGroup';
import { getHiddenFieldsFromGroups } from '@/page-layout/widgets/fields/utils/getHiddenFieldsFromGroups';
import { mapViewFieldsToFieldsWidgetGroupFields } from '@/page-layout/widgets/fields/utils/mapViewFieldsToFieldsWidgetGroupFields';
import { useViewById } from '@/views/hooks/useViewById';
import { useMemo } from 'react';
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
@@ -61,28 +62,10 @@ export const useFieldsWidgetHiddenFields = ({
}
if (isDefined(view) && view.viewFields.length > 0) {
let globalIndex = 0;
return [...view.viewFields]
.sort((a, b) => a.position - b.position)
.filter((viewField) => !viewField.isVisible)
.map((viewField) => {
const fieldMetadataItem = objectMetadataItem.fields.find(
(f) => f.id === viewField.fieldMetadataId,
);
if (!isDefined(fieldMetadataItem)) {
return null;
}
return {
fieldMetadataItem,
position: viewField.position,
isVisible: false,
globalIndex: globalIndex++,
};
})
.filter(isDefined);
return mapViewFieldsToFieldsWidgetGroupFields({
viewFields: view.viewFields,
objectMetadataFields: objectMetadataItem.fields,
}).filter((field) => !field.isVisible);
}
return [];
@@ -1,65 +0,0 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type FieldsWidgetGroup } from '@/page-layout/widgets/fields/types/FieldsWidgetGroup';
import { FieldMetadataType } from 'twenty-shared/types';
import { isFieldMetadataEligibleForFieldsWidget } from 'twenty-shared/utils';
import { v4 as uuidv4 } from 'uuid';
export const buildDefaultFieldsWidgetGroups = ({
fields,
objectNameSingular,
labelIdentifierFieldMetadataItemId,
}: {
fields: FieldMetadataItem[];
objectNameSingular: string;
labelIdentifierFieldMetadataItemId: string | undefined;
}): FieldsWidgetGroup[] => {
const eligibleFields = fields.filter((field) =>
isFieldMetadataEligibleForFieldsWidget({
fieldName: field.name,
fieldType: field.type,
isLabelIdentifierField: field.id === labelIdentifierFieldMetadataItemId,
}),
);
const standardFields = eligibleFields.filter((field) => !field.isCustom);
const customFields = eligibleFields.filter((field) => field.isCustom);
const isFieldVisible = (fieldType: FieldMetadataType) =>
fieldType !== FieldMetadataType.RELATION &&
fieldType !== FieldMetadataType.MORPH_RELATION;
const groups: FieldsWidgetGroup[] = [];
let globalIndex = 0;
if (standardFields.length > 0) {
groups.push({
id: uuidv4(),
name: 'General',
position: 0,
isVisible: true,
fields: standardFields.map((field, index) => ({
fieldMetadataItem: field,
position: index,
isVisible: isFieldVisible(field.type),
globalIndex: globalIndex++,
})),
});
}
if (customFields.length > 0) {
groups.push({
id: uuidv4(),
name: 'Other',
position: 1,
isVisible: true,
fields: customFields.map((field, index) => ({
fieldMetadataItem: field,
position: index,
isVisible: isFieldVisible(field.type),
globalIndex: globalIndex++,
})),
});
}
return groups;
};
@@ -0,0 +1,35 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type FieldsWidgetGroupField } from '@/page-layout/widgets/fields/types/FieldsWidgetGroup';
import { type ViewField } from '@/views/types/ViewField';
import { isDefined } from 'twenty-shared/utils';
export const mapViewFieldsToFieldsWidgetGroupFields = ({
viewFields,
objectMetadataFields,
}: {
viewFields: ViewField[];
objectMetadataFields: FieldMetadataItem[];
}): FieldsWidgetGroupField[] => {
let globalIndex = 0;
return [...viewFields]
.sort((a, b) => a.position - b.position)
.map((viewField) => {
const fieldMetadataItem = objectMetadataFields.find(
(field) => field.id === viewField.fieldMetadataId,
);
if (!isDefined(fieldMetadataItem)) {
return null;
}
return {
fieldMetadataItem,
position: viewField.position,
isVisible: viewField.isVisible,
globalIndex: globalIndex++,
viewFieldId: viewField.id,
};
})
.filter(isDefined);
};