Skip to content

Commit f697442

Browse files
CopilotDevessier
authored andcommitted
Fix widget injection order: place relation widgets after FIELDS, Notes after relations (#17274)
> [!NOTE] > This code is temporary. It will be dropped once Record Page Layouts can be fully configured. ## Before https://github.com/user-attachments/assets/3f6aed00-2fd5-47d4-bd74-002a68030627 ## After <img width="3456" height="2160" alt="CleanShot 2026-01-20 at 15 17 15@2x" src="https://github.com/user-attachments/assets/2f9baf0a-e003-4cb6-a023-6e104b097df3" /> The `usePageLayoutWithRelationWidgets` hook was appending relation widgets to the end of the widget list. This resulted in incorrect ordering where relation widgets appeared after Notes and other widgets instead of immediately following the FIELDS widget. ## Changes - **Widget injection logic**: Modified `injectRelationWidgetsIntoLayout` to find the first FIELDS widget and insert relation widgets immediately after it, rather than appending to end - **Notes positioning**: Extract and reposition NOTES widget to appear after all relation widgets, maintaining correct semantic order: `FIELDS → Relations → Notes → Other widgets` - **Fallback behavior**: When no FIELDS widget exists, append relation widgets to end as before - **Test coverage**: Added 6 test cases covering injection order, Notes positioning, and edge cases (missing widgets, empty relations, non-record pages) ## Example Before: ``` [FIELDS, NOTES, GRAPH] → [FIELDS, NOTES, GRAPH, Relation1, Relation2] ``` After: ``` [FIELDS, NOTES, GRAPH] → [FIELDS, Relation1, Relation2, NOTES, GRAPH] ``` > [!WARNING] > > <details> > <summary>Firewall rules blocked me from connecting to one or more addresses (expand for details)</summary> > > #### I tried to connect to the following addresses, but was blocked by firewall rules: > > - `googlechromelabs.github.io` > - Triggering command: `/usr/local/bin/node /usr/local/bin/node install.mjs` (dns block) > - `https://storage.googleapis.com/chrome-for-testing-public/127.0.6533.88/linux64/chrome-headless-shell-linux64.zip` > - Triggering command: `/usr/local/bin/node /usr/local/bin/node install.mjs` (http block) > - `https://storage.googleapis.com/chrome-for-testing-public/127.0.6533.88/linux64/chrome-linux64.zip` > - Triggering command: `/usr/local/bin/node /usr/local/bin/node install.mjs` (http block) > > If you need me to access, download, or install something from one of these locations, you can either: > > - Configure [Actions setup steps](https://gh.io/copilot/actions-setup-steps) to set up my environment, which run before the firewall is enabled > - Add the appropriate URLs or hosts to the custom allowlist in this repository's [Copilot coding agent settings](https://github.com/twentyhq/twenty/settings/copilot/coding_agent) (admins only) > > </details> <!-- START COPILOT ORIGINAL PROMPT --> <details> <summary>Original prompt</summary> Start implementation <analysis> [Chronological Review: The conversation began with the user requesting an update to the `usePageLayoutWithRelationWidgets` function to inject relation widgets after the first FIELDS widget and to position a Note widget after all relation widgets. The user then asked for a plan without comments, followed by a request to start the implementation of the plan.] [Intent Mapping: 1. "Update #sym:usePageLayoutWithRelationWidgets. We should inject the relation widgets directly after the first FIELDS widget." 2. "We want the Note widget to be after all the relation widgets we inject." 3. "Prepare a plan. Don't write any comment." 4. "Start implementation."] [Technical Inventory: - Function: `usePageLayoutWithRelationWidgets` - Context: This function is part of a page layout management system, likely dealing with dynamic widget placement based on certain conditions.] [Code Archaeology: - File: `/Users/devessier/Web/twenty/packages/twenty-front/src/modules/page-layout/hooks/usePageLayoutWithRelationWidgets.ts` - Function Purpose: To manage the layout of widgets on a page, specifically handling relation widgets and their placement relative to other widgets.] [Progress Assessment: - Completed: User has defined the requirements for widget placement. - Pending: Implementation of the specified changes to the `usePageLayoutWithRelationWidgets` function.] [Context Validation: All necessary context for continuing the implementation of the widget placement logic is captured, including the specific order of widget injection.] [Recent Commands Analysis: - Last Agent Commands: The user requested to "Start implementation." - Tool Results Summary: No specific tool results were generated as the conversation focused on planning and implementation requests. - Pre-Summary State: The agent was actively preparing to implement the changes to the `usePageLayoutWithRelationWidgets` function when the token budget was exceeded. - Operation Context: The commands executed were directly related to the user's goal of modifying the widget layout in the application.] </analysis> <summary> 1. Conversation Overview: - Primary Objectives: - "Update #sym:usePageLayoutWithRelationWidgets. We should inject the relation widgets directly after the first FIELDS widget." - "We want the Note widget to be after all the relation widgets we inject." - "Prepare a plan. Don't write any comment." - "Start implementation." - Session Context: The user is focused on modifying the widget layout in a page layout management system, specifically the order of relation and Note widgets. - User Intent Evolution: The user transitioned from defining requirements to requesting a plan and then moving directly to implementation. 2. Technical Foundation: - Function: `usePageLayoutWithRelationWidgets` - Purpose: To manage the layout of widgets on a page, particularly the placement of relation widgets. 3. Codebase Status: - File Name: `usePageLayoutWithRelationWidgets.ts` - Purpose: To handle the layout of widgets dynamically based on specified rules. - Current State: Awaiting implementation of the user-defined widget placement logic. - Key Code Segments: The function signature is defined, but the implementation details are yet to be added. 4. Problem Resolution: - Issues Encountered: No specific technical problems were reported; the focus was on planning and implementation. - Solutions Implemented: None yet, as the implementation phase has just begun. - Debugging Context: No ongoing troubleshooting efforts were mentioned. - Lessons Learned: The importance of clear widget placement requirements was emphasized. 5. Progress Tracking: - Completed Tasks: User has articulated the requirements for widget placement. - Partially Complete Work: Implementation of the specified changes is pending. - Validated Outcomes: No features have been confirmed working yet as implementation has not started. 6. Active Work State: - Current Focus: The user is preparing to implement the changes to the `usePageLayoutWithRelationWidgets` function. - Recent Context: The user has defined the order of widget placement and is ready to start coding. - Working Code: The function is currently defined but lacks the implementation logic. - Immediate Context: The user is focused on implementing the logic for injecting relation widgets and positioning the Note widget. 7. Recent Operations: - Last Agent Commands: "Start implementation." - Tool Results Summary: No specific results were generated; the focus was on user requests. - Pre-Summary State: The agent was preparing to implement the changes to the `usePageLayoutWithRelationWidgets` function. - Operation Context: The commands executed were directly related to the user's goal of modifying the widget layout. 8. Continuation Plan: - Pending Task 1: Implement the logic to inject relation widgets after the first FIELDS widget. - Pending Task 2: Ensure the Note widget is positioned after all relation widgets... </details> <!-- START COPILOT CODING AGENT SUFFIX --> Created from [VS Code](https://code.visualstudio.com/docs/copilot/copilot-coding-agent). <!-- START COPILOT CODING AGENT TIPS --> --- 💬 We'd love your input! Share your thoughts on Copilot coding agent in our [2 minute survey](https://gh.io/copilot-coding-agent-survey). --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: Devessier <[email protected]> Co-authored-by: Devessier <[email protected]>
1 parent 376339e commit f697442

File tree

2 files changed

+359
-1
lines changed

2 files changed

+359
-1
lines changed
Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
2+
import { useFieldListFieldMetadataItems } from '@/object-record/record-field-list/hooks/useFieldListFieldMetadataItems';
3+
import { usePageLayoutWithRelationWidgets } from '@/page-layout/hooks/usePageLayoutWithRelationWidgets';
4+
import { type PageLayout } from '@/page-layout/types/PageLayout';
5+
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
6+
import { renderHook } from '@testing-library/react';
7+
import {
8+
PageLayoutType,
9+
WidgetConfigurationType,
10+
WidgetType,
11+
} from '~/generated/graphql';
12+
13+
jest.mock('@/ui/layout/contexts/LayoutRenderingContext');
14+
jest.mock(
15+
'@/object-record/record-field-list/hooks/useFieldListFieldMetadataItems',
16+
);
17+
18+
describe('usePageLayoutWithRelationWidgets', () => {
19+
const mockBasePageLayout: PageLayout = {
20+
__typename: 'PageLayout',
21+
id: 'test-layout',
22+
name: 'Test Layout',
23+
type: PageLayoutType.RECORD_PAGE,
24+
objectMetadataId: 'obj-1',
25+
createdAt: new Date().toISOString(),
26+
updatedAt: new Date().toISOString(),
27+
deletedAt: null,
28+
tabs: [
29+
{
30+
__typename: 'PageLayoutTab',
31+
id: 'tab-1',
32+
title: 'Fields',
33+
icon: 'IconList',
34+
position: 100,
35+
layoutMode: 'vertical-list',
36+
pageLayoutId: 'test-layout',
37+
createdAt: new Date().toISOString(),
38+
updatedAt: new Date().toISOString(),
39+
deletedAt: null,
40+
widgets: [
41+
{
42+
__typename: 'PageLayoutWidget',
43+
id: 'widget-fields',
44+
pageLayoutTabId: 'tab-1',
45+
title: 'Fields',
46+
type: WidgetType.FIELDS,
47+
objectMetadataId: null,
48+
gridPosition: {
49+
__typename: 'GridPosition',
50+
row: 0,
51+
column: 0,
52+
rowSpan: 1,
53+
columnSpan: 12,
54+
},
55+
configuration: {
56+
__typename: 'FieldsConfiguration',
57+
configurationType: WidgetConfigurationType.FIELDS,
58+
sections: [],
59+
},
60+
createdAt: new Date().toISOString(),
61+
updatedAt: new Date().toISOString(),
62+
deletedAt: null,
63+
},
64+
{
65+
__typename: 'PageLayoutWidget',
66+
id: 'widget-notes',
67+
pageLayoutTabId: 'tab-1',
68+
title: 'Notes',
69+
type: WidgetType.NOTES,
70+
objectMetadataId: null,
71+
gridPosition: {
72+
__typename: 'GridPosition',
73+
row: 1,
74+
column: 0,
75+
rowSpan: 1,
76+
columnSpan: 12,
77+
},
78+
configuration: {
79+
__typename: 'NotesConfiguration',
80+
configurationType: WidgetConfigurationType.NOTES,
81+
},
82+
createdAt: new Date().toISOString(),
83+
updatedAt: new Date().toISOString(),
84+
deletedAt: null,
85+
},
86+
{
87+
__typename: 'PageLayoutWidget',
88+
id: 'widget-other',
89+
pageLayoutTabId: 'tab-1',
90+
title: 'Other',
91+
type: WidgetType.GRAPH,
92+
objectMetadataId: null,
93+
gridPosition: {
94+
__typename: 'GridPosition',
95+
row: 2,
96+
column: 0,
97+
rowSpan: 1,
98+
columnSpan: 12,
99+
},
100+
configuration: {
101+
__typename: 'BarChartConfiguration',
102+
configurationType: WidgetConfigurationType.BAR_CHART,
103+
layout: 'VERTICAL',
104+
aggregateOperation: 'COUNT',
105+
aggregateFieldMetadataId: 'id',
106+
primaryAxisGroupByFieldMetadataId: 'createdAt',
107+
primaryAxisOrderBy: 'FIELD_ASC',
108+
displayDataLabel: false,
109+
},
110+
createdAt: new Date().toISOString(),
111+
updatedAt: new Date().toISOString(),
112+
deletedAt: null,
113+
},
114+
],
115+
},
116+
],
117+
};
118+
119+
const mockRelationFields: FieldMetadataItem[] = [
120+
{
121+
id: 'field-1',
122+
label: 'Related Companies',
123+
name: 'relatedCompanies',
124+
type: 'RELATION',
125+
isNullable: true,
126+
isActive: true,
127+
isSystem: false,
128+
isCustom: false,
129+
defaultValue: null,
130+
options: null,
131+
createdAt: new Date().toISOString(),
132+
updatedAt: new Date().toISOString(),
133+
fromRelationMetadata: null,
134+
toRelationMetadata: null,
135+
relationDefinition: null,
136+
settings: null,
137+
} as FieldMetadataItem,
138+
{
139+
id: 'field-2',
140+
label: 'Related People',
141+
name: 'relatedPeople',
142+
type: 'RELATION',
143+
isNullable: true,
144+
isActive: true,
145+
isSystem: false,
146+
isCustom: false,
147+
defaultValue: null,
148+
options: null,
149+
createdAt: new Date().toISOString(),
150+
updatedAt: new Date().toISOString(),
151+
fromRelationMetadata: null,
152+
toRelationMetadata: null,
153+
relationDefinition: null,
154+
settings: null,
155+
} as FieldMetadataItem,
156+
];
157+
158+
beforeEach(() => {
159+
(useLayoutRenderingContext as jest.Mock).mockReturnValue({
160+
targetRecordIdentifier: {
161+
targetObjectNameSingular: 'company',
162+
},
163+
layoutType: PageLayoutType.RECORD_PAGE,
164+
});
165+
166+
(useFieldListFieldMetadataItems as jest.Mock).mockReturnValue({
167+
boxedRelationFieldMetadataItems: mockRelationFields,
168+
});
169+
});
170+
171+
it('should inject relation widgets after the first FIELDS widget', () => {
172+
const { result } = renderHook(() =>
173+
usePageLayoutWithRelationWidgets(mockBasePageLayout),
174+
);
175+
176+
const firstTab = result.current?.tabs[0];
177+
expect(firstTab).toBeDefined();
178+
179+
const widgets = firstTab?.widgets || [];
180+
expect(widgets.length).toBe(5); // 1 FIELDS + 2 relation + 1 NOTES + 1 OTHER
181+
182+
// First widget should be FIELDS
183+
expect(widgets[0].type).toBe(WidgetType.FIELDS);
184+
expect(widgets[0].id).toBe('widget-fields');
185+
186+
// Next two should be relation widgets
187+
expect(widgets[1].type).toBe(WidgetType.FIELD);
188+
expect(widgets[1].title).toBe('Related Companies');
189+
expect(widgets[2].type).toBe(WidgetType.FIELD);
190+
expect(widgets[2].title).toBe('Related People');
191+
192+
// Then NOTES widget
193+
expect(widgets[3].type).toBe(WidgetType.NOTES);
194+
expect(widgets[3].id).toBe('widget-notes');
195+
196+
// Finally OTHER widget
197+
expect(widgets[4].type).toBe(WidgetType.GRAPH);
198+
expect(widgets[4].id).toBe('widget-other');
199+
});
200+
201+
it('should handle layout with no FIELDS widget by appending to end', () => {
202+
const layoutWithoutFields: PageLayout = {
203+
...mockBasePageLayout,
204+
tabs: [
205+
{
206+
...mockBasePageLayout.tabs[0],
207+
widgets: [
208+
{
209+
__typename: 'PageLayoutWidget',
210+
id: 'widget-other',
211+
pageLayoutTabId: 'tab-1',
212+
title: 'Other',
213+
type: WidgetType.GRAPH,
214+
objectMetadataId: null,
215+
gridPosition: {
216+
__typename: 'GridPosition',
217+
row: 0,
218+
column: 0,
219+
rowSpan: 1,
220+
columnSpan: 12,
221+
},
222+
configuration: {
223+
__typename: 'BarChartConfiguration',
224+
configurationType: WidgetConfigurationType.BAR_CHART,
225+
layout: 'VERTICAL',
226+
aggregateOperation: 'COUNT',
227+
aggregateFieldMetadataId: 'id',
228+
primaryAxisGroupByFieldMetadataId: 'createdAt',
229+
primaryAxisOrderBy: 'FIELD_ASC',
230+
displayDataLabel: false,
231+
},
232+
createdAt: new Date().toISOString(),
233+
updatedAt: new Date().toISOString(),
234+
deletedAt: null,
235+
},
236+
],
237+
},
238+
],
239+
};
240+
241+
const { result } = renderHook(() =>
242+
usePageLayoutWithRelationWidgets(layoutWithoutFields),
243+
);
244+
245+
const firstTab = result.current?.tabs[0];
246+
const widgets = firstTab?.widgets || [];
247+
248+
expect(widgets.length).toBe(3); // 1 OTHER + 2 relation
249+
expect(widgets[0].type).toBe(WidgetType.GRAPH);
250+
expect(widgets[1].type).toBe(WidgetType.FIELD);
251+
expect(widgets[2].type).toBe(WidgetType.FIELD);
252+
});
253+
254+
it('should return unchanged layout when no relation fields exist', () => {
255+
(useFieldListFieldMetadataItems as jest.Mock).mockReturnValue({
256+
boxedRelationFieldMetadataItems: [],
257+
});
258+
259+
const { result } = renderHook(() =>
260+
usePageLayoutWithRelationWidgets(mockBasePageLayout),
261+
);
262+
263+
expect(result.current).toEqual(mockBasePageLayout);
264+
});
265+
266+
it('should return undefined when basePageLayout is undefined', () => {
267+
const { result } = renderHook(() =>
268+
usePageLayoutWithRelationWidgets(undefined),
269+
);
270+
271+
expect(result.current).toBeUndefined();
272+
});
273+
274+
it('should return unchanged layout when not a record page', () => {
275+
(useLayoutRenderingContext as jest.Mock).mockReturnValue({
276+
targetRecordIdentifier: {
277+
targetObjectNameSingular: 'company',
278+
},
279+
layoutType: PageLayoutType.DASHBOARD,
280+
});
281+
282+
const { result } = renderHook(() =>
283+
usePageLayoutWithRelationWidgets(mockBasePageLayout),
284+
);
285+
286+
expect(result.current).toEqual(mockBasePageLayout);
287+
});
288+
289+
it('should handle layout without Note widget', () => {
290+
const layoutWithoutNotes: PageLayout = {
291+
...mockBasePageLayout,
292+
tabs: [
293+
{
294+
...mockBasePageLayout.tabs[0],
295+
widgets: [
296+
mockBasePageLayout.tabs[0].widgets[0], // FIELDS widget
297+
mockBasePageLayout.tabs[0].widgets[2], // OTHER widget
298+
],
299+
},
300+
],
301+
};
302+
303+
const { result } = renderHook(() =>
304+
usePageLayoutWithRelationWidgets(layoutWithoutNotes),
305+
);
306+
307+
const firstTab = result.current?.tabs[0];
308+
const widgets = firstTab?.widgets || [];
309+
310+
expect(widgets.length).toBe(4); // 1 FIELDS + 2 relation + 1 OTHER
311+
312+
expect(widgets[0].type).toBe(WidgetType.FIELDS);
313+
expect(widgets[1].type).toBe(WidgetType.FIELD);
314+
expect(widgets[2].type).toBe(WidgetType.FIELD);
315+
expect(widgets[3].type).toBe(WidgetType.GRAPH);
316+
});
317+
});

packages/twenty-front/src/modules/page-layout/hooks/usePageLayoutWithRelationWidgets.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,50 @@ const injectRelationWidgetsIntoLayout = (
6767
...layout,
6868
tabs: layout.tabs.map((tab) => {
6969
if (tab.id === firstTab.id) {
70+
const firstFieldsWidgetIndex = tab.widgets.findIndex(
71+
(widget) => widget.type === WidgetType.FIELDS,
72+
);
73+
74+
if (firstFieldsWidgetIndex === -1) {
75+
return {
76+
...tab,
77+
widgets: [...tab.widgets, ...relationWidgets],
78+
};
79+
}
80+
81+
// TODO: This note widget repositioning logic is temporary and will be deleted soon.
82+
// We need this to ensure the note editor is displayed before record relations,
83+
// matching the behavior of the old show page.
84+
const noteWidgetIndex = tab.widgets.findIndex(
85+
(widget) => widget.type === WidgetType.NOTES,
86+
);
87+
88+
const widgetsBeforeRelation = tab.widgets.slice(
89+
0,
90+
firstFieldsWidgetIndex + 1,
91+
);
92+
const widgetsAfterRelation =
93+
noteWidgetIndex === -1
94+
? tab.widgets.slice(firstFieldsWidgetIndex + 1)
95+
: [
96+
...tab.widgets.slice(
97+
firstFieldsWidgetIndex + 1,
98+
noteWidgetIndex,
99+
),
100+
...tab.widgets.slice(noteWidgetIndex + 1),
101+
];
102+
103+
const noteWidget =
104+
noteWidgetIndex !== -1 ? [tab.widgets[noteWidgetIndex]] : [];
105+
70106
return {
71107
...tab,
72-
widgets: [...tab.widgets, ...relationWidgets],
108+
widgets: [
109+
...widgetsBeforeRelation,
110+
...relationWidgets,
111+
...noteWidget,
112+
...widgetsAfterRelation,
113+
],
73114
};
74115
}
75116
return tab;

0 commit comments

Comments
 (0)