Create the Dashboard record show page (#14423)

Closes https://github.com/twentyhq/core-team-issues/issues/1438

- Reorganized PageLayout module
- Created `DashboardRenderer` and `PageLayoutRenderer`
- Created stories for the `PageLayoutRenderer`
- Refactored the Widget components


https://github.com/user-attachments/assets/27e9ac8f-b237-4c21-8494-3fab6d65af3a
This commit is contained in:
Raphaël Bosi
2025-09-11 18:26:26 +02:00
committed by GitHub
parent 140b416b22
commit e80c552ae7
124 changed files with 1528 additions and 808 deletions
@@ -0,0 +1,89 @@
import { WidgetType } from '../../mocks/mockWidgets';
import {
type PageLayoutTabWithData,
type PageLayoutWidgetWithData,
} from '../../types/pageLayoutTypes';
import { addWidgetToTab } from '../addWidgetToTab';
describe('addWidgetToTab', () => {
const mockWidget: PageLayoutWidgetWithData = {
id: 'widget-1',
pageLayoutTabId: 'tab-1',
title: 'Test Widget',
type: WidgetType.GRAPH,
gridPosition: { row: 0, column: 0, rowSpan: 2, columnSpan: 2 },
objectMetadataId: null,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
deletedAt: null,
};
const mockTabs: PageLayoutTabWithData[] = [
{
id: 'tab-1',
title: 'Tab 1',
position: 0,
pageLayoutId: 'layout-1',
widgets: [],
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
deletedAt: null,
},
{
id: 'tab-2',
title: 'Tab 2',
position: 1,
pageLayoutId: 'layout-1',
widgets: [],
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
deletedAt: null,
},
];
it('should add widget to the correct tab', () => {
const result = addWidgetToTab(mockTabs, 'tab-1', mockWidget);
expect(result[0].widgets).toHaveLength(1);
expect(result[0].widgets[0]).toEqual(mockWidget);
expect(result[1].widgets).toHaveLength(0);
});
it('should not modify other tabs', () => {
const result = addWidgetToTab(mockTabs, 'tab-1', mockWidget);
expect(result[1]).toEqual(mockTabs[1]);
expect(result[1].widgets).toHaveLength(0);
});
it('should handle non-existent tab ID gracefully', () => {
const result = addWidgetToTab(mockTabs, 'non-existent-tab', mockWidget);
// All tabs should remain unchanged
expect(result[0].widgets).toHaveLength(0);
expect(result[1].widgets).toHaveLength(0);
});
it('should add multiple widgets to the same tab', () => {
const secondWidget: PageLayoutWidgetWithData = {
...mockWidget,
id: 'widget-2',
title: 'Second Widget',
};
let result = addWidgetToTab(mockTabs, 'tab-1', mockWidget);
result = addWidgetToTab(result, 'tab-1', secondWidget);
expect(result[0].widgets).toHaveLength(2);
expect(result[0].widgets[0]).toEqual(mockWidget);
expect(result[0].widgets[1]).toEqual(secondWidget);
});
it('should return a new array without mutating the original', () => {
const result = addWidgetToTab(mockTabs, 'tab-1', mockWidget);
expect(result).not.toBe(mockTabs);
expect(mockTabs[0].widgets).toHaveLength(0);
expect(result[0].widgets).toHaveLength(1);
});
});
@@ -0,0 +1,138 @@
import { calculateGridBoundsFromSelectedCells } from '../calculateGridBoundsFromSelectedCells';
describe('calculateGridBoundsFromSelectedCells', () => {
it('should return null for empty array', () => {
expect(calculateGridBoundsFromSelectedCells([])).toBeNull();
});
it('should calculate bounds for single cell', () => {
expect(calculateGridBoundsFromSelectedCells(['cell-2-3'])).toEqual({
x: 2,
y: 3,
w: 1,
h: 1,
});
});
it('should calculate bounds for rectangular selection', () => {
expect(
calculateGridBoundsFromSelectedCells([
'cell-1-1',
'cell-2-1',
'cell-1-2',
'cell-2-2',
]),
).toEqual({
x: 1,
y: 1,
w: 2,
h: 2,
});
});
it('should handle non-contiguous selection', () => {
expect(
calculateGridBoundsFromSelectedCells([
'cell-0-0',
'cell-5-3',
'cell-2-1',
]),
).toEqual({
x: 0,
y: 0,
w: 6,
h: 4,
});
});
it('should handle large grid selections', () => {
expect(
calculateGridBoundsFromSelectedCells(['cell-0-0', 'cell-11-24']),
).toEqual({
x: 0,
y: 0,
w: 12,
h: 25,
});
});
it('should handle single row selection', () => {
expect(
calculateGridBoundsFromSelectedCells([
'cell-0-5',
'cell-1-5',
'cell-2-5',
'cell-3-5',
]),
).toEqual({
x: 0,
y: 5,
w: 4,
h: 1,
});
});
it('should handle single column selection', () => {
expect(
calculateGridBoundsFromSelectedCells([
'cell-3-0',
'cell-3-1',
'cell-3-2',
'cell-3-3',
]),
).toEqual({
x: 3,
y: 0,
w: 1,
h: 4,
});
});
it('should handle duplicate cell IDs in selection', () => {
expect(
calculateGridBoundsFromSelectedCells([
'cell-1-1',
'cell-1-1',
'cell-2-2',
'cell-2-2',
]),
).toEqual({
x: 1,
y: 1,
w: 2,
h: 2,
});
});
it('should handle L-shaped selection', () => {
expect(
calculateGridBoundsFromSelectedCells([
'cell-0-0',
'cell-1-0',
'cell-0-1',
'cell-0-2',
]),
).toEqual({
x: 0,
y: 0,
w: 2,
h: 3,
});
});
it('should handle sparse diagonal selection', () => {
expect(
calculateGridBoundsFromSelectedCells([
'cell-0-0',
'cell-1-1',
'cell-2-2',
'cell-3-3',
]),
).toEqual({
x: 0,
y: 0,
w: 4,
h: 4,
});
});
});
@@ -0,0 +1,40 @@
import { GRID_MIN_ROWS } from '../../constants/GridMinRows';
import { calculateTotalGridRows } from '../calculateTotalGridRows';
describe('calculateTotalGridRows', () => {
it('should return minimum rows for empty layouts', () => {
expect(calculateTotalGridRows({})).toBe(GRID_MIN_ROWS);
});
it('should calculate rows based on content when exceeding minimum', () => {
const layouts = {
desktop: [
{ i: '1', x: 0, y: 0, w: 2, h: 2 },
{ i: '2', x: 2, y: 20, w: 2, h: 3 },
],
};
expect(calculateTotalGridRows(layouts)).toBe(33);
});
it('should respect minimum rows even with content', () => {
const layouts = {
desktop: [{ i: '1', x: 0, y: 0, w: 1, h: 1 }],
};
expect(calculateTotalGridRows(layouts)).toBe(GRID_MIN_ROWS);
});
it('should handle custom min and buffer values', () => {
const layouts = {
desktop: [{ i: '1', x: 0, y: 10, w: 1, h: 5 }],
};
expect(calculateTotalGridRows(layouts, 10, 5)).toBe(20);
});
it('should consider both desktop and mobile layouts', () => {
const layouts = {
desktop: [{ i: '1', x: 0, y: 5, w: 2, h: 2 }],
mobile: [{ i: '1', x: 0, y: 25, w: 1, h: 3 }],
};
expect(calculateTotalGridRows(layouts)).toBe(38);
});
});
@@ -0,0 +1,102 @@
import { GraphType, WidgetType } from '../../mocks/mockWidgets';
import { type PageLayoutWidgetWithData } from '../../types/pageLayoutTypes';
import { convertLayoutsToWidgets } from '../convertLayoutsToWidgets';
describe('convertLayoutsToWidgets', () => {
const mockWidgets: PageLayoutWidgetWithData[] = [
{
id: 'widget-1',
pageLayoutTabId: 'tab-1',
title: 'Widget 1',
type: WidgetType.GRAPH,
objectMetadataId: null,
gridPosition: {
row: 0,
column: 0,
rowSpan: 2,
columnSpan: 2,
},
configuration: {
graphType: GraphType.NUMBER,
},
data: { value: 100 },
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
deletedAt: null,
},
{
id: 'widget-2',
pageLayoutTabId: 'tab-1',
title: 'Widget 2',
type: WidgetType.GRAPH,
objectMetadataId: null,
gridPosition: {
row: 0,
column: 2,
rowSpan: 2,
columnSpan: 2,
},
configuration: {
graphType: GraphType.PIE,
},
data: { items: [] },
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
deletedAt: null,
},
];
it('should map layout positions to widgets', () => {
const layouts = {
desktop: [
{ i: 'widget-1', x: 2, y: 3, w: 4, h: 5 },
{ i: 'widget-2', x: 6, y: 7, w: 8, h: 9 },
],
};
const result = convertLayoutsToWidgets(mockWidgets, layouts);
expect(result[0].gridPosition).toEqual({
column: 2,
row: 3,
columnSpan: 4,
rowSpan: 5,
});
expect(result[1].gridPosition).toEqual({
column: 6,
row: 7,
columnSpan: 8,
rowSpan: 9,
});
});
it('should use defaults when layout not found', () => {
const layouts = {
desktop: [{ i: 'widget-1', x: 1, y: 1, w: 1, h: 1 }],
};
const result = convertLayoutsToWidgets(mockWidgets, layouts);
expect(result[1].gridPosition).toEqual({
column: 0,
row: 0,
columnSpan: 2,
rowSpan: 2,
});
});
it('should handle mobile layout', () => {
const layouts = {
mobile: [{ i: 'widget-1', x: 0, y: 4, w: 1, h: 6 }],
};
const result = convertLayoutsToWidgets(mockWidgets, layouts);
expect(result[0].gridPosition).toEqual({
column: 0,
row: 4,
columnSpan: 1,
rowSpan: 6,
});
});
});
@@ -0,0 +1,79 @@
import { type TabLayouts } from '../../states/pageLayoutCurrentLayoutsState';
import { createEmptyTabLayout } from '../createEmptyTabLayout';
describe('createEmptyTabLayout', () => {
const mockTabLayouts: TabLayouts = {
'tab-1': {
desktop: [{ i: 'widget-1', x: 0, y: 0, w: 2, h: 2 }],
mobile: [{ i: 'widget-1', x: 0, y: 0, w: 1, h: 2 }],
},
'tab-2': {
desktop: [{ i: 'widget-2', x: 0, y: 0, w: 3, h: 3 }],
mobile: [{ i: 'widget-2', x: 0, y: 0, w: 1, h: 3 }],
},
};
it('should create empty layout for a new tab', () => {
const result = createEmptyTabLayout(mockTabLayouts, 'tab-3');
expect(result['tab-3']).toBeDefined();
expect(result['tab-3'].desktop).toEqual([]);
expect(result['tab-3'].mobile).toEqual([]);
});
it('should preserve existing tabs', () => {
const result = createEmptyTabLayout(mockTabLayouts, 'tab-3');
expect(result['tab-1']).toEqual(mockTabLayouts['tab-1']);
expect(result['tab-2']).toEqual(mockTabLayouts['tab-2']);
expect(Object.keys(result)).toHaveLength(3);
});
it('should overwrite existing tab with empty layout', () => {
const result = createEmptyTabLayout(mockTabLayouts, 'tab-1');
expect(result['tab-1'].desktop).toEqual([]);
expect(result['tab-1'].mobile).toEqual([]);
expect(result['tab-2']).toEqual(mockTabLayouts['tab-2']);
});
it('should work with empty initial state', () => {
const emptyLayouts: TabLayouts = {};
const result = createEmptyTabLayout(emptyLayouts, 'tab-1');
expect(result['tab-1']).toBeDefined();
expect(result['tab-1'].desktop).toEqual([]);
expect(result['tab-1'].mobile).toEqual([]);
expect(Object.keys(result)).toHaveLength(1);
});
it('should return a new object without mutating the original', () => {
const originalLayouts = JSON.parse(JSON.stringify(mockTabLayouts));
const result = createEmptyTabLayout(mockTabLayouts, 'tab-3');
expect(result).not.toBe(mockTabLayouts);
expect(mockTabLayouts).toEqual(originalLayouts);
expect(Object.keys(mockTabLayouts)).toHaveLength(2);
expect(Object.keys(result)).toHaveLength(3);
});
it('should handle multiple new tabs', () => {
let result = createEmptyTabLayout(mockTabLayouts, 'tab-3');
result = createEmptyTabLayout(result, 'tab-4');
result = createEmptyTabLayout(result, 'tab-5');
expect(Object.keys(result)).toHaveLength(5);
expect(result['tab-3'].desktop).toEqual([]);
expect(result['tab-4'].desktop).toEqual([]);
expect(result['tab-5'].desktop).toEqual([]);
});
it('should create consistent structure for desktop and mobile', () => {
const result = createEmptyTabLayout(mockTabLayouts, 'new-tab');
expect(result['new-tab']).toHaveProperty('desktop');
expect(result['new-tab']).toHaveProperty('mobile');
expect(Array.isArray(result['new-tab'].desktop)).toBe(true);
expect(Array.isArray(result['new-tab'].mobile)).toBe(true);
});
});
@@ -0,0 +1,82 @@
import { type TabLayouts } from '../../states/pageLayoutCurrentLayoutsState';
import { createUpdatedTabLayouts } from '../createUpdatedTabLayouts';
describe('createUpdatedTabLayouts', () => {
const mockTabLayouts: TabLayouts = {
'tab-1': {
desktop: [{ i: 'widget-1', x: 0, y: 0, w: 2, h: 2 }],
mobile: [{ i: 'widget-1', x: 0, y: 0, w: 1, h: 2 }],
},
'tab-2': {
desktop: [],
mobile: [],
},
};
const newLayout = { i: 'widget-2', x: 2, y: 0, w: 3, h: 3 };
it('should add new layout to existing tab', () => {
const result = createUpdatedTabLayouts(mockTabLayouts, 'tab-1', newLayout);
expect(result['tab-1'].desktop).toHaveLength(2);
expect(result['tab-1'].desktop[1]).toEqual(newLayout);
expect(result['tab-1'].mobile).toHaveLength(2);
expect(result['tab-1'].mobile[1]).toEqual({ ...newLayout, w: 1, x: 0 });
});
it('should add layout to empty tab', () => {
const result = createUpdatedTabLayouts(mockTabLayouts, 'tab-2', newLayout);
expect(result['tab-2'].desktop).toHaveLength(1);
expect(result['tab-2'].desktop[0]).toEqual(newLayout);
expect(result['tab-2'].mobile).toHaveLength(1);
expect(result['tab-2'].mobile[0]).toEqual({ ...newLayout, w: 1, x: 0 });
});
it('should create new tab entry if tab does not exist', () => {
const result = createUpdatedTabLayouts(mockTabLayouts, 'tab-3', newLayout);
expect(result['tab-3']).toBeDefined();
expect(result['tab-3'].desktop).toHaveLength(1);
expect(result['tab-3'].desktop[0]).toEqual(newLayout);
expect(result['tab-3'].mobile).toHaveLength(1);
});
it('should not modify other tabs', () => {
const result = createUpdatedTabLayouts(mockTabLayouts, 'tab-1', newLayout);
expect(result['tab-2']).toEqual(mockTabLayouts['tab-2']);
});
it('should handle mobile layout transformation correctly', () => {
const wideLayout = { i: 'widget-3', x: 5, y: 2, w: 6, h: 4 };
const result = createUpdatedTabLayouts(mockTabLayouts, 'tab-1', wideLayout);
const mobileLayout =
result['tab-1'].mobile[result['tab-1'].mobile.length - 1];
expect(mobileLayout.w).toBe(1);
expect(mobileLayout.x).toBe(0);
expect(mobileLayout.y).toBe(wideLayout.y);
expect(mobileLayout.h).toBe(wideLayout.h);
});
it('should return a new object without mutating the original', () => {
const originalLayouts = JSON.parse(JSON.stringify(mockTabLayouts));
const result = createUpdatedTabLayouts(mockTabLayouts, 'tab-1', newLayout);
expect(result).not.toBe(mockTabLayouts);
expect(mockTabLayouts).toEqual(originalLayouts);
expect(result['tab-1'].desktop).toHaveLength(2);
expect(mockTabLayouts['tab-1'].desktop).toHaveLength(1);
});
it('should throw an error for malformed tab layouts', () => {
const malformedLayouts: TabLayouts = {
'tab-1': {} as any,
};
expect(() => {
createUpdatedTabLayouts(malformedLayouts, 'tab-1', newLayout);
}).toThrow();
});
});
@@ -0,0 +1,15 @@
import { generateCellId } from '../generateCellId';
describe('generateCellId', () => {
it('should generate cell ID with correct format', () => {
expect(generateCellId(3, 5)).toBe('cell-3-5');
});
it('should handle zero values', () => {
expect(generateCellId(0, 0)).toBe('cell-0-0');
});
it('should handle large numbers', () => {
expect(generateCellId(100, 200)).toBe('cell-100-200');
});
});
@@ -0,0 +1,23 @@
import { getDefaultWidgetPosition } from '../getDefaultWidgetPosition';
describe('getDefaultWidgetPosition', () => {
it('should return dragged area when provided', () => {
const draggedArea = { x: 2, y: 3, w: 4, h: 5 };
const defaultSize = { w: 2, h: 2 };
expect(getDefaultWidgetPosition(draggedArea, defaultSize)).toEqual(
draggedArea,
);
});
it('should return default position with size when no dragged area', () => {
const defaultSize = { w: 3, h: 4 };
expect(getDefaultWidgetPosition(null, defaultSize)).toEqual({
x: 0,
y: 0,
w: 3,
h: 4,
});
});
});
@@ -0,0 +1,18 @@
import { parseCellIdToCoordinates } from '../parseCellIdToCoordinates';
describe('parseCellIdToCoordinates', () => {
it('should parse cell ID correctly', () => {
expect(parseCellIdToCoordinates('cell-3-5')).toEqual({ col: 3, row: 5 });
});
it('should handle zero coordinates', () => {
expect(parseCellIdToCoordinates('cell-0-0')).toEqual({ col: 0, row: 0 });
});
it('should handle double-digit coordinates', () => {
expect(parseCellIdToCoordinates('cell-12-25')).toEqual({
col: 12,
row: 25,
});
});
});
@@ -0,0 +1,109 @@
import { WidgetType } from '../../mocks/mockWidgets';
import { type PageLayoutTabWithData } from '../../types/pageLayoutTypes';
import { removeWidgetFromTab } from '../removeWidgetFromTab';
describe('removeWidgetFromTab', () => {
const mockTabs: PageLayoutTabWithData[] = [
{
id: 'tab-1',
title: 'Tab 1',
position: 0,
pageLayoutId: 'layout-1',
widgets: [
{
id: 'widget-1',
pageLayoutTabId: 'tab-1',
title: 'Widget 1',
type: WidgetType.GRAPH,
gridPosition: { row: 0, column: 0, rowSpan: 2, columnSpan: 2 },
objectMetadataId: null,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
deletedAt: null,
},
{
id: 'widget-2',
pageLayoutTabId: 'tab-1',
title: 'Widget 2',
type: WidgetType.GRAPH,
gridPosition: { row: 2, column: 0, rowSpan: 2, columnSpan: 2 },
objectMetadataId: null,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
deletedAt: null,
},
],
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
deletedAt: null,
},
{
id: 'tab-2',
title: 'Tab 2',
position: 1,
pageLayoutId: 'layout-1',
widgets: [
{
id: 'widget-3',
pageLayoutTabId: 'tab-2',
title: 'Widget 3',
type: WidgetType.IFRAME,
gridPosition: { row: 0, column: 0, rowSpan: 2, columnSpan: 2 },
objectMetadataId: null,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
deletedAt: null,
},
],
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
deletedAt: null,
},
];
it('should remove widget from the correct tab', () => {
const result = removeWidgetFromTab(mockTabs, 'tab-1', 'widget-1');
expect(result[0].widgets).toHaveLength(1);
expect(result[0].widgets[0].id).toBe('widget-2');
expect(result[1].widgets).toHaveLength(1);
expect(result[1].widgets[0].id).toBe('widget-3');
});
it('should not affect other tabs', () => {
const result = removeWidgetFromTab(mockTabs, 'tab-1', 'widget-1');
expect(result[1]).toEqual(mockTabs[1]);
expect(result[1].widgets).toHaveLength(1);
});
it('should handle removing non-existent widget gracefully', () => {
const result = removeWidgetFromTab(mockTabs, 'tab-1', 'non-existent');
expect(result[0].widgets).toHaveLength(2);
expect(result[0].widgets).toEqual(mockTabs[0].widgets);
});
it('should handle removing from non-existent tab gracefully', () => {
const result = removeWidgetFromTab(mockTabs, 'non-existent', 'widget-1');
expect(result).toEqual(mockTabs);
});
it('should remove all widgets if called multiple times', () => {
let result = removeWidgetFromTab(mockTabs, 'tab-1', 'widget-1');
result = removeWidgetFromTab(result, 'tab-1', 'widget-2');
expect(result[0].widgets).toHaveLength(0);
expect(result[1].widgets).toHaveLength(1);
});
it('should return a new array without mutating the original', () => {
const originalTabs = structuredClone(mockTabs);
const result = removeWidgetFromTab(mockTabs, 'tab-1', 'widget-1');
expect(result).not.toBe(mockTabs);
expect(mockTabs).toEqual(originalTabs);
expect(result[0].widgets).toHaveLength(1);
});
});
@@ -0,0 +1,126 @@
import { type TabLayouts } from '../../states/pageLayoutCurrentLayoutsState';
import { removeWidgetLayoutFromTab } from '../removeWidgetLayoutFromTab';
describe('removeWidgetLayoutFromTab', () => {
const mockTabLayouts: TabLayouts = {
'tab-1': {
desktop: [
{ i: 'widget-1', x: 0, y: 0, w: 2, h: 2 },
{ i: 'widget-2', x: 2, y: 0, w: 3, h: 3 },
{ i: 'widget-3', x: 5, y: 0, w: 2, h: 2 },
],
mobile: [
{ i: 'widget-1', x: 0, y: 0, w: 1, h: 2 },
{ i: 'widget-2', x: 0, y: 2, w: 1, h: 3 },
{ i: 'widget-3', x: 0, y: 5, w: 1, h: 2 },
],
},
'tab-2': {
desktop: [{ i: 'widget-4', x: 0, y: 0, w: 4, h: 4 }],
mobile: [{ i: 'widget-4', x: 0, y: 0, w: 1, h: 4 }],
},
};
it('should remove widget layout from the correct tab', () => {
const result = removeWidgetLayoutFromTab(
mockTabLayouts,
'tab-1',
'widget-2',
);
expect(result['tab-1'].desktop).toHaveLength(2);
expect(result['tab-1'].desktop.map((l) => l.i)).toEqual([
'widget-1',
'widget-3',
]);
expect(result['tab-1'].mobile).toHaveLength(2);
expect(result['tab-1'].mobile.map((l) => l.i)).toEqual([
'widget-1',
'widget-3',
]);
});
it('should not affect other tabs', () => {
const result = removeWidgetLayoutFromTab(
mockTabLayouts,
'tab-1',
'widget-1',
);
expect(result['tab-2']).toEqual(mockTabLayouts['tab-2']);
expect(result['tab-2'].desktop).toHaveLength(1);
});
it('should handle non-existent tab gracefully', () => {
const result = removeWidgetLayoutFromTab(
mockTabLayouts,
'non-existent',
'widget-1',
);
expect(result).toEqual(mockTabLayouts);
});
it('should handle non-existent widget gracefully', () => {
const result = removeWidgetLayoutFromTab(
mockTabLayouts,
'tab-1',
'non-existent',
);
expect(result['tab-1'].desktop).toHaveLength(3);
expect(result['tab-1']).toEqual(mockTabLayouts['tab-1']);
});
it('should remove all widgets from a tab', () => {
let result = removeWidgetLayoutFromTab(mockTabLayouts, 'tab-1', 'widget-1');
result = removeWidgetLayoutFromTab(result, 'tab-1', 'widget-2');
result = removeWidgetLayoutFromTab(result, 'tab-1', 'widget-3');
expect(result['tab-1'].desktop).toHaveLength(0);
expect(result['tab-1'].mobile).toHaveLength(0);
});
it('should handle empty tab layouts', () => {
const emptyLayouts: TabLayouts = {
'tab-1': {
desktop: [],
mobile: [],
},
};
const result = removeWidgetLayoutFromTab(emptyLayouts, 'tab-1', 'widget-1');
expect(result['tab-1'].desktop).toHaveLength(0);
expect(result['tab-1'].mobile).toHaveLength(0);
});
it('should return a new object without mutating the original', () => {
const originalLayouts = JSON.parse(JSON.stringify(mockTabLayouts));
const result = removeWidgetLayoutFromTab(
mockTabLayouts,
'tab-1',
'widget-1',
);
expect(result).not.toBe(mockTabLayouts);
expect(mockTabLayouts).toEqual(originalLayouts);
expect(result['tab-1'].desktop).toHaveLength(2);
expect(mockTabLayouts['tab-1'].desktop).toHaveLength(3);
});
it('should remove widget from both desktop and mobile layouts', () => {
const result = removeWidgetLayoutFromTab(
mockTabLayouts,
'tab-1',
'widget-2',
);
const desktopIds = result['tab-1'].desktop.map((l) => l.i);
const mobileIds = result['tab-1'].mobile.map((l) => l.i);
expect(desktopIds).not.toContain('widget-2');
expect(mobileIds).not.toContain('widget-2');
expect(desktopIds).toEqual(mobileIds);
});
});
@@ -0,0 +1,20 @@
import {
type PageLayoutTabWithData,
type PageLayoutWidgetWithData,
} from '../types/pageLayoutTypes';
export const addWidgetToTab = (
tabs: PageLayoutTabWithData[],
activeTabId: string,
newWidget: PageLayoutWidgetWithData,
): PageLayoutTabWithData[] => {
return tabs.map((tab) => {
if (tab.id === activeTabId) {
return {
...tab,
widgets: [...tab.widgets, newWidget],
};
}
return tab;
});
};
@@ -0,0 +1,30 @@
import { parseCellIdToCoordinates } from './parseCellIdToCoordinates';
export type GridBounds = {
x: number;
y: number;
w: number;
h: number;
};
export const calculateGridBoundsFromSelectedCells = (
selectedCellIds: string[],
): GridBounds | null => {
if (selectedCellIds.length === 0) {
return null;
}
const cellCoords = selectedCellIds.map(parseCellIdToCoordinates);
const minCol = Math.min(...cellCoords.map((c) => c.col));
const maxCol = Math.max(...cellCoords.map((c) => c.col));
const minRow = Math.min(...cellCoords.map((c) => c.row));
const maxRow = Math.max(...cellCoords.map((c) => c.row));
return {
x: minCol,
y: minRow,
w: maxCol - minCol + 1,
h: maxRow - minRow + 1,
};
};
@@ -0,0 +1,18 @@
import { type Layouts } from 'react-grid-layout';
import { GRID_BUFFER_ROWS } from '../constants/GridBufferRows';
import { GRID_MIN_ROWS } from '../constants/GridMinRows';
export const calculateTotalGridRows = (
layouts: Layouts,
minRows = GRID_MIN_ROWS,
bufferRows = GRID_BUFFER_ROWS,
): number => {
const allLayouts = [...(layouts.desktop || []), ...(layouts.mobile || [])];
const contentRows =
allLayouts.length === 0
? 0
: Math.max(...allLayouts.map((item) => item.y + item.h));
return Math.max(minRows, contentRows + bufferRows);
};
@@ -0,0 +1,22 @@
import { type PageLayoutWidgetWithData } from '@/page-layout/types/pageLayoutTypes';
import { type Layouts } from 'react-grid-layout';
export const convertLayoutsToWidgets = (
widgets: PageLayoutWidgetWithData[],
layouts: Layouts,
): PageLayoutWidgetWithData[] => {
const activeLayouts = layouts.desktop || layouts.mobile || [];
return widgets.map((widget) => {
const layout = activeLayouts.find((l) => l.i === widget.id);
return {
...widget,
gridPosition: {
row: layout?.y ?? 0,
column: layout?.x ?? 0,
rowSpan: layout?.h ?? 2,
columnSpan: layout?.w ?? 2,
},
};
});
};
@@ -0,0 +1,11 @@
import { type TabLayouts } from '../states/pageLayoutCurrentLayoutsState';
export const createEmptyTabLayout = (
allTabLayouts: TabLayouts,
tabId: string,
): TabLayouts => {
return {
...allTabLayouts,
[tabId]: { desktop: [], mobile: [] },
};
};
@@ -0,0 +1,20 @@
import { type TabLayouts } from '../states/pageLayoutCurrentLayoutsState';
export const createUpdatedTabLayouts = (
allTabLayouts: TabLayouts,
activeTabId: string,
newLayout: { i: string; x: number; y: number; w: number; h: number },
): TabLayouts => {
const currentTabLayouts = allTabLayouts[activeTabId] || {
desktop: [],
mobile: [],
};
return {
...allTabLayouts,
[activeTabId]: {
desktop: [...currentTabLayouts.desktop, newLayout],
mobile: [...currentTabLayouts.mobile, { ...newLayout, w: 1, x: 0 }],
},
};
};
@@ -0,0 +1,3 @@
export const generateCellId = (col: number, row: number): string => {
return `cell-${col}-${row}`;
};
@@ -0,0 +1,144 @@
import { GraphType } from '../mocks/mockWidgets';
export const getDefaultWidgetData = (graphType: GraphType) => {
switch (graphType) {
case GraphType.NUMBER:
return {
value: '1,234',
trendPercentage: 15.2,
};
case GraphType.GAUGE:
return {
value: 0.7,
min: 0,
max: 1,
label: 'Progress',
};
case GraphType.PIE:
return {
items: [
{ id: 'segment1', value: 35, label: 'Segment A' },
{ id: 'segment2', value: 28, label: 'Segment B' },
{ id: 'segment3', value: 20, label: 'Segment C' },
{ id: 'segment4', value: 17, label: 'Segment D' },
],
};
case GraphType.BAR:
return {
items: [
{ category: 'Jan', value: 45 },
{ category: 'Feb', value: 52 },
{ category: 'Mar', value: 48 },
{ category: 'Apr', value: 61 },
{ category: 'May', value: 55 },
],
indexBy: 'category',
keys: ['value'],
seriesLabels: { value: 'Value' },
layout: 'vertical' as const,
};
case GraphType.LINE:
return {
series: [
{
id: 'revenue',
label: 'Revenue',
color: 'blue',
data: [
{ x: 0, y: 30 },
{ x: 1, y: 50 },
{ x: 2, y: 45 },
{ x: 3, y: 70 },
{ x: 4, y: 65 },
{ x: 5, y: 80 },
{ x: 6, y: 75 },
{ x: 7, y: 85 },
],
enableArea: true,
},
{
id: 'costs',
label: 'Costs',
color: 'red',
data: [
{ x: 0, y: 60 },
{ x: 1, y: 45 },
{ x: 2, y: 55 },
{ x: 3, y: 40 },
{ x: 4, y: 60 },
{ x: 5, y: 50 },
{ x: 6, y: 70 },
{ x: 7, y: 65 },
],
enableArea: true,
},
{
id: 'profit',
label: 'Profit',
color: 'turquoise',
data: [
{ x: 0, y: 45 },
{ x: 1, y: 60 },
{ x: 2, y: 35 },
{ x: 3, y: 55 },
{ x: 4, y: 50 },
{ x: 5, y: 65 },
{ x: 6, y: 40 },
{ x: 7, y: 75 },
],
enableArea: true,
},
],
enableArea: true,
showLegend: true,
showGrid: true,
enablePoints: false,
xAxisLabel: 'Time',
yAxisLabel: 'Value',
curve: 'monotoneX',
displayType: 'shortNumber',
prefix: '$',
suffix: '',
xScale: { type: 'linear' },
yScale: { type: 'linear', min: 0, max: 'auto' },
stackedArea: false,
enableSlices: false,
};
default:
return {};
}
};
export const getWidgetTitle = (graphType: GraphType, index: number): string => {
const baseNames: Record<GraphType, string> = {
[GraphType.NUMBER]: 'Number',
[GraphType.GAUGE]: 'Gauge',
[GraphType.PIE]: 'Pie Chart',
[GraphType.BAR]: 'Bar Chart',
[GraphType.LINE]: 'Line Chart',
};
return `${baseNames[graphType] || 'Widget'} ${index + 1}`;
};
export const getWidgetSize = (graphType: GraphType) => {
switch (graphType) {
case GraphType.NUMBER:
return { w: 3, h: 2 };
case GraphType.GAUGE:
return { w: 3, h: 3 };
case GraphType.PIE:
return { w: 4, h: 4 };
case GraphType.BAR:
return { w: 6, h: 4 };
case GraphType.LINE:
return { w: 6, h: 10 };
default:
return { w: 4, h: 4 };
}
};
@@ -0,0 +1,17 @@
import { type GridBounds } from './calculateGridBoundsFromSelectedCells';
export const getDefaultWidgetPosition = (
draggedArea: GridBounds | null,
defaultSize: { w: number; h: number },
): GridBounds => {
if (draggedArea !== null) {
return draggedArea;
}
return {
x: 0,
y: 0,
w: defaultSize.w,
h: defaultSize.h,
};
};
@@ -0,0 +1,17 @@
import { type PageLayout } from '~/generated/graphql';
import { type PageLayoutWithData } from '../types/pageLayoutTypes';
export const normalizePageLayoutData = (
pageLayout: PageLayout,
): PageLayoutWithData => {
return {
...pageLayout,
tabs: (pageLayout.tabs || []).map((tab) => ({
...tab,
widgets: (tab.widgets || []).map((widget) => ({
...widget,
data: undefined,
})),
})),
};
};
@@ -0,0 +1,9 @@
export type CellCoordinate = {
col: number;
row: number;
};
export const parseCellIdToCoordinates = (cellId: string): CellCoordinate => {
const [col, row] = cellId.split('-').slice(1).map(Number);
return { col, row };
};
@@ -0,0 +1,17 @@
import { type PageLayoutTabWithData } from '../types/pageLayoutTypes';
export const removeWidgetFromTab = (
tabs: PageLayoutTabWithData[],
tabId: string,
widgetId: string,
): PageLayoutTabWithData[] => {
return tabs.map((tab) => {
if (tab.id === tabId) {
return {
...tab,
widgets: tab.widgets.filter((w) => w.id !== widgetId),
};
}
return tab;
});
};
@@ -0,0 +1,24 @@
import { type TabLayouts } from '../states/pageLayoutCurrentLayoutsState';
export const removeWidgetLayoutFromTab = (
allTabLayouts: TabLayouts,
tabId: string,
widgetId: string,
): TabLayouts => {
if (!allTabLayouts[tabId]) {
return allTabLayouts;
}
const currentTabLayouts = allTabLayouts[tabId];
return {
...allTabLayouts,
[tabId]: {
desktop: currentTabLayouts.desktop.filter(
(layout) => layout.i !== widgetId,
),
mobile: currentTabLayouts.mobile.filter(
(layout) => layout.i !== widgetId,
),
},
};
};