Skip to content

Commit 784c005

Browse files
feat(ui) Add drag and drop functionality to modules on home page (#14100)
1 parent f7894f1 commit 784c005

26 files changed

+3648
-1907
lines changed

datahub-web-react/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
"@ant-design/colors": "^5.0.0",
1010
"@ant-design/icons": "^4.3.0",
1111
"@apollo/client": "^3.3.19",
12+
"@dnd-kit/core": "^6.3.1",
13+
"@dnd-kit/sortable": "^10.0.0",
14+
"@dnd-kit/utilities": "^3.2.2",
1215
"@fontsource/mulish": "^5.0.16",
1316
"@geometricpanda/storybook-addon-badges": "^2.0.2",
1417
"@graphql-codegen/fragment-matcher": "^5.0.0",

datahub-web-react/src/app/homeV3/context/PageTemplateContext.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export const PageTemplateProvider = ({
3838
const moduleModalState = useModuleModalState();
3939

4040
// Module operations
41-
const { addModule, removeModule, upsertModule } = useModuleOperations(
41+
const { addModule, removeModule, upsertModule, moveModule } = useModuleOperations(
4242
isEditingGlobalTemplate,
4343
personalTemplate,
4444
globalTemplate,
@@ -64,6 +64,7 @@ export const PageTemplateProvider = ({
6464
removeModule,
6565
upsertModule,
6666
moduleModalState,
67+
moveModule,
6768
}),
6869
[
6970
personalTemplate,
@@ -78,6 +79,7 @@ export const PageTemplateProvider = ({
7879
removeModule,
7980
upsertModule,
8081
moduleModalState,
82+
moveModule,
8183
],
8284
);
8385

datahub-web-react/src/app/homeV3/context/__tests__/PageTemplateContext.test.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ const mockSetTemplate = vi.fn();
8888
const mockAddModule = vi.fn();
8989
const mockRemoveModule = vi.fn();
9090
const mockUpsertModule = vi.fn();
91+
const mockMoveModule = vi.fn();
9192
const mockUpdateTemplateWithModule = vi.fn();
9293
const mockRemoveModuleFromTemplate = vi.fn();
9394
const mockUpsertTemplate = vi.fn();
@@ -118,6 +119,7 @@ describe('PageTemplateContext', () => {
118119
addModule: mockAddModule,
119120
removeModule: mockRemoveModule,
120121
upsertModule: mockUpsertModule,
122+
moveModule: mockMoveModule,
121123
});
122124
});
123125

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import {
4+
calculateAdjustedRowIndex,
5+
insertModuleIntoRows,
6+
removeModuleFromRows,
7+
validateModuleMoveConstraints,
8+
} from '@app/homeV3/context/hooks/utils/moduleOperationsUtils';
9+
import { ModulePositionInput } from '@app/homeV3/template/types';
10+
11+
import { PageModuleFragment, PageTemplateFragment } from '@graphql/template.generated';
12+
import { DataHubPageModuleType, EntityType, PageModuleScope, PageTemplateScope, PageTemplateSurfaceType } from '@types';
13+
14+
// Mock data helpers
15+
const createMockModule = (name: string, urn: string): PageModuleFragment => ({
16+
urn,
17+
type: EntityType.DatahubPageModule,
18+
properties: {
19+
name,
20+
type: DataHubPageModuleType.OwnedAssets,
21+
visibility: { scope: PageModuleScope.Personal },
22+
params: {},
23+
},
24+
});
25+
26+
const createMockTemplate = (rows: any[]): PageTemplateFragment => ({
27+
urn: 'urn:li:pageTemplate:test',
28+
type: EntityType.DatahubPageTemplate,
29+
properties: {
30+
rows,
31+
surface: { surfaceType: PageTemplateSurfaceType.HomePage },
32+
visibility: { scope: PageTemplateScope.Personal },
33+
},
34+
});
35+
36+
describe('Module Operations Utility Functions', () => {
37+
describe('removeModuleFromRows', () => {
38+
const module1 = createMockModule('Module 1', 'urn:li:module:1');
39+
const module2 = createMockModule('Module 2', 'urn:li:module:2');
40+
const module3 = createMockModule('Module 3', 'urn:li:module:3');
41+
42+
it('should remove module and keep row when other modules exist', () => {
43+
const rows = [{ modules: [module1, module2, module3] }];
44+
const position: ModulePositionInput = { rowIndex: 0, moduleIndex: 1 };
45+
46+
const result = removeModuleFromRows(rows, position);
47+
48+
expect(result.wasRowRemoved).toBe(false);
49+
expect(result.updatedRows).toHaveLength(1);
50+
expect(result.updatedRows[0].modules).toHaveLength(2);
51+
expect(result.updatedRows[0].modules).toEqual([module1, module3]);
52+
});
53+
54+
it('should remove entire row when removing last module', () => {
55+
const rows = [{ modules: [module1] }, { modules: [module2, module3] }];
56+
const position: ModulePositionInput = { rowIndex: 0, moduleIndex: 0 };
57+
58+
const result = removeModuleFromRows(rows, position);
59+
60+
expect(result.wasRowRemoved).toBe(true);
61+
expect(result.updatedRows).toHaveLength(1);
62+
expect(result.updatedRows[0].modules).toEqual([module2, module3]);
63+
});
64+
65+
it('should handle invalid positions gracefully', () => {
66+
const rows = [{ modules: [module1] }];
67+
const position: ModulePositionInput = { rowIndex: 5, moduleIndex: 0 };
68+
69+
const result = removeModuleFromRows(rows, position);
70+
71+
expect(result.wasRowRemoved).toBe(false);
72+
expect(result.updatedRows).toEqual(rows);
73+
});
74+
75+
it('should handle undefined positions gracefully', () => {
76+
const rows = [{ modules: [module1] }];
77+
const position: ModulePositionInput = { rowIndex: undefined, moduleIndex: 0 };
78+
79+
const result = removeModuleFromRows(rows, position);
80+
81+
expect(result.wasRowRemoved).toBe(false);
82+
expect(result.updatedRows).toEqual(rows);
83+
});
84+
85+
it('should handle null rows gracefully', () => {
86+
const rows = null as any;
87+
const position: ModulePositionInput = { rowIndex: 0, moduleIndex: 0 };
88+
89+
const result = removeModuleFromRows(rows, position);
90+
91+
expect(result.wasRowRemoved).toBe(false);
92+
expect(result.updatedRows).toEqual(rows);
93+
});
94+
});
95+
96+
describe('calculateAdjustedRowIndex', () => {
97+
it('should not adjust when no row was removed', () => {
98+
const fromPosition: ModulePositionInput = { rowIndex: 0, moduleIndex: 0 };
99+
const toRowIndex = 2;
100+
const wasRowRemoved = false;
101+
102+
const result = calculateAdjustedRowIndex(fromPosition, toRowIndex, wasRowRemoved);
103+
104+
expect(result).toBe(2);
105+
});
106+
107+
it('should adjust when row was removed before target', () => {
108+
const fromPosition: ModulePositionInput = { rowIndex: 0, moduleIndex: 0 };
109+
const toRowIndex = 2;
110+
const wasRowRemoved = true;
111+
112+
const result = calculateAdjustedRowIndex(fromPosition, toRowIndex, wasRowRemoved);
113+
114+
expect(result).toBe(1); // 2 - 1 = 1
115+
});
116+
117+
it('should not adjust when removed row is after target', () => {
118+
const fromPosition: ModulePositionInput = { rowIndex: 2, moduleIndex: 0 };
119+
const toRowIndex = 1;
120+
const wasRowRemoved = true;
121+
122+
const result = calculateAdjustedRowIndex(fromPosition, toRowIndex, wasRowRemoved);
123+
124+
expect(result).toBe(1); // No change
125+
});
126+
127+
it('should not adjust when removed row is same as target', () => {
128+
const fromPosition: ModulePositionInput = { rowIndex: 1, moduleIndex: 0 };
129+
const toRowIndex = 1;
130+
const wasRowRemoved = true;
131+
132+
const result = calculateAdjustedRowIndex(fromPosition, toRowIndex, wasRowRemoved);
133+
134+
expect(result).toBe(1); // No change
135+
});
136+
137+
it('should handle undefined fromPosition rowIndex gracefully', () => {
138+
const fromPosition: ModulePositionInput = { rowIndex: undefined, moduleIndex: 0 };
139+
const toRowIndex = 2;
140+
const wasRowRemoved = true;
141+
142+
const result = calculateAdjustedRowIndex(fromPosition, toRowIndex, wasRowRemoved);
143+
144+
expect(result).toBe(2); // No change when fromPosition is undefined
145+
});
146+
});
147+
148+
describe('insertModuleIntoRows', () => {
149+
const module1 = createMockModule('Module 1', 'urn:li:module:1');
150+
const module2 = createMockModule('Module 2', 'urn:li:module:2');
151+
const newModule = createMockModule('New Module', 'urn:li:module:new');
152+
153+
it('should insert new row when insertNewRow is true', () => {
154+
const rows = [{ modules: [module1] }];
155+
const position: ModulePositionInput = { rowIndex: 1, moduleIndex: 0 };
156+
157+
const result = insertModuleIntoRows(rows, newModule, position, 1, true);
158+
159+
expect(result).toHaveLength(2);
160+
expect(result[1].modules).toEqual([newModule]);
161+
expect(result[0].modules).toEqual([module1]); // Original row unchanged
162+
});
163+
164+
it('should append new row when target index exceeds rows length', () => {
165+
const rows = [{ modules: [module1] }];
166+
const position: ModulePositionInput = { rowIndex: 5, moduleIndex: 0 };
167+
168+
const result = insertModuleIntoRows(rows, newModule, position, 5, false);
169+
170+
expect(result).toHaveLength(2);
171+
expect(result[1].modules).toEqual([newModule]);
172+
});
173+
174+
it('should insert into existing row at specific position', () => {
175+
const rows = [{ modules: [module1, module2] }];
176+
const position: ModulePositionInput = { rowIndex: 0, moduleIndex: 1 };
177+
178+
const result = insertModuleIntoRows(rows, newModule, position, 0, false);
179+
180+
expect(result[0].modules).toHaveLength(3);
181+
expect(result[0].modules[1]).toEqual(newModule);
182+
expect(result[0].modules[0]).toEqual(module1);
183+
expect(result[0].modules[2]).toEqual(module2);
184+
});
185+
186+
it('should append to existing row when moduleIndex is undefined', () => {
187+
const rows = [{ modules: [module1, module2] }];
188+
const position: ModulePositionInput = { rowIndex: 0, moduleIndex: undefined };
189+
190+
const result = insertModuleIntoRows(rows, newModule, position, 0, false);
191+
192+
expect(result[0].modules).toHaveLength(3);
193+
expect(result[0].modules[2]).toEqual(newModule); // Added at end
194+
});
195+
196+
it('should handle empty rows array', () => {
197+
const rows: any[] = [];
198+
const position: ModulePositionInput = { rowIndex: 0, moduleIndex: 0 };
199+
200+
const result = insertModuleIntoRows(rows, newModule, position, 0, false);
201+
202+
expect(result).toHaveLength(1);
203+
expect(result[0].modules).toEqual([newModule]);
204+
});
205+
206+
it('should handle null rows gracefully', () => {
207+
const rows = null as any;
208+
const position: ModulePositionInput = { rowIndex: 0, moduleIndex: 0 };
209+
210+
const result = insertModuleIntoRows(rows, newModule, position, 0, false);
211+
212+
expect(result).toHaveLength(1);
213+
expect(result[0].modules).toEqual([newModule]);
214+
});
215+
});
216+
217+
describe('validateModuleMoveConstraints', () => {
218+
const module1 = createMockModule('Module 1', 'urn:li:module:1');
219+
const module2 = createMockModule('Module 2', 'urn:li:module:2');
220+
const module3 = createMockModule('Module 3', 'urn:li:module:3');
221+
222+
it('should allow move when target row has space', () => {
223+
const template = createMockTemplate([
224+
{ modules: [module1, module2] }, // Only 2 modules
225+
]);
226+
const fromPosition: ModulePositionInput = { rowIndex: 1, moduleIndex: 0 };
227+
const toPosition: ModulePositionInput = { rowIndex: 0, moduleIndex: 2 };
228+
229+
const result = validateModuleMoveConstraints(template, fromPosition, toPosition);
230+
231+
expect(result).toBeNull();
232+
});
233+
234+
it('should prevent move when target row is full and dragging from different row', () => {
235+
const template = createMockTemplate([
236+
{ modules: [module1, module2, module3] }, // Full row (3 modules)
237+
]);
238+
const fromPosition: ModulePositionInput = { rowIndex: 1, moduleIndex: 0 };
239+
const toPosition: ModulePositionInput = { rowIndex: 0, moduleIndex: 3 };
240+
241+
const result = validateModuleMoveConstraints(template, fromPosition, toPosition);
242+
243+
expect(result).toBe('Cannot move module: Target row already has maximum number of modules');
244+
});
245+
246+
it('should allow move within same row even when full', () => {
247+
const template = createMockTemplate([
248+
{ modules: [module1, module2, module3] }, // Full row
249+
]);
250+
const fromPosition: ModulePositionInput = { rowIndex: 0, moduleIndex: 0 };
251+
const toPosition: ModulePositionInput = { rowIndex: 0, moduleIndex: 2 };
252+
253+
const result = validateModuleMoveConstraints(template, fromPosition, toPosition);
254+
255+
expect(result).toBeNull();
256+
});
257+
258+
it('should handle missing template data gracefully', () => {
259+
const template = createMockTemplate([]);
260+
const fromPosition: ModulePositionInput = { rowIndex: 0, moduleIndex: 0 };
261+
const toPosition: ModulePositionInput = { rowIndex: 1, moduleIndex: 0 };
262+
263+
const result = validateModuleMoveConstraints(template, fromPosition, toPosition);
264+
265+
expect(result).toBeNull();
266+
});
267+
268+
it('should handle undefined toPosition rowIndex gracefully', () => {
269+
const template = createMockTemplate([{ modules: [module1, module2, module3] }]);
270+
const fromPosition: ModulePositionInput = { rowIndex: 0, moduleIndex: 0 };
271+
const toPosition: ModulePositionInput = { rowIndex: undefined, moduleIndex: 0 };
272+
273+
const result = validateModuleMoveConstraints(template, fromPosition, toPosition);
274+
275+
expect(result).toBeNull();
276+
});
277+
278+
it('should handle missing template properties gracefully', () => {
279+
const template = {
280+
urn: 'urn:li:pageTemplate:test',
281+
type: EntityType.DatahubPageTemplate,
282+
properties: null,
283+
} as any;
284+
const fromPosition: ModulePositionInput = { rowIndex: 0, moduleIndex: 0 };
285+
const toPosition: ModulePositionInput = { rowIndex: 0, moduleIndex: 0 };
286+
287+
const result = validateModuleMoveConstraints(template, fromPosition, toPosition);
288+
289+
expect(result).toBeNull();
290+
});
291+
});
292+
});

0 commit comments

Comments
 (0)