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,139 @@
import { act, renderHook } from '@testing-library/react';
import { type ReactNode } from 'react';
import { RecoilRoot, useRecoilValue } from 'recoil';
import { pageLayoutSelectedCellsState } from '../../states/pageLayoutSelectedCellsState';
import { useChangePageLayoutDragSelection } from '../useChangePageLayoutDragSelection';
describe('useChangePageLayoutDragSelection', () => {
it('should add cell to selection when selected is true', () => {
const { result } = renderHook(
() => ({
changeDragSelection: useChangePageLayoutDragSelection(),
selectedCells: useRecoilValue(pageLayoutSelectedCellsState),
}),
{
wrapper: ({ children }: { children: ReactNode }) =>
RecoilRoot({
initializeState: ({ set }) => {
set(pageLayoutSelectedCellsState, new Set(['cell-1']));
},
children,
}),
},
);
expect(result.current.selectedCells.size).toBe(1);
expect(result.current.selectedCells.has('cell-2')).toBe(false);
act(() => {
result.current.changeDragSelection.changePageLayoutDragSelection(
'cell-2',
true,
);
});
expect(result.current.selectedCells.size).toBe(2);
expect(result.current.selectedCells.has('cell-1')).toBe(true);
expect(result.current.selectedCells.has('cell-2')).toBe(true);
});
it('should remove cell from selection when selected is false', () => {
const { result } = renderHook(
() => ({
changeDragSelection: useChangePageLayoutDragSelection(),
selectedCells: useRecoilValue(pageLayoutSelectedCellsState),
}),
{
wrapper: ({ children }: { children: ReactNode }) =>
RecoilRoot({
initializeState: ({ set }) => {
set(pageLayoutSelectedCellsState, new Set(['cell-1', 'cell-2']));
},
children,
}),
},
);
expect(result.current.selectedCells.size).toBe(2);
expect(result.current.selectedCells.has('cell-2')).toBe(true);
act(() => {
result.current.changeDragSelection.changePageLayoutDragSelection(
'cell-2',
false,
);
});
expect(result.current.selectedCells.size).toBe(1);
expect(result.current.selectedCells.has('cell-1')).toBe(true);
expect(result.current.selectedCells.has('cell-2')).toBe(false);
});
it('should handle adding same cell multiple times', () => {
const { result } = renderHook(
() => ({
changeDragSelection: useChangePageLayoutDragSelection(),
selectedCells: useRecoilValue(pageLayoutSelectedCellsState),
}),
{
wrapper: RecoilRoot,
},
);
act(() => {
result.current.changeDragSelection.changePageLayoutDragSelection(
'cell-1',
true,
);
});
expect(result.current.selectedCells.size).toBe(1);
act(() => {
result.current.changeDragSelection.changePageLayoutDragSelection(
'cell-1',
true,
);
});
expect(result.current.selectedCells.size).toBe(1);
});
it('should handle removing non-existent cell', () => {
const { result } = renderHook(
() => ({
changeDragSelection: useChangePageLayoutDragSelection(),
selectedCells: useRecoilValue(pageLayoutSelectedCellsState),
}),
{
wrapper: ({ children }: { children: ReactNode }) =>
RecoilRoot({
initializeState: ({ set }) => {
set(pageLayoutSelectedCellsState, new Set(['cell-1']));
},
children,
}),
},
);
expect(result.current.selectedCells.size).toBe(1);
act(() => {
result.current.changeDragSelection.changePageLayoutDragSelection(
'cell-99',
false,
);
});
expect(result.current.selectedCells.size).toBe(1);
expect(result.current.selectedCells.has('cell-1')).toBe(true);
});
it('should return a function', () => {
const { result } = renderHook(() => useChangePageLayoutDragSelection(), {
wrapper: RecoilRoot,
});
expect(typeof result.current.changePageLayoutDragSelection).toBe(
'function',
);
});
});
@@ -0,0 +1,135 @@
import { pageLayoutCurrentLayoutsState } from '@/page-layout/states/pageLayoutCurrentLayoutsState';
import { pageLayoutDraftState } from '@/page-layout/states/pageLayoutDraftState';
import { act, renderHook } from '@testing-library/react';
import { RecoilRoot, useRecoilValue } from 'recoil';
import { useCreatePageLayoutTab } from '../useCreatePageLayoutTab';
jest.mock('uuid', () => ({
v4: jest.fn(),
}));
describe('useCreatePageLayoutTab', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should create a new tab with default title', () => {
const uuidModule = require('uuid');
uuidModule.v4.mockReturnValue('mock-uuid');
const { result } = renderHook(
() => ({
createTab: useCreatePageLayoutTab(),
pageLayoutCurrentLayouts: useRecoilValue(pageLayoutCurrentLayoutsState),
pageLayoutDraft: useRecoilValue(pageLayoutDraftState),
}),
{
wrapper: RecoilRoot,
},
);
let newTabId: string;
act(() => {
newTabId = result.current.createTab.createPageLayoutTab();
});
expect(result.current.pageLayoutDraft.tabs[0].id).toBe('tab-mock-uuid');
expect(result.current.pageLayoutDraft.tabs[0].title).toBe('Tab 1');
expect(result.current.pageLayoutDraft.tabs[0].position).toBe(0);
expect(result.current.pageLayoutDraft.tabs[0].widgets).toEqual([]);
expect(result.current.pageLayoutCurrentLayouts['tab-mock-uuid']).toEqual({
desktop: [],
mobile: [],
});
expect(newTabId!).toBe('tab-mock-uuid');
});
it('should create a new tab with custom title', () => {
const uuidModule = require('uuid');
uuidModule.v4.mockReturnValue('mock-uuid');
const { result } = renderHook(
() => ({
createTab: useCreatePageLayoutTab(),
pageLayoutDraft: useRecoilValue(pageLayoutDraftState),
}),
{
wrapper: RecoilRoot,
},
);
act(() => {
result.current.createTab.createPageLayoutTab('Custom Tab Name');
});
expect(result.current.pageLayoutDraft.tabs[0].title).toBe(
'Custom Tab Name',
);
});
it('should increment position for subsequent tabs', () => {
const uuidModule = require('uuid');
uuidModule.v4
.mockReturnValueOnce('mock-uuid')
.mockReturnValueOnce('mock-uuid-2');
const { result } = renderHook(
() => ({
createTab: useCreatePageLayoutTab(),
pageLayoutDraft: useRecoilValue(pageLayoutDraftState),
}),
{
wrapper: RecoilRoot,
},
);
act(() => {
result.current.createTab.createPageLayoutTab();
});
act(() => {
result.current.createTab.createPageLayoutTab();
});
expect(result.current.pageLayoutDraft.tabs).toHaveLength(2);
expect(result.current.pageLayoutDraft.tabs[0].position).toBe(0);
expect(result.current.pageLayoutDraft.tabs[0].title).toBe('Tab 1');
expect(result.current.pageLayoutDraft.tabs[1].position).toBe(1);
expect(result.current.pageLayoutDraft.tabs[1].title).toBe('Tab 2');
});
it('should create isolated layouts for multiple tabs', () => {
const uuidModule = require('uuid');
uuidModule.v4
.mockReturnValueOnce('mock-uuid-1')
.mockReturnValueOnce('mock-uuid-2');
const { result } = renderHook(
() => ({
createTab: useCreatePageLayoutTab(),
pageLayoutCurrentLayouts: useRecoilValue(pageLayoutCurrentLayoutsState),
}),
{
wrapper: RecoilRoot,
},
);
let tabId1: string = '';
act(() => {
tabId1 = result.current.createTab.createPageLayoutTab();
});
let tabId2: string = '';
act(() => {
tabId2 = result.current.createTab.createPageLayoutTab();
});
expect(result.current.pageLayoutCurrentLayouts[tabId1]).toEqual({
desktop: [],
mobile: [],
});
expect(result.current.pageLayoutCurrentLayouts[tabId2]).toEqual({
desktop: [],
mobile: [],
});
expect(tabId1).not.toBe(tabId2);
});
});
@@ -0,0 +1,201 @@
import { SETTINGS_PAGE_LAYOUT_TABS_INSTANCE_ID } from '@/page-layout/constants/SettingsPageLayoutTabsInstanceId';
import { GraphType, WidgetType } from '@/page-layout/mocks/mockWidgets';
import { pageLayoutCurrentLayoutsState } from '@/page-layout/states/pageLayoutCurrentLayoutsState';
import { pageLayoutDraftState } from '@/page-layout/states/pageLayoutDraftState';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { act, renderHook } from '@testing-library/react';
import { RecoilRoot, useRecoilValue, useSetRecoilState } from 'recoil';
import { PageLayoutType } from '~/generated/graphql';
import { useCreatePageLayoutWidget } from '../useCreatePageLayoutWidget';
jest.mock('uuid', () => ({
v4: jest.fn(() => 'mock-uuid'),
}));
describe('useCreatePageLayoutWidget', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should create widget in the correct tab with isolated layouts', () => {
const { result } = renderHook(
() => {
const setActiveTabId = useSetRecoilState(
activeTabIdComponentState.atomFamily({
instanceId: SETTINGS_PAGE_LAYOUT_TABS_INSTANCE_ID,
}),
);
const setPageLayoutDraft = useSetRecoilState(pageLayoutDraftState);
const pageLayoutDraft = useRecoilValue(pageLayoutDraftState);
const allWidgets = pageLayoutDraft.tabs.flatMap((tab) => tab.widgets);
const pageLayoutCurrentLayouts = useRecoilValue(
pageLayoutCurrentLayoutsState,
);
const createWidget = useCreatePageLayoutWidget();
return {
setActiveTabId,
setPageLayoutDraft,
allWidgets,
pageLayoutCurrentLayouts,
createWidget,
};
},
{
wrapper: RecoilRoot,
},
);
act(() => {
result.current.setPageLayoutDraft({
name: 'Test Layout',
type: PageLayoutType.DASHBOARD,
objectMetadataId: null,
tabs: [
{
id: 'tab-1',
title: 'Tab 1',
position: 0,
pageLayoutId: '',
widgets: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
},
],
});
result.current.setActiveTabId('tab-1');
});
act(() => {
result.current.createWidget.createPageLayoutWidget(
WidgetType.GRAPH,
GraphType.BAR,
);
});
expect(result.current.allWidgets).toHaveLength(1);
expect(result.current.allWidgets[0].pageLayoutTabId).toBe('tab-1');
expect(result.current.pageLayoutCurrentLayouts['tab-1']).toBeDefined();
expect(
result.current.pageLayoutCurrentLayouts['tab-1'].desktop,
).toHaveLength(1);
expect(result.current.pageLayoutCurrentLayouts['tab-2']).toBeUndefined();
});
it('should handle different graph types', () => {
const { result } = renderHook(
() => {
const setPageLayoutDraft = useSetRecoilState(pageLayoutDraftState);
const setActiveTabId = useSetRecoilState(
activeTabIdComponentState.atomFamily({
instanceId: SETTINGS_PAGE_LAYOUT_TABS_INSTANCE_ID,
}),
);
const pageLayoutDraft = useRecoilValue(pageLayoutDraftState);
const allWidgets = pageLayoutDraft.tabs.flatMap((tab) => tab.widgets);
const pageLayoutCurrentLayouts = useRecoilValue(
pageLayoutCurrentLayoutsState,
);
const createWidget = useCreatePageLayoutWidget();
return {
setPageLayoutDraft,
setActiveTabId,
pageLayoutDraft,
allWidgets,
pageLayoutCurrentLayouts,
createWidget,
};
},
{
wrapper: RecoilRoot,
},
);
act(() => {
result.current.setPageLayoutDraft({
name: 'Test Layout',
type: PageLayoutType.DASHBOARD,
objectMetadataId: null,
tabs: [
{
id: 'tab-1',
title: 'Tab 1',
position: 0,
pageLayoutId: '',
widgets: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
},
],
});
result.current.setActiveTabId('tab-1');
});
const graphTypes = [
GraphType.NUMBER,
GraphType.GAUGE,
GraphType.PIE,
GraphType.BAR,
];
graphTypes.forEach((graphType) => {
act(() => {
result.current.createWidget.createPageLayoutWidget(
WidgetType.GRAPH,
graphType,
);
});
});
expect(result.current.allWidgets).toHaveLength(4);
graphTypes.forEach((graphType, index) => {
const widget = result.current.allWidgets[index];
expect(widget.type).toBe(WidgetType.GRAPH);
expect(widget.pageLayoutTabId).toBe('tab-1');
expect(widget.configuration.graphType).toBe(graphType);
expect(widget.id).toBe('widget-mock-uuid');
});
expect(result.current.pageLayoutCurrentLayouts['tab-1']).toBeDefined();
expect(
result.current.pageLayoutCurrentLayouts['tab-1'].desktop,
).toHaveLength(4);
expect(
result.current.pageLayoutCurrentLayouts['tab-1'].mobile,
).toHaveLength(4);
expect(result.current.pageLayoutDraft.tabs[0].widgets).toHaveLength(4);
});
it('should not create widget when activeTabId is null', () => {
const { result } = renderHook(
() => {
const pageLayoutDraft = useRecoilValue(pageLayoutDraftState);
const allWidgets = pageLayoutDraft.tabs.flatMap((tab) => tab.widgets);
const pageLayoutCurrentLayouts = useRecoilValue(
pageLayoutCurrentLayoutsState,
);
const createWidget = useCreatePageLayoutWidget();
return { allWidgets, pageLayoutCurrentLayouts, createWidget };
},
{
wrapper: RecoilRoot,
},
);
act(() => {
result.current.createWidget.createPageLayoutWidget(
WidgetType.GRAPH,
GraphType.BAR,
);
});
expect(result.current.allWidgets).toHaveLength(0);
expect(Object.keys(result.current.pageLayoutCurrentLayouts)).toHaveLength(
0,
);
});
});
@@ -0,0 +1,41 @@
import { act, renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { useDeletePageLayoutWidget } from '../useDeletePageLayoutWidget';
describe('useDeletePageLayoutWidget', () => {
it('should remove widget from all states', () => {
const { result } = renderHook(() => useDeletePageLayoutWidget(), {
wrapper: RecoilRoot,
});
act(() => {
result.current.deletePageLayoutWidget('widget-1');
});
expect(typeof result.current.deletePageLayoutWidget).toBe('function');
});
it('should handle removing non-existent widget', () => {
const { result } = renderHook(() => useDeletePageLayoutWidget(), {
wrapper: RecoilRoot,
});
act(() => {
result.current.deletePageLayoutWidget('non-existent-widget');
});
expect(typeof result.current.deletePageLayoutWidget).toBe('function');
});
it('should handle empty layouts', () => {
const { result } = renderHook(() => useDeletePageLayoutWidget(), {
wrapper: RecoilRoot,
});
act(() => {
result.current.deletePageLayoutWidget('any-widget');
});
expect(typeof result.current.deletePageLayoutWidget).toBe('function');
});
});
@@ -0,0 +1,208 @@
import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { act, renderHook } from '@testing-library/react';
import { type ReactNode } from 'react';
import { RecoilRoot, useRecoilValue } from 'recoil';
import { IconAppWindow } from 'twenty-ui/display';
import { pageLayoutDraggedAreaState } from '../../states/pageLayoutDraggedAreaState';
import { pageLayoutSelectedCellsState } from '../../states/pageLayoutSelectedCellsState';
import { calculateGridBoundsFromSelectedCells } from '../../utils/calculateGridBoundsFromSelectedCells';
import { useEndPageLayoutDragSelection } from '../useEndPageLayoutDragSelection';
jest.mock('@/command-menu/hooks/useNavigateCommandMenu');
jest.mock('../../utils/calculateGridBoundsFromSelectedCells');
describe('useEndPageLayoutDragSelection', () => {
const mockNavigateCommandMenu = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
(useNavigateCommandMenu as jest.Mock).mockReturnValue({
navigateCommandMenu: mockNavigateCommandMenu,
});
});
it('should handle drag selection end with valid bounds', () => {
const mockBounds = { x: 0, y: 0, w: 2, h: 2 };
(calculateGridBoundsFromSelectedCells as jest.Mock).mockReturnValue(
mockBounds,
);
const { result } = renderHook(
() => ({
endDragSelection: useEndPageLayoutDragSelection(),
selectedCells: useRecoilValue(pageLayoutSelectedCellsState),
draggedArea: useRecoilValue(pageLayoutDraggedAreaState),
}),
{
wrapper: ({ children }: { children: ReactNode }) =>
RecoilRoot({
initializeState: ({ set }) => {
set(
pageLayoutSelectedCellsState,
new Set(['0-0', '0-1', '1-0', '1-1']),
);
set(pageLayoutDraggedAreaState, null);
},
children,
}),
},
);
expect(result.current.selectedCells.size).toBe(4);
expect(result.current.draggedArea).toBeNull();
act(() => {
result.current.endDragSelection.endPageLayoutDragSelection();
});
expect(calculateGridBoundsFromSelectedCells).toHaveBeenCalledWith([
'0-0',
'0-1',
'1-0',
'1-1',
]);
expect(result.current.draggedArea).toEqual(mockBounds);
expect(result.current.selectedCells.size).toBe(0);
expect(mockNavigateCommandMenu).toHaveBeenCalledWith({
page: CommandMenuPages.PageLayoutWidgetTypeSelect,
pageTitle: 'Add Widget',
pageIcon: IconAppWindow,
resetNavigationStack: true,
});
});
it('should not navigate when no cells are selected', () => {
(calculateGridBoundsFromSelectedCells as jest.Mock).mockReturnValue(null);
const { result } = renderHook(
() => ({
endDragSelection: useEndPageLayoutDragSelection(),
selectedCells: useRecoilValue(pageLayoutSelectedCellsState),
draggedArea: useRecoilValue(pageLayoutDraggedAreaState),
}),
{
wrapper: ({ children }: { children: ReactNode }) =>
RecoilRoot({
initializeState: ({ set }) => {
set(pageLayoutSelectedCellsState, new Set());
set(pageLayoutDraggedAreaState, null);
},
children,
}),
},
);
act(() => {
result.current.endDragSelection.endPageLayoutDragSelection();
});
expect(calculateGridBoundsFromSelectedCells).not.toHaveBeenCalled();
expect(mockNavigateCommandMenu).not.toHaveBeenCalled();
expect(result.current.draggedArea).toBeNull();
expect(result.current.selectedCells.size).toBe(0);
});
it('should not navigate when bounds calculation returns null', () => {
(calculateGridBoundsFromSelectedCells as jest.Mock).mockReturnValue(null);
const { result } = renderHook(
() => ({
endDragSelection: useEndPageLayoutDragSelection(),
selectedCells: useRecoilValue(pageLayoutSelectedCellsState),
draggedArea: useRecoilValue(pageLayoutDraggedAreaState),
}),
{
wrapper: ({ children }: { children: ReactNode }) =>
RecoilRoot({
initializeState: ({ set }) => {
set(pageLayoutSelectedCellsState, new Set(['invalid-cell']));
set(pageLayoutDraggedAreaState, null);
},
children,
}),
},
);
act(() => {
result.current.endDragSelection.endPageLayoutDragSelection();
});
expect(calculateGridBoundsFromSelectedCells).toHaveBeenCalledWith([
'invalid-cell',
]);
expect(mockNavigateCommandMenu).not.toHaveBeenCalled();
expect(result.current.draggedArea).toBeNull();
expect(result.current.selectedCells.size).toBe(1);
});
it('should return a function', () => {
const { result } = renderHook(() => useEndPageLayoutDragSelection(), {
wrapper: RecoilRoot,
});
expect(typeof result.current.endPageLayoutDragSelection).toBe('function');
});
it('should navigate to widget selection when bounds are valid', () => {
const mockBounds = { x: 0, y: 0, w: 2, h: 2 };
(calculateGridBoundsFromSelectedCells as jest.Mock).mockReturnValue(
mockBounds,
);
const { result } = renderHook(
() => ({
endDragSelection: useEndPageLayoutDragSelection(),
selectedCells: useRecoilValue(pageLayoutSelectedCellsState),
}),
{
wrapper: ({ children }: { children: ReactNode }) =>
RecoilRoot({
initializeState: ({ set }) => {
set(pageLayoutSelectedCellsState, new Set(['0-0']));
},
children,
}),
},
);
act(() => {
result.current.endDragSelection.endPageLayoutDragSelection();
});
expect(mockNavigateCommandMenu).toHaveBeenCalled();
});
it('should clear selected cells after successful navigation', () => {
const mockBounds = { x: 0, y: 0, w: 1, h: 1 };
(calculateGridBoundsFromSelectedCells as jest.Mock).mockReturnValue(
mockBounds,
);
const { result } = renderHook(
() => ({
endDragSelection: useEndPageLayoutDragSelection(),
selectedCells: useRecoilValue(pageLayoutSelectedCellsState),
}),
{
wrapper: ({ children }: { children: ReactNode }) =>
RecoilRoot({
initializeState: ({ set }) => {
set(pageLayoutSelectedCellsState, new Set(['0-0']));
},
children,
}),
},
);
expect(result.current.selectedCells.size).toBe(1);
act(() => {
result.current.endDragSelection.endPageLayoutDragSelection();
});
expect(result.current.selectedCells.size).toBe(0);
});
});
@@ -0,0 +1,96 @@
import { GraphType, WidgetType } from '@/page-layout/mocks/mockWidgets';
import { act, renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { PageLayoutType } from '~/generated/graphql';
import { usePageLayoutDraftState } from '../usePageLayoutDraftState';
describe('usePageLayoutDraftState', () => {
it('should detect dirty state when draft differs from persisted', () => {
const { result } = renderHook(() => usePageLayoutDraftState(), {
wrapper: RecoilRoot,
});
expect(result.current.isDirty).toBe(false);
expect(result.current.canSave).toBe(false);
});
it('should handle empty name as not saveable', () => {
const { result } = renderHook(() => usePageLayoutDraftState(), {
wrapper: RecoilRoot,
});
act(() => {
result.current.setPageLayoutDraft({
name: ' ',
type: PageLayoutType.DASHBOARD,
objectMetadataId: null,
tabs: [],
});
});
expect(result.current.isDirty).toBe(false);
expect(result.current.canSave).toBe(false);
});
it('should allow updating draft state', () => {
const { result } = renderHook(() => usePageLayoutDraftState(), {
wrapper: RecoilRoot,
});
act(() => {
result.current.setPageLayoutDraft({
name: 'Updated Name',
type: PageLayoutType.DASHBOARD,
objectMetadataId: null,
tabs: [],
});
});
expect(result.current.pageLayoutDraft.name).toBe('Updated Name');
expect(result.current.canSave).toBe(true);
expect(result.current.isDirty).toBe(true);
});
it('should detect changes in widgets', () => {
const { result } = renderHook(() => usePageLayoutDraftState(), {
wrapper: RecoilRoot,
});
act(() => {
result.current.setPageLayoutDraft({
name: 'Test Layout',
type: PageLayoutType.DASHBOARD,
objectMetadataId: null,
tabs: [
{
id: 'tab-1',
title: 'Tab 1',
position: 0,
pageLayoutId: '',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
widgets: [
{
id: 'widget-1',
pageLayoutTabId: 'tab-1',
title: 'New Widget',
type: WidgetType.GRAPH,
gridPosition: { row: 2, column: 2, rowSpan: 2, columnSpan: 2 },
configuration: { graphType: GraphType.BAR },
data: {},
objectMetadataId: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
},
],
},
],
});
});
expect(result.current.isDirty).toBe(true);
expect(result.current.canSave).toBe(true);
});
});
@@ -0,0 +1,95 @@
import { act, renderHook } from '@testing-library/react';
import { RecoilRoot, useRecoilValue } from 'recoil';
import { pageLayoutCurrentLayoutsState } from '../../states/pageLayoutCurrentLayoutsState';
import { usePageLayoutHandleLayoutChange } from '../usePageLayoutHandleLayoutChange';
describe('usePageLayoutHandleLayoutChange', () => {
it('should update layouts for specific tab only', () => {
const { result } = renderHook(
() => ({
handler: usePageLayoutHandleLayoutChange('tab-1'),
layouts: useRecoilValue(pageLayoutCurrentLayoutsState),
}),
{
wrapper: RecoilRoot,
},
);
const newLayouts = {
desktop: [
{ i: 'widget-1', x: 2, y: 3, w: 4, h: 5 },
{ i: 'widget-2', x: 6, y: 7, w: 8, h: 9 },
],
mobile: [
{ i: 'widget-1', x: 0, y: 0, w: 1, h: 5 },
{ i: 'widget-2', x: 0, y: 5, w: 1, h: 9 },
],
};
act(() => {
result.current.handler.handleLayoutChange([], newLayouts);
});
expect(result.current.layouts['tab-1']).toEqual(newLayouts);
expect(result.current.layouts['tab-2']).toBeUndefined();
});
it('should isolate layouts between different tabs', () => {
const { result, rerender } = renderHook(
({ tabId }) => ({
handler: usePageLayoutHandleLayoutChange(tabId),
layouts: useRecoilValue(pageLayoutCurrentLayoutsState),
}),
{
wrapper: RecoilRoot,
initialProps: { tabId: 'tab-1' },
},
);
const tab1Layouts = {
desktop: [{ i: 'widget-1', x: 0, y: 0, w: 2, h: 2 }],
mobile: [{ i: 'widget-1', x: 0, y: 0, w: 1, h: 2 }],
};
act(() => {
result.current.handler.handleLayoutChange([], tab1Layouts);
});
rerender({ tabId: 'tab-2' });
const tab2Layouts = {
desktop: [{ i: 'widget-2', x: 4, y: 4, w: 3, h: 3 }],
mobile: [{ i: 'widget-2', x: 0, y: 0, w: 1, h: 3 }],
};
act(() => {
result.current.handler.handleLayoutChange([], tab2Layouts);
});
expect(result.current.layouts['tab-1']).toEqual(tab1Layouts);
expect(result.current.layouts['tab-2']).toEqual(tab2Layouts);
});
it('should not update layouts when activeTabId is null', () => {
const { result } = renderHook(
() => ({
handler: usePageLayoutHandleLayoutChange(null),
layouts: useRecoilValue(pageLayoutCurrentLayoutsState),
}),
{
wrapper: RecoilRoot,
},
);
const newLayouts = {
desktop: [{ i: 'widget-1', x: 0, y: 0, w: 2, h: 2 }],
mobile: [{ i: 'widget-1', x: 0, y: 0, w: 1, h: 2 }],
};
act(() => {
result.current.handler.handleLayoutChange([], newLayouts);
});
expect(Object.keys(result.current.layouts)).toHaveLength(0);
});
});
@@ -0,0 +1,73 @@
import { act, renderHook } from '@testing-library/react';
import { type ReactNode } from 'react';
import { RecoilRoot, useRecoilValue } from 'recoil';
import { pageLayoutSelectedCellsState } from '../../states/pageLayoutSelectedCellsState';
import { useStartPageLayoutDragSelection } from '../useStartPageLayoutDragSelection';
describe('useStartPageLayoutDragSelection', () => {
it('should clear selected cells when starting drag selection', () => {
const { result } = renderHook(
() => ({
startDragSelection: useStartPageLayoutDragSelection(),
selectedCells: useRecoilValue(pageLayoutSelectedCellsState),
}),
{
wrapper: ({ children }: { children: ReactNode }) =>
RecoilRoot({
initializeState: ({ set }) => {
set(pageLayoutSelectedCellsState, new Set(['cell-1', 'cell-2']));
},
children,
}),
},
);
expect(result.current.selectedCells.size).toBe(2);
expect(result.current.selectedCells.has('cell-1')).toBe(true);
expect(result.current.selectedCells.has('cell-2')).toBe(true);
act(() => {
result.current.startDragSelection.startPageLayoutDragSelection();
});
expect(result.current.selectedCells.size).toBe(0);
});
it('should return a function', () => {
const { result } = renderHook(() => useStartPageLayoutDragSelection(), {
wrapper: RecoilRoot,
});
expect(typeof result.current.startPageLayoutDragSelection).toBe('function');
});
it('should handle multiple calls correctly', () => {
const { result } = renderHook(
() => ({
startDragSelection: useStartPageLayoutDragSelection(),
selectedCells: useRecoilValue(pageLayoutSelectedCellsState),
}),
{
wrapper: ({ children }: { children: ReactNode }) =>
RecoilRoot({
initializeState: ({ set }) => {
set(pageLayoutSelectedCellsState, new Set(['cell-1']));
},
children,
}),
},
);
expect(result.current.selectedCells.size).toBe(1);
act(() => {
result.current.startDragSelection.startPageLayoutDragSelection();
});
expect(result.current.selectedCells.size).toBe(0);
act(() => {
result.current.startDragSelection.startPageLayoutDragSelection();
});
expect(result.current.selectedCells.size).toBe(0);
});
});
@@ -0,0 +1,22 @@
import { useRecoilCallback } from 'recoil';
import { pageLayoutSelectedCellsState } from '../states/pageLayoutSelectedCellsState';
export const useChangePageLayoutDragSelection = () => {
const changePageLayoutDragSelection = useRecoilCallback(
({ set }) =>
(cellId: string, selected: boolean) => {
set(pageLayoutSelectedCellsState, (prev) => {
const newSet = new Set(prev);
if (selected) {
newSet.add(cellId);
} else {
newSet.delete(cellId);
}
return newSet;
});
},
[],
);
return { changePageLayoutDragSelection };
};
@@ -0,0 +1,89 @@
import { SETTINGS_PAGE_LAYOUT_TABS_INSTANCE_ID } from '@/page-layout/constants/SettingsPageLayoutTabsInstanceId';
import { pageLayoutCurrentLayoutsState } from '@/page-layout/states/pageLayoutCurrentLayoutsState';
import { pageLayoutDraftState } from '@/page-layout/states/pageLayoutDraftState';
import { pageLayoutDraggedAreaState } from '@/page-layout/states/pageLayoutDraggedAreaState';
import { type PageLayoutWidgetWithData } from '@/page-layout/types/pageLayoutTypes';
import { addWidgetToTab } from '@/page-layout/utils/addWidgetToTab';
import { createUpdatedTabLayouts } from '@/page-layout/utils/createUpdatedTabLayouts';
import { getDefaultWidgetPosition } from '@/page-layout/utils/getDefaultWidgetPosition';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useRecoilCallback } from 'recoil';
import { v4 as uuidv4 } from 'uuid';
import { WidgetType } from '~/generated/graphql';
export const useCreatePageLayoutIframeWidget = () => {
const activeTabId = useRecoilComponentValue(
activeTabIdComponentState,
SETTINGS_PAGE_LAYOUT_TABS_INSTANCE_ID,
);
const createPageLayoutIframeWidget = useRecoilCallback(
({ snapshot, set }) =>
(title: string, url: string) => {
const allTabLayouts = snapshot
.getLoadable(pageLayoutCurrentLayoutsState)
.getValue();
const pageLayoutDraggedArea = snapshot
.getLoadable(pageLayoutDraggedAreaState)
.getValue();
if (!activeTabId) {
return;
}
const widgetId = `widget-${uuidv4()}`;
const defaultSize = { w: 6, h: 6 };
const position = getDefaultWidgetPosition(
pageLayoutDraggedArea,
defaultSize,
);
const newWidget: PageLayoutWidgetWithData = {
id: widgetId,
pageLayoutTabId: activeTabId,
title,
type: WidgetType.IFRAME,
gridPosition: {
row: position.y,
column: position.x,
rowSpan: position.h,
columnSpan: position.w,
},
configuration: {
url,
},
data: {},
objectMetadataId: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
};
const newLayout = {
i: widgetId,
x: position.x,
y: position.y,
w: position.w,
h: position.h,
};
const updatedLayouts = createUpdatedTabLayouts(
allTabLayouts,
activeTabId,
newLayout,
);
set(pageLayoutCurrentLayoutsState, updatedLayouts);
set(pageLayoutDraftState, (prev) => ({
...prev,
tabs: addWidgetToTab(prev.tabs, activeTabId, newWidget),
}));
set(pageLayoutDraggedAreaState, null);
},
[activeTabId],
);
return { createPageLayoutIframeWidget };
};
@@ -0,0 +1,46 @@
import { useRecoilCallback } from 'recoil';
import { v4 as uuidv4 } from 'uuid';
import { pageLayoutCurrentLayoutsState } from '../states/pageLayoutCurrentLayoutsState';
import { pageLayoutDraftState } from '../states/pageLayoutDraftState';
import { type PageLayoutTabWithData } from '../types/pageLayoutTypes';
import { createEmptyTabLayout } from '../utils/createEmptyTabLayout';
export const useCreatePageLayoutTab = () => {
const createPageLayoutTab = useRecoilCallback(
({ snapshot, set }) =>
(title?: string): string => {
const pageLayoutDraft = snapshot
.getLoadable(pageLayoutDraftState)
.getValue();
const newTabId = `tab-${uuidv4()}`;
const tabsLength = pageLayoutDraft.tabs.length;
const newTab: PageLayoutTabWithData = {
id: newTabId,
title: title || `Tab ${tabsLength + 1}`,
position: tabsLength,
pageLayoutId: '',
widgets: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
};
const updatedTabs = [...(pageLayoutDraft.tabs || []), newTab];
set(pageLayoutDraftState, (prev) => ({
...prev,
tabs: updatedTabs,
}));
set(pageLayoutCurrentLayoutsState, (prev) =>
createEmptyTabLayout(prev, newTabId),
);
return newTabId;
},
[],
);
return { createPageLayoutTab };
};
@@ -0,0 +1,107 @@
import { SETTINGS_PAGE_LAYOUT_TABS_INSTANCE_ID } from '@/page-layout/constants/SettingsPageLayoutTabsInstanceId';
import { type GraphType } from '@/page-layout/mocks/mockWidgets';
import { pageLayoutCurrentLayoutsState } from '@/page-layout/states/pageLayoutCurrentLayoutsState';
import { pageLayoutDraftState } from '@/page-layout/states/pageLayoutDraftState';
import { pageLayoutDraggedAreaState } from '@/page-layout/states/pageLayoutDraggedAreaState';
import { type PageLayoutWidgetWithData } from '@/page-layout/types/pageLayoutTypes';
import { addWidgetToTab } from '@/page-layout/utils/addWidgetToTab';
import { createUpdatedTabLayouts } from '@/page-layout/utils/createUpdatedTabLayouts';
import {
getDefaultWidgetData,
getWidgetSize,
getWidgetTitle,
} from '@/page-layout/utils/getDefaultWidgetData';
import { getDefaultWidgetPosition } from '@/page-layout/utils/getDefaultWidgetPosition';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useRecoilCallback } from 'recoil';
import { v4 as uuidv4 } from 'uuid';
import { type WidgetType } from '~/generated/graphql';
export const useCreatePageLayoutWidget = () => {
const activeTabId = useRecoilComponentValue(
activeTabIdComponentState,
SETTINGS_PAGE_LAYOUT_TABS_INSTANCE_ID,
);
const createPageLayoutWidget = useRecoilCallback(
({ snapshot, set }) =>
(widgetType: WidgetType, graphType: GraphType) => {
const widgetData = getDefaultWidgetData(graphType);
const pageLayoutDraft = snapshot
.getLoadable(pageLayoutDraftState)
.getValue();
const allTabLayouts = snapshot
.getLoadable(pageLayoutCurrentLayoutsState)
.getValue();
const pageLayoutDraggedArea = snapshot
.getLoadable(pageLayoutDraggedAreaState)
.getValue();
if (!activeTabId) {
return;
}
const allWidgets = pageLayoutDraft.tabs.flatMap((tab) => tab.widgets);
const existingWidgetCount = allWidgets.filter(
(w) =>
w.type === widgetType && w.configuration.graphType === graphType,
).length;
const title = getWidgetTitle(graphType, existingWidgetCount);
const widgetId = `widget-${uuidv4()}`;
const defaultSize = getWidgetSize(graphType);
const position = getDefaultWidgetPosition(
pageLayoutDraggedArea,
defaultSize,
);
const newWidget: PageLayoutWidgetWithData = {
id: widgetId,
pageLayoutTabId: activeTabId,
title,
type: widgetType,
gridPosition: {
row: position.y,
column: position.x,
rowSpan: position.h,
columnSpan: position.w,
},
configuration: {
graphType,
},
data: widgetData as Record<string, unknown>,
objectMetadataId: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
};
const newLayout = {
i: widgetId,
x: position.x,
y: position.y,
w: position.w,
h: position.h,
};
const updatedLayouts = createUpdatedTabLayouts(
allTabLayouts,
activeTabId,
newLayout,
);
set(pageLayoutCurrentLayoutsState, updatedLayouts);
set(pageLayoutDraftState, (prev) => ({
...prev,
tabs: addWidgetToTab(prev.tabs, activeTabId, newWidget),
}));
set(pageLayoutDraggedAreaState, null);
},
[activeTabId],
);
return { createPageLayoutWidget };
};
@@ -0,0 +1,42 @@
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { pageLayoutCurrentLayoutsState } from '../states/pageLayoutCurrentLayoutsState';
import { pageLayoutDraftState } from '../states/pageLayoutDraftState';
import { removeWidgetFromTab } from '../utils/removeWidgetFromTab';
import { removeWidgetLayoutFromTab } from '../utils/removeWidgetLayoutFromTab';
export const useDeletePageLayoutWidget = () => {
const deletePageLayoutWidget = useRecoilCallback(
({ snapshot, set }) =>
(widgetId: string) => {
const pageLayoutDraft = snapshot
.getLoadable(pageLayoutDraftState)
.getValue();
const allTabLayouts = snapshot
.getLoadable(pageLayoutCurrentLayoutsState)
.getValue();
const tabWithWidget = pageLayoutDraft.tabs.find((tab) =>
tab.widgets.some((w) => w.id === widgetId),
);
const tabId = tabWithWidget?.id;
if (isDefined(tabId)) {
const updatedLayouts = removeWidgetLayoutFromTab(
allTabLayouts,
tabId,
widgetId,
);
set(pageLayoutCurrentLayoutsState, updatedLayouts);
set(pageLayoutDraftState, (prev) => ({
...prev,
tabs: removeWidgetFromTab(prev.tabs, tabId, widgetId),
}));
}
},
[],
);
return { deletePageLayoutWidget };
};
@@ -0,0 +1,43 @@
import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { IconAppWindow } from 'twenty-ui/display';
import { pageLayoutDraggedAreaState } from '../states/pageLayoutDraggedAreaState';
import { pageLayoutSelectedCellsState } from '../states/pageLayoutSelectedCellsState';
import { calculateGridBoundsFromSelectedCells } from '../utils/calculateGridBoundsFromSelectedCells';
export const useEndPageLayoutDragSelection = () => {
const { navigateCommandMenu } = useNavigateCommandMenu();
const endPageLayoutDragSelection = useRecoilCallback(
({ snapshot, set }) =>
() => {
const pageLayoutSelectedCells = snapshot
.getLoadable(pageLayoutSelectedCellsState)
.getValue();
if (pageLayoutSelectedCells.size > 0) {
const draggedBounds = calculateGridBoundsFromSelectedCells(
Array.from(pageLayoutSelectedCells),
);
if (isDefined(draggedBounds)) {
set(pageLayoutDraggedAreaState, draggedBounds);
navigateCommandMenu({
page: CommandMenuPages.PageLayoutWidgetTypeSelect,
pageTitle: 'Add Widget',
pageIcon: IconAppWindow,
resetNavigationStack: true,
});
set(pageLayoutSelectedCellsState, new Set());
}
}
},
[navigateCommandMenu],
);
return { endPageLayoutDragSelection };
};
@@ -0,0 +1,28 @@
import { useRecoilState, useRecoilValue } from 'recoil';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { pageLayoutDraftState } from '../states/pageLayoutDraftState';
import { pageLayoutPersistedState } from '../states/pageLayoutPersistedState';
export const usePageLayoutDraftState = () => {
const [pageLayoutDraft, setPageLayoutDraft] =
useRecoilState(pageLayoutDraftState);
const pageLayoutPersisted = useRecoilValue(pageLayoutPersistedState);
const isDirty = pageLayoutPersisted
? !isDeeplyEqual(pageLayoutDraft, {
name: pageLayoutPersisted.name,
type: pageLayoutPersisted.type,
objectMetadataId: pageLayoutPersisted.objectMetadataId,
tabs: pageLayoutPersisted.tabs,
})
: pageLayoutDraft.name.trim().length > 0 || pageLayoutDraft.tabs.length > 0;
const canSave = pageLayoutDraft.name?.trim().length > 0;
return {
pageLayoutDraft,
setPageLayoutDraft,
isDirty,
canSave,
};
};
@@ -0,0 +1,72 @@
import { type Layout, type Layouts } from 'react-grid-layout';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { pageLayoutCurrentLayoutsState } from '../states/pageLayoutCurrentLayoutsState';
import { pageLayoutDraftState } from '../states/pageLayoutDraftState';
import { type PageLayoutWidgetWithData } from '../types/pageLayoutTypes';
import { convertLayoutsToWidgets } from '../utils/convertLayoutsToWidgets';
export const usePageLayoutHandleLayoutChange = (activeTabId: string | null) => {
const handleLayoutChange = useRecoilCallback(
({ snapshot, set }) =>
(_: Layout[], allLayouts: Layouts) => {
if (!isDefined(activeTabId)) return;
const currentTabLayouts = snapshot
.getLoadable(pageLayoutCurrentLayoutsState)
.getValue();
set(pageLayoutCurrentLayoutsState, {
...currentTabLayouts,
[activeTabId]: allLayouts,
});
const pageLayoutDraft = snapshot
.getLoadable(pageLayoutDraftState)
.getValue();
const currentTab = pageLayoutDraft.tabs.find(
(tab) => tab.id === activeTabId,
);
if (!currentTab) return;
const updatedWidgets = convertLayoutsToWidgets(
currentTab.widgets,
allLayouts,
);
if (isDefined(activeTabId)) {
set(pageLayoutDraftState, (prev) => ({
...prev,
tabs: prev.tabs.map((tab) => {
if (tab.id === activeTabId) {
const tabWidgets: PageLayoutWidgetWithData[] = updatedWidgets
.filter((w) => w.pageLayoutTabId === activeTabId)
.map((widget) => ({
id: widget.id,
pageLayoutTabId: widget.pageLayoutTabId || activeTabId,
title: widget.title,
type: widget.type,
objectMetadataId: null,
gridPosition: widget.gridPosition,
configuration: widget.configuration || undefined,
data: widget.data,
createdAt:
tab.widgets.find((w) => w.id === widget.id)?.createdAt ||
new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
}));
return {
...tab,
widgets: tabWidgets,
};
}
return tab;
}),
}));
}
},
[activeTabId],
);
return { handleLayoutChange };
};
@@ -0,0 +1,73 @@
import { savedPageLayoutsState } from '@/page-layout/states/savedPageLayoutsState';
import {
type PageLayoutWidgetWithData,
type PageLayoutWithData,
} from '@/page-layout/types/pageLayoutTypes';
import { useParams } from 'react-router-dom';
import { useRecoilCallback } from 'recoil';
import { SettingsPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { v4 as uuidv4 } from 'uuid';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { pageLayoutDraftState } from '../states/pageLayoutDraftState';
import { pageLayoutPersistedState } from '../states/pageLayoutPersistedState';
export const usePageLayoutSaveHandler = () => {
const navigateSettings = useNavigateSettings();
const { id } = useParams<{ id: string }>();
const isEditMode = id && id !== 'new';
const savePageLayout = useRecoilCallback(
({ snapshot, set }) =>
async (widgetsWithPositions?: PageLayoutWidgetWithData[]) => {
const pageLayoutDraft = snapshot
.getLoadable(pageLayoutDraftState)
.getValue();
const savedPageLayouts = snapshot
.getLoadable(savedPageLayoutsState)
.getValue();
const existingLayout = isEditMode
? savedPageLayouts.find((layout) => layout.id === id)
: undefined;
const updatedTabs = widgetsWithPositions
? pageLayoutDraft.tabs.map((tab) => ({
...tab,
widgets: widgetsWithPositions.filter(
(w) => w.pageLayoutTabId === tab.id,
),
}))
: pageLayoutDraft.tabs;
const layoutToSave: PageLayoutWithData = {
id: isEditMode ? id : uuidv4(),
name: pageLayoutDraft.name,
type: pageLayoutDraft.type,
objectMetadataId: pageLayoutDraft.objectMetadataId,
tabs: updatedTabs,
createdAt: isEditMode
? (existingLayout?.createdAt ?? new Date().toISOString())
: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
};
set(savedPageLayoutsState, (prev) => {
if (isDefined(isEditMode)) {
return prev.map((layout) =>
layout.id === id ? layoutToSave : layout,
);
}
return [...prev, layoutToSave];
});
set(pageLayoutPersistedState, layoutToSave);
navigateSettings(SettingsPath.PageLayout);
},
[isEditMode, id, navigateSettings],
);
return { savePageLayout };
};
@@ -0,0 +1,14 @@
import { useRecoilCallback } from 'recoil';
import { pageLayoutSelectedCellsState } from '../states/pageLayoutSelectedCellsState';
export const useStartPageLayoutDragSelection = () => {
const startPageLayoutDragSelection = useRecoilCallback(
({ set }) =>
() => {
set(pageLayoutSelectedCellsState, new Set());
},
[],
);
return { startPageLayoutDragSelection };
};
@@ -0,0 +1,23 @@
import { pageLayoutDraftState } from '@/page-layout/states/pageLayoutDraftState';
import { type PageLayoutWidgetWithData } from '@/page-layout/types/pageLayoutTypes';
import { useRecoilCallback } from 'recoil';
export const useUpdatePageLayoutWidget = () => {
const updatePageLayoutWidget = useRecoilCallback(
({ set }) =>
(widgetId: string, updates: Partial<PageLayoutWidgetWithData>) => {
set(pageLayoutDraftState, (prev) => ({
...prev,
tabs: prev.tabs.map((tab) => ({
...tab,
widgets: tab.widgets.map((widget) =>
widget.id === widgetId ? { ...widget, ...updates } : widget,
),
})),
}));
},
[],
);
return { updatePageLayoutWidget };
};