Various bug fixes Record page layouts (#19719)

Fixes:

- Can't add multiple widgets in a row
- Ensure newly created is always focused
This commit is contained in:
Baptiste Devessier
2026-04-15 17:12:57 +02:00
committed by GitHub
parent 5e83ad43de
commit 8cb803cedf
12 changed files with 393 additions and 41 deletions
@@ -1,15 +1,48 @@
import { usePageLayoutContentContext } from '@/page-layout/contexts/PageLayoutContentContext';
import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState';
import { widgetCreationTargetTabIdComponentState } from '@/page-layout/states/widgetCreationTargetTabIdComponentState';
import { widgetInsertionContextComponentState } from '@/page-layout/states/widgetInsertionContextComponentState';
import { useNavigatePageLayoutSidePanel } from '@/side-panel/pages/page-layout/hooks/useNavigatePageLayoutSidePanel';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { SidePanelPages } from 'twenty-shared/types';
export const useNavigateToMoreWidgets = () => {
const { tabId } = usePageLayoutContentContext();
const { navigatePageLayoutSidePanel } = useNavigatePageLayoutSidePanel();
const pageLayoutEditingWidgetIdState = useAtomComponentStateCallbackState(
pageLayoutEditingWidgetIdComponentState,
);
const widgetCreationTargetTabIdState = useAtomComponentStateCallbackState(
widgetCreationTargetTabIdComponentState,
);
const widgetInsertionContextState = useAtomComponentStateCallbackState(
widgetInsertionContextComponentState,
);
const store = useStore();
const navigateToMoreWidgets = useCallback(() => {
store.set(pageLayoutEditingWidgetIdState, null);
store.set(widgetInsertionContextState, null);
store.set(widgetCreationTargetTabIdState, tabId);
navigatePageLayoutSidePanel({
sidePanelPage: SidePanelPages.PageLayoutRecordPageWidgetTypeSelect,
});
}, [navigatePageLayoutSidePanel]);
}, [
navigatePageLayoutSidePanel,
pageLayoutEditingWidgetIdState,
store,
tabId,
widgetCreationTargetTabIdState,
widgetInsertionContextState,
]);
return { navigateToMoreWidgets };
};
@@ -7,10 +7,12 @@ import { type PageLayoutAddTabStrategy } from '@/page-layout/types/PageLayoutAdd
import { isReactivatableTab } from '@/page-layout/utils/isReactivatableTab';
import { shouldEnableTabEditingFeatures } from '@/page-layout/utils/shouldEnableTabEditingFeatures';
import { useNavigatePageLayoutSidePanel } from '@/side-panel/pages/page-layout/hooks/useNavigatePageLayoutSidePanel';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { t } from '@lingui/core/macro';
import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { SidePanelPages } from 'twenty-shared/types';
import { FeatureFlagKey, PageLayoutType } from '~/generated-metadata/graphql';
@@ -40,8 +42,17 @@ export const usePageLayoutAddTabStrategy = ({
const { navigatePageLayoutSidePanel } = useNavigatePageLayoutSidePanel();
const { isInSidePanel } = useLayoutRenderingContext();
const navigate = useNavigate();
const onCreate = useCallback(() => {
const newTabId = createPageLayoutTab(t`Untitled`);
if (!isInSidePanel) {
navigate(`#${newTabId}`);
}
setPageLayoutTabSettingsOpenTabId(newTabId);
navigatePageLayoutSidePanel({
sidePanelPage: SidePanelPages.PageLayoutTabSettings,
@@ -49,6 +60,8 @@ export const usePageLayoutAddTabStrategy = ({
});
}, [
createPageLayoutTab,
isInSidePanel,
navigate,
setPageLayoutTabSettingsOpenTabId,
navigatePageLayoutSidePanel,
]);
@@ -0,0 +1,11 @@
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
import { PageLayoutComponentInstanceContext } from './contexts/PageLayoutComponentInstanceContext';
export const widgetCreationTargetTabIdComponentState = createAtomComponentState<
string | null
>({
key: 'widgetCreationTargetTabIdComponentState',
defaultValue: null,
componentInstanceContext: PageLayoutComponentInstanceContext,
});
@@ -0,0 +1,59 @@
import { createDefaultFieldWidget } from '@/page-layout/utils/createDefaultFieldWidget';
import {
FieldDisplayMode,
PageLayoutTabLayoutMode,
WidgetConfigurationType,
WidgetType,
} from '~/generated-metadata/graphql';
describe('createDefaultFieldWidget', () => {
it('should return a FIELD widget with CARD display mode by default', () => {
const widget = createDefaultFieldWidget({
id: 'widget-1',
pageLayoutTabId: 'tab-1',
title: 'Company Name',
fieldMetadataId: 'field-1',
objectMetadataId: 'object-1',
positionIndex: 0,
});
expect(widget).toMatchObject({
__typename: 'PageLayoutWidget',
id: 'widget-1',
pageLayoutTabId: 'tab-1',
title: 'Company Name',
isActive: true,
type: WidgetType.FIELD,
configuration: {
__typename: 'FieldConfiguration',
configurationType: WidgetConfigurationType.FIELD,
fieldMetadataId: 'field-1',
fieldDisplayMode: FieldDisplayMode.CARD,
},
position: {
__typename: 'PageLayoutWidgetVerticalListPosition',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
index: 0,
},
objectMetadataId: 'object-1',
deletedAt: null,
});
});
it('should use the provided fieldDisplayMode', () => {
const widget = createDefaultFieldWidget({
id: 'widget-2',
pageLayoutTabId: 'tab-1',
title: 'Description',
fieldMetadataId: 'field-2',
fieldDisplayMode: FieldDisplayMode.EDITOR,
objectMetadataId: 'object-1',
positionIndex: 1,
});
expect(widget.configuration).toMatchObject({
fieldDisplayMode: FieldDisplayMode.EDITOR,
});
expect(widget.position).toMatchObject({ index: 1 });
});
});
@@ -0,0 +1,46 @@
import { createDefaultFieldsWidget } from '@/page-layout/utils/createDefaultFieldsWidget';
import {
PageLayoutTabLayoutMode,
WidgetConfigurationType,
WidgetType,
} from '~/generated-metadata/graphql';
describe('createDefaultFieldsWidget', () => {
it('should return a FIELDS widget with correct properties', () => {
const widget = createDefaultFieldsWidget({
id: 'widget-1',
pageLayoutTabId: 'tab-1',
viewId: 'view-1',
objectMetadataId: 'object-1',
positionIndex: 3,
});
expect(widget).toMatchObject({
__typename: 'PageLayoutWidget',
id: 'widget-1',
pageLayoutTabId: 'tab-1',
title: 'Fields',
isActive: true,
type: WidgetType.FIELDS,
configuration: {
__typename: 'FieldsConfiguration',
configurationType: WidgetConfigurationType.FIELDS,
viewId: 'view-1',
},
gridPosition: {
__typename: 'GridPosition',
row: 0,
column: 0,
rowSpan: 1,
columnSpan: 12,
},
position: {
__typename: 'PageLayoutWidgetVerticalListPosition',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
index: 3,
},
objectMetadataId: 'object-1',
deletedAt: null,
});
});
});
@@ -0,0 +1,52 @@
import { createDefaultFrontComponentWidget } from '@/page-layout/utils/createDefaultFrontComponentWidget';
import {
type GridPosition,
PageLayoutTabLayoutMode,
WidgetConfigurationType,
WidgetType,
} from '~/generated-metadata/graphql';
describe('createDefaultFrontComponentWidget', () => {
it('should return a FRONT_COMPONENT widget with grid position mapped to position', () => {
const gridPosition: GridPosition = {
__typename: 'GridPosition',
row: 2,
column: 3,
rowSpan: 4,
columnSpan: 6,
};
const widget = createDefaultFrontComponentWidget(
'widget-1',
'tab-1',
'My Component',
'front-comp-1',
gridPosition,
);
expect(widget).toMatchObject({
__typename: 'PageLayoutWidget',
id: 'widget-1',
pageLayoutTabId: 'tab-1',
title: 'My Component',
isActive: true,
type: WidgetType.FRONT_COMPONENT,
configuration: {
__typename: 'FrontComponentConfiguration',
configurationType: WidgetConfigurationType.FRONT_COMPONENT,
frontComponentId: 'front-comp-1',
},
gridPosition,
position: {
__typename: 'PageLayoutWidgetGridPosition',
layoutMode: PageLayoutTabLayoutMode.GRID,
row: 2,
column: 3,
rowSpan: 4,
columnSpan: 6,
},
objectMetadataId: null,
deletedAt: null,
});
});
});
@@ -0,0 +1,51 @@
import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab';
import { isReactivatableTab } from '@/page-layout/utils/isReactivatableTab';
const makeTab = (overrides: Partial<PageLayoutTab> = {}): PageLayoutTab =>
({
id: 'tab-1',
applicationId: 'app-1',
title: 'Tab',
isActive: true,
position: 0,
pageLayoutId: '',
widgets: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
...overrides,
}) as unknown as PageLayoutTab;
describe('isReactivatableTab', () => {
it('should return true when tab is inactive and applicationId matches', () => {
const tab = makeTab({ isActive: false, applicationId: 'app-1' });
expect(isReactivatableTab({ tab, objectApplicationId: 'app-1' })).toBe(
true,
);
});
it('should return false when tab is active', () => {
const tab = makeTab({ isActive: true, applicationId: 'app-1' });
expect(isReactivatableTab({ tab, objectApplicationId: 'app-1' })).toBe(
false,
);
});
it('should return false when applicationId does not match', () => {
const tab = makeTab({ isActive: false, applicationId: 'app-1' });
expect(isReactivatableTab({ tab, objectApplicationId: 'app-2' })).toBe(
false,
);
});
it('should return false when objectApplicationId is undefined', () => {
const tab = makeTab({ isActive: false, applicationId: 'app-1' });
expect(isReactivatableTab({ tab, objectApplicationId: undefined })).toBe(
false,
);
});
});
@@ -36,6 +36,7 @@ const StyledHeader = styled.div`
`;
const StyledMenuItemList = styled.div`
background-color: ${themeCssVariables.background.secondary};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.md};
margin-top: ${themeCssVariables.spacing[2]};
@@ -4,12 +4,12 @@ import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadata
import { useInsertCreatedWidgetAtContext } from '@/page-layout/hooks/useInsertCreatedWidgetAtContext';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState';
import { widgetCreationTargetTabIdComponentState } from '@/page-layout/states/widgetCreationTargetTabIdComponentState';
import { widgetInsertionContextComponentState } from '@/page-layout/states/widgetInsertionContextComponentState';
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { addWidgetToTab } from '@/page-layout/utils/addWidgetToTab';
import { createDefaultFieldWidget } from '@/page-layout/utils/createDefaultFieldWidget';
import { createDefaultFieldsWidget } from '@/page-layout/utils/createDefaultFieldsWidget';
import { getTabListInstanceIdFromPageLayoutAndRecord } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutAndRecord';
import { isVerticalListPosition } from '@/page-layout/utils/isVerticalListPosition';
import { removeWidgetFromTab } from '@/page-layout/utils/removeWidgetFromTab';
import { useFieldWidgetEligibleFields } from '@/page-layout/widgets/field/hooks/useFieldWidgetEligibleFields';
@@ -20,8 +20,8 @@ import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { useNavigatePageLayoutSidePanel } from '@/side-panel/pages/page-layout/hooks/useNavigatePageLayoutSidePanel';
import { usePageLayoutIdFromContextStore } from '@/side-panel/pages/page-layout/hooks/usePageLayoutIdFromContextStore';
import { getFrontComponentWidgetTypeSelectItemId } from '@/side-panel/pages/page-layout/utils/getFrontComponentWidgetTypeSelectItemId';
import { resolveWidgetTypeSelectTargetTabId } from '@/side-panel/pages/page-layout/utils/resolveWidgetTypeSelectTargetTabId';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
@@ -41,11 +41,8 @@ import {
} from '~/generated-metadata/graphql';
export const SidePanelPageLayoutRecordPageWidgetTypeSelect = () => {
const {
pageLayoutId,
recordId,
objectNameSingular: targetObjectNameSingular,
} = usePageLayoutIdFromContextStore();
const { pageLayoutId, objectNameSingular: targetObjectNameSingular } =
usePageLayoutIdFromContextStore();
const { closeSidePanelMenu } = useSidePanelMenu();
@@ -72,15 +69,9 @@ export const SidePanelPageLayoutRecordPageWidgetTypeSelect = () => {
pageLayoutId,
);
const tabListInstanceId = getTabListInstanceIdFromPageLayoutAndRecord({
const widgetCreationTargetTabId = useAtomComponentStateValue(
widgetCreationTargetTabIdComponentState,
pageLayoutId,
layoutType: pageLayoutDraft.type,
targetRecordIdentifier: { id: recordId, targetObjectNameSingular: '' },
});
const activeTabId = useAtomComponentStateValue(
activeTabIdComponentState,
tabListInstanceId,
);
const store = useStore();
@@ -96,13 +87,11 @@ export const SidePanelPageLayoutRecordPageWidgetTypeSelect = () => {
targetObjectNameSingular,
);
const editingWidgetTab = isDefined(pageLayoutEditingWidgetId)
? pageLayoutDraft.tabs.find((tab) =>
tab.widgets.some((widget) => widget.id === pageLayoutEditingWidgetId),
)
: undefined;
const tabId = editingWidgetTab?.id ?? activeTabId;
const tabId = resolveWidgetTypeSelectTargetTabId({
pageLayoutEditingWidgetId,
tabs: pageLayoutDraft.tabs,
widgetCreationTargetTabId,
});
const isReplaceMode =
isDefined(pageLayoutEditingWidgetId) && !isDefined(widgetInsertionContext);
@@ -126,11 +115,7 @@ export const SidePanelPageLayoutRecordPageWidgetTypeSelect = () => {
}, [existingWidget]);
const removeExistingWidgetIfReplacing = useCallback(() => {
if (
!isReplaceMode ||
!isDefined(pageLayoutEditingWidgetId) ||
!isDefined(tabId)
) {
if (!isReplaceMode || !isDefined(pageLayoutEditingWidgetId)) {
return;
}
@@ -160,10 +145,6 @@ export const SidePanelPageLayoutRecordPageWidgetTypeSelect = () => {
);
const handleCreateFieldsWidget = useCallback(() => {
if (!isDefined(tabId)) {
return;
}
const replacePositionIndex = getExistingWidgetPositionIndex();
const viewId = uuidv4();
@@ -210,10 +191,6 @@ export const SidePanelPageLayoutRecordPageWidgetTypeSelect = () => {
]);
const handleCreateFieldWidget = useCallback(() => {
if (!isDefined(tabId)) {
return;
}
const replacePositionIndex = getExistingWidgetPositionIndex();
removeExistingWidgetIfReplacing();
@@ -287,10 +264,6 @@ export const SidePanelPageLayoutRecordPageWidgetTypeSelect = () => {
const handleCreateFrontComponentWidget = useCallback(
(frontComponent: FrontComponent) => {
if (!isDefined(tabId)) {
return;
}
const replacePositionIndex = getExistingWidgetPositionIndex();
removeExistingWidgetIfReplacing();
@@ -14,6 +14,7 @@ import { RegularTabSettingsContent } from '@/side-panel/pages/page-layout/compon
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useNavigate } from 'react-router-dom';
import { isDefined } from 'twenty-shared/utils';
import {
PageLayoutTabLayoutMode,
@@ -31,6 +32,8 @@ export const SidePanelPageLayoutTabSettingsContent = ({
}: SidePanelPageLayoutTabSettingsContentProps) => {
const { closeSidePanelMenu } = useSidePanelMenu();
const navigate = useNavigate();
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const pageLayoutDraft = useAtomComponentStateValue(
@@ -129,7 +132,10 @@ export const SidePanelPageLayoutTabSettingsContent = ({
onMoveLeft={() => moveLeft(tab.id)}
onMoveRight={() => moveRight(tab.id)}
onSetAsPinned={() => setAsPinnedTab(tab.id)}
onDuplicate={() => duplicateTab(tab.id)}
onDuplicate={() => {
const newTabId = duplicateTab(tab.id);
navigate(`#${newTabId}`);
}}
onResetToDefault={handleResetToDefault}
onDelete={handleDelete}
/>
@@ -0,0 +1,73 @@
import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab';
import { resolveWidgetTypeSelectTargetTabId } from '@/side-panel/pages/page-layout/utils/resolveWidgetTypeSelectTargetTabId';
import { PageLayoutTabLayoutMode } from '~/generated-metadata/graphql';
const makeTab = (id: string, widgetIds: string[] = []): PageLayoutTab =>
({
id,
applicationId: '',
title: id,
isActive: true,
position: 0,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
pageLayoutId: '',
widgets: widgetIds.map((wId) => ({ id: wId })),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
}) as unknown as PageLayoutTab;
describe('resolveWidgetTypeSelectTargetTabId', () => {
it('should return the tab containing the editing widget', () => {
const tabs = [
makeTab('tab-1', ['widget-a']),
makeTab('tab-2', ['widget-b']),
];
const result = resolveWidgetTypeSelectTargetTabId({
pageLayoutEditingWidgetId: 'widget-b',
tabs,
widgetCreationTargetTabId: null,
});
expect(result).toBe('tab-2');
});
it('should throw when the editing widget is not found in any tab', () => {
const tabs = [makeTab('tab-1', ['widget-a'])];
expect(() =>
resolveWidgetTypeSelectTargetTabId({
pageLayoutEditingWidgetId: 'non-existent',
tabs,
widgetCreationTargetTabId: null,
}),
).toThrow('Cannot find tab containing editing widget non-existent');
});
it('should return widgetCreationTargetTabId when no editing widget is set', () => {
const tabs = [makeTab('tab-1', ['widget-a'])];
const result = resolveWidgetTypeSelectTargetTabId({
pageLayoutEditingWidgetId: null,
tabs,
widgetCreationTargetTabId: 'tab-1',
});
expect(result).toBe('tab-1');
});
it('should throw when both pageLayoutEditingWidgetId and widgetCreationTargetTabId are null', () => {
const tabs = [makeTab('tab-1')];
expect(() =>
resolveWidgetTypeSelectTargetTabId({
pageLayoutEditingWidgetId: null,
tabs,
widgetCreationTargetTabId: null,
}),
).toThrow(
'widgetCreationTargetTabId must be set when navigating to widget type select without an editing widget',
);
});
});
@@ -0,0 +1,34 @@
import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab';
import { isDefined } from 'twenty-shared/utils';
export const resolveWidgetTypeSelectTargetTabId = ({
pageLayoutEditingWidgetId,
tabs,
widgetCreationTargetTabId,
}: {
pageLayoutEditingWidgetId: string | null;
tabs: PageLayoutTab[];
widgetCreationTargetTabId: string | null;
}): string => {
if (isDefined(pageLayoutEditingWidgetId)) {
const editingWidgetTab = tabs.find((tab) =>
tab.widgets.some((widget) => widget.id === pageLayoutEditingWidgetId),
);
if (!isDefined(editingWidgetTab)) {
throw new Error(
`Cannot find tab containing editing widget ${pageLayoutEditingWidgetId}`,
);
}
return editingWidgetTab.id;
}
if (!isDefined(widgetCreationTargetTabId)) {
throw new Error(
'widgetCreationTargetTabId must be set when navigating to widget type select without an editing widget',
);
}
return widgetCreationTargetTabId;
};