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:
+139
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
+135
@@ -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);
|
||||
});
|
||||
});
|
||||
+201
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
+41
@@ -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');
|
||||
});
|
||||
});
|
||||
+208
@@ -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);
|
||||
});
|
||||
});
|
||||
+96
@@ -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);
|
||||
});
|
||||
});
|
||||
+95
@@ -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);
|
||||
});
|
||||
});
|
||||
+73
@@ -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);
|
||||
});
|
||||
});
|
||||
+22
@@ -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 };
|
||||
};
|
||||
+89
@@ -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,
|
||||
};
|
||||
};
|
||||
+72
@@ -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 };
|
||||
};
|
||||
+14
@@ -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 };
|
||||
};
|
||||
Reference in New Issue
Block a user