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
@@ -1,98 +0,0 @@
import { GraphWidgetBarChart } from '@/dashboards/widgets/graph/components/GraphWidgetBarChart';
import { GraphWidgetGaugeChart } from '@/dashboards/widgets/graph/components/GraphWidgetGaugeChart';
import { GraphWidgetLineChart } from '@/dashboards/widgets/graph/components/GraphWidgetLineChart';
import { GraphWidgetNumberChart } from '@/dashboards/widgets/graph/components/GraphWidgetNumberChart';
import { GraphWidgetPieChart } from '@/dashboards/widgets/graph/components/GraphWidgetPieChart';
import { GraphType } from '../mocks/mockWidgets';
import { type PageLayoutWidget } from '../states/savedPageLayoutsState';
type GraphWidgetRendererProps = {
widget: PageLayoutWidget;
};
export const GraphWidgetRenderer = ({ widget }: GraphWidgetRendererProps) => {
const graphType = widget.configuration?.graphType;
if (!graphType || typeof graphType !== 'string') {
return null;
}
if (!Object.values(GraphType).includes(graphType as GraphType)) {
return null;
}
switch (graphType as GraphType) {
case GraphType.NUMBER:
return (
<GraphWidgetNumberChart
value={widget.data.value}
trendPercentage={widget.data.trendPercentage}
/>
);
case GraphType.GAUGE:
return (
<GraphWidgetGaugeChart
data={{
value: widget.data.value,
min: widget.data.min,
max: widget.data.max,
label: widget.data.label,
}}
displayType="percentage"
showValue
id={`gauge-chart-${widget.id}`}
/>
);
case GraphType.PIE:
return (
<GraphWidgetPieChart
data={widget.data.items}
showLegend
displayType="percentage"
id={`pie-chart-${widget.id}`}
/>
);
case GraphType.BAR:
return (
<GraphWidgetBarChart
data={widget.data.items}
indexBy={widget.data.indexBy}
keys={widget.data.keys}
seriesLabels={widget.data.seriesLabels}
layout={widget.data.layout}
showLegend
showGrid
displayType="number"
id={`bar-chart-${widget.id}`}
/>
);
case GraphType.LINE:
return (
<GraphWidgetLineChart
id={`line-chart-${widget.id}`}
data={widget.data.series}
enableArea={widget.data.enableArea}
showLegend={widget.data.showLegend}
showGrid={widget.data.showGrid}
enablePoints={widget.data.enablePoints}
xAxisLabel={widget.data.xAxisLabel}
yAxisLabel={widget.data.yAxisLabel}
displayType={widget.data.displayType}
prefix={widget.data.prefix}
suffix={widget.data.suffix}
xScale={widget.data.xScale}
yScale={widget.data.yScale}
curve={widget.data.curve}
stackedArea={widget.data.stackedArea}
enableSlices={widget.data.enableSlices}
/>
);
default:
return null;
}
};
@@ -1,113 +0,0 @@
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { useEffect, useState } from 'react';
import { useRecoilCallback, useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { v4 as uuidv4 } from 'uuid';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import {
pageLayoutCurrentLayoutsState,
type TabLayouts,
} from '../states/pageLayoutCurrentLayoutsState';
import { pageLayoutDraftState } from '../states/pageLayoutDraftState';
import { pageLayoutPersistedState } from '../states/pageLayoutPersistedState';
import {
PageLayoutType,
savedPageLayoutsState,
type SavedPageLayout,
} from '../states/savedPageLayoutsState';
type PageLayoutInitializationEffectProps = {
layoutId: string | undefined;
isEditMode: boolean;
};
export const PageLayoutInitializationEffect = ({
layoutId,
isEditMode,
}: PageLayoutInitializationEffectProps) => {
const [isInitialized, setIsInitialized] = useState(false);
const savedPageLayouts = useRecoilValue(savedPageLayoutsState);
const initializePageLayout = useRecoilCallback(
({ set, snapshot }) =>
(layout: SavedPageLayout | undefined) => {
const currentPersisted = getSnapshotValue(
snapshot,
pageLayoutPersistedState,
);
if (isDefined(layout)) {
if (!isDeeplyEqual(layout, currentPersisted)) {
set(pageLayoutPersistedState, layout);
set(pageLayoutDraftState, {
name: layout.name,
type: layout.type,
objectMetadataId: layout.objectMetadataId,
tabs: layout.tabs,
});
if (layout.tabs.length > 0) {
const tabLayouts: TabLayouts = {};
layout.tabs.forEach((tab) => {
const layouts = tab.widgets.map((w) => ({
i: w.id,
x: w.gridPosition.column,
y: w.gridPosition.row,
w: w.gridPosition.columnSpan,
h: w.gridPosition.rowSpan,
}));
tabLayouts[tab.id] = {
desktop: layouts,
mobile: layouts.map((l) => ({ ...l, w: 1, x: 0 })),
};
});
set(pageLayoutCurrentLayoutsState, tabLayouts);
} else {
set(pageLayoutCurrentLayoutsState, {});
}
}
} else {
const defaultTab = {
id: `tab-${uuidv4()}`,
title: 'Main',
position: 0,
pageLayoutId: '',
widgets: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
};
set(pageLayoutDraftState, {
name: '',
type: PageLayoutType.DASHBOARD,
objectMetadataId: null,
tabs: [defaultTab],
});
set(pageLayoutPersistedState, undefined);
set(pageLayoutCurrentLayoutsState, {
[defaultTab.id]: { desktop: [], mobile: [] },
});
}
},
[],
);
useEffect(() => {
if (!isInitialized) {
const existingLayout = isEditMode
? savedPageLayouts.find((l) => l.id === layoutId)
: undefined;
initializePageLayout(existingLayout);
setIsInitialized(true);
}
}, [
layoutId,
savedPageLayouts,
initializePageLayout,
isInitialized,
isEditMode,
]);
return null;
};
@@ -1,141 +0,0 @@
import styled from '@emotion/styled';
import { type ReactNode } from 'react';
import { IconGripVertical, IconPencil, IconX } from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
import {
AnimatedPlaceholder,
AnimatedPlaceholderEmptyContainer,
AnimatedPlaceholderEmptySubTitle,
AnimatedPlaceholderEmptyTextContainer,
AnimatedPlaceholderEmptyTitle,
EMPTY_PLACEHOLDER_TRANSITION_PROPS,
} from 'twenty-ui/layout';
const StyledPlaceholderContainer = styled.div<{ isEmpty?: boolean }>`
background: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.md};
box-sizing: border-box;
display: flex;
flex-direction: column;
height: 100%;
position: relative;
width: 100%;
padding: ${({ theme }) => theme.spacing(4)};
&:hover {
cursor: ${({ isEmpty }) => (isEmpty ? 'pointer' : 'default')};
border: 1px solid ${({ theme }) => theme.color.blue};
background: ${({ theme }) => theme.background.primary};
}
`;
const StyledHeader = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledDragHandleButton = styled(IconButton)`
cursor: grab;
display: flex;
align-items: center;
user-select: none;
&:active {
cursor: grabbing;
}
`;
const StyledTitle = styled.span`
color: ${({ theme }) => theme.font.color.secondary};
flex: 1;
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
user-select: none;
`;
const StyledContent = styled.div`
align-items: center;
display: flex;
height: 100%;
width: 100%;
justify-content: center;
`;
type PageLayoutWidgetPlaceholderProps = {
title?: string;
onRemove?: () => void;
onEdit?: () => void;
children?: ReactNode;
isEmpty?: boolean;
};
export const PageLayoutWidgetPlaceholder = ({
title = 'Graph Title',
onRemove,
onEdit,
children,
isEmpty = false,
}: PageLayoutWidgetPlaceholderProps) => {
if (isEmpty) {
return (
<StyledPlaceholderContainer isEmpty>
<StyledHeader>
<IconButton
Icon={IconGripVertical}
variant="tertiary"
size="small"
disabled
/>
<StyledTitle>Add Widget</StyledTitle>
</StyledHeader>
<AnimatedPlaceholderEmptyContainer
// eslint-disable-next-line react/jsx-props-no-spreading
{...EMPTY_PLACEHOLDER_TRANSITION_PROPS}
>
<AnimatedPlaceholder type="noWidgets" />
<AnimatedPlaceholderEmptyTextContainer>
<AnimatedPlaceholderEmptyTitle>
No widgets yet
</AnimatedPlaceholderEmptyTitle>
<AnimatedPlaceholderEmptySubTitle>
Click to add your first widget
</AnimatedPlaceholderEmptySubTitle>
</AnimatedPlaceholderEmptyTextContainer>
</AnimatedPlaceholderEmptyContainer>
</StyledPlaceholderContainer>
);
}
return (
<StyledPlaceholderContainer>
<StyledHeader>
<StyledDragHandleButton
Icon={IconGripVertical}
className="drag-handle"
variant="tertiary"
size="small"
/>
<StyledTitle>{title}</StyledTitle>
{onEdit && (
<IconButton
onClick={onEdit}
Icon={IconPencil}
variant="tertiary"
size="small"
/>
)}
{onRemove && (
<IconButton
onClick={onRemove}
Icon={IconX}
variant="tertiary"
size="small"
/>
)}
</StyledHeader>
<StyledContent>{children}</StyledContent>
</StyledPlaceholderContainer>
);
};
@@ -1,32 +0,0 @@
import { IframeWidget } from '@/dashboards/widgets/iframe/components/IframeWidget';
import { isString } from '@sniptt/guards';
import { WidgetType } from '../mocks/mockWidgets';
import { type PageLayoutWidget } from '../states/savedPageLayoutsState';
import { GraphWidgetRenderer } from './GraphWidgetRenderer';
type WidgetRendererProps = {
widget: PageLayoutWidget;
};
export const WidgetRenderer = ({ widget }: WidgetRendererProps) => {
switch (widget.type) {
case WidgetType.GRAPH:
return <GraphWidgetRenderer widget={widget} />;
case WidgetType.IFRAME: {
const url = widget.configuration?.url;
return (
<IframeWidget url={isString(url) ? url : ''} title={widget.title} />
);
}
case WidgetType.VIEW:
return null;
case WidgetType.FIELDS:
return null;
default:
return null;
}
};
@@ -1,313 +0,0 @@
import { GraphWidgetGaugeChart } from '@/dashboards/widgets/graph/components/GraphWidgetGaugeChart';
import { GraphWidgetNumberChart } from '@/dashboards/widgets/graph/components/GraphWidgetNumberChart';
import { GraphWidgetPieChart } from '@/dashboards/widgets/graph/components/GraphWidgetPieChart';
import { type Meta, type StoryObj } from '@storybook/react';
import { ComponentDecorator } from 'twenty-ui/testing';
import { PageLayoutWidgetPlaceholder } from '../PageLayoutWidgetPlaceholder';
const meta: Meta<typeof PageLayoutWidgetPlaceholder> = {
title: 'Modules/Settings/PageLayout/PageLayoutWidgetPlaceholder',
component: PageLayoutWidgetPlaceholder,
decorators: [ComponentDecorator],
parameters: {
layout: 'centered',
},
argTypes: {
title: {
control: 'text',
description: 'Widget title',
},
isEmpty: {
control: 'boolean',
description: 'Show empty state',
},
onRemove: {
action: 'onRemove',
},
},
};
export default meta;
type Story = StoryObj<typeof PageLayoutWidgetPlaceholder>;
export const WithNumberChart: Story = {
args: {
title: 'Sales Pipeline',
children: <GraphWidgetNumberChart value="1,234" trendPercentage={12.5} />,
},
render: (args) => (
<div style={{ width: '300px', height: '100px' }}>
<PageLayoutWidgetPlaceholder
title={args.title}
isEmpty={args.isEmpty}
onRemove={args.onRemove}
children={args.children}
/>
</div>
),
};
export const WithGaugeChart: Story = {
args: {
title: 'Conversion Rate',
children: (
<GraphWidgetGaugeChart
data={{
value: 0.5,
min: 0,
max: 1,
label: 'Conversion rate',
}}
displayType="percentage"
showValue
id="gauge-chart-story"
/>
),
},
render: (args) => (
<div style={{ width: '300px', height: '400px' }}>
<PageLayoutWidgetPlaceholder
title={args.title}
isEmpty={args.isEmpty}
onRemove={args.onRemove}
children={args.children}
/>
</div>
),
};
export const EmptyState: Story = {
args: {
isEmpty: true,
},
parameters: {
docs: {
description: {
story: 'Empty widget placeholder state with dashed border',
},
},
},
render: (args) => (
<div style={{ width: '500px', height: '300px' }}>
<PageLayoutWidgetPlaceholder
title={args.title}
isEmpty={args.isEmpty}
onRemove={args.onRemove}
children={args.children}
/>
</div>
),
};
export const WithPieChart: Story = {
args: {
title: 'Lead Distribution',
children: (
<GraphWidgetPieChart
data={[
{
id: 'qualified',
value: 35,
label: 'Qualified',
to: '/leads/qualified',
},
{
id: 'contacted',
value: 25,
label: 'Contacted',
to: '/leads/contacted',
},
{
id: 'unqualified',
value: 20,
label: 'Unqualified',
to: '/leads/unqualified',
},
{
id: 'proposal',
value: 15,
label: 'Proposal',
to: '/leads/proposal',
},
{
id: 'negotiation',
value: 5,
label: 'Negotiation',
to: '/leads/negotiation',
},
]}
showLegend
displayType="percentage"
id="pie-chart-story"
/>
),
},
render: (args) => (
<div style={{ width: '300px', height: '500px' }}>
<PageLayoutWidgetPlaceholder
title={args.title}
isEmpty={args.isEmpty}
onRemove={args.onRemove}
children={args.children}
/>
</div>
),
};
export const SmallWidget: Story = {
args: {
title: 'Small Widget (2x2 grid)',
children: <GraphWidgetNumberChart value="42" trendPercentage={5} />,
},
parameters: {
docs: {
description: {
story: 'Simulates a 2x2 grid cell widget',
},
},
},
render: (args) => (
<div style={{ width: '300px', height: '100px' }}>
<PageLayoutWidgetPlaceholder
title={args.title}
isEmpty={args.isEmpty}
onRemove={args.onRemove}
children={args.children}
/>
</div>
),
};
export const MediumWidget: Story = {
args: {
title: 'Medium Widget (4x3 grid)',
children: (
<GraphWidgetGaugeChart
data={{
value: 0.75,
min: 0,
max: 1,
label: 'Progress',
}}
displayType="percentage"
showValue
id="gauge-medium"
/>
),
},
parameters: {
docs: {
description: {
story: 'Simulates a 4x3 grid cell widget',
},
},
},
render: (args) => (
<div style={{ width: '400px', height: '250px' }}>
<PageLayoutWidgetPlaceholder
title={args.title}
isEmpty={args.isEmpty}
onRemove={args.onRemove}
children={args.children}
/>
</div>
),
};
export const LargeWidget: Story = {
args: {
title: 'Large Widget (6x4 grid)',
children: (
<GraphWidgetPieChart
data={[
{ id: 'a', value: 40, label: 'Category A', to: '/a' },
{ id: 'b', value: 30, label: 'Category B', to: '/b' },
{ id: 'c', value: 20, label: 'Category C', to: '/c' },
{ id: 'd', value: 10, label: 'Category D', to: '/d' },
]}
showLegend
displayType="percentage"
id="pie-large"
/>
),
},
parameters: {
docs: {
description: {
story: 'Simulates a 6x4 grid cell widget',
},
},
},
render: (args) => (
<div style={{ width: '600px', height: '400px' }}>
<PageLayoutWidgetPlaceholder
title={args.title}
isEmpty={args.isEmpty}
onRemove={args.onRemove}
children={args.children}
/>
</div>
),
};
export const WideWidget: Story = {
args: {
title: 'Wide Widget (8x2 grid)',
children: (
<GraphWidgetNumberChart value="1,234,567" trendPercentage={23.4} />
),
},
parameters: {
docs: {
description: {
story: 'Simulates a wide 8x2 grid cell widget',
},
},
},
render: (args) => (
<div style={{ width: '800px', height: '200px' }}>
<PageLayoutWidgetPlaceholder
title={args.title}
isEmpty={args.isEmpty}
onRemove={args.onRemove}
children={args.children}
/>
</div>
),
};
export const TallWidget: Story = {
args: {
title: 'Tall Widget (3x6 grid)',
children: (
<GraphWidgetGaugeChart
data={{
value: 0.33,
min: 0,
max: 1,
label: 'Utilization',
}}
displayType="percentage"
showValue
id="gauge-tall"
/>
),
},
parameters: {
docs: {
description: {
story: 'Simulates a tall 3x6 grid cell widget',
},
},
},
render: (args) => (
<div style={{ width: '300px', height: '500px' }}>
<PageLayoutWidgetPlaceholder
title={args.title}
isEmpty={args.isEmpty}
onRemove={args.onRemove}
children={args.children}
/>
</div>
),
};
@@ -1,6 +0,0 @@
import { type Layouts } from 'react-grid-layout';
export const EMPTY_LAYOUT: Layouts = {
desktop: [{ i: 'empty-placeholder', x: 0, y: 0, w: 4, h: 4, static: true }],
mobile: [{ i: 'empty-placeholder', x: 0, y: 0, w: 1, h: 4, static: true }],
};
@@ -1 +0,0 @@
export const GRID_BUFFER_ROWS = 10;
@@ -1 +0,0 @@
export const GRID_MIN_ROWS = 25;
@@ -1,14 +0,0 @@
import { MOBILE_VIEWPORT } from 'twenty-ui/theme';
export const PAGE_LAYOUT_CONFIG = {
breakpoints: {
desktop: MOBILE_VIEWPORT,
mobile: 0,
},
columns: {
desktop: 12,
mobile: 1,
},
} as const;
export type PageLayoutBreakpoint = 'desktop' | 'mobile';
@@ -1,2 +0,0 @@
export const SETTINGS_PAGE_LAYOUT_TABS_INSTANCE_ID =
'settings-page-layout-tabs';
@@ -1,139 +0,0 @@
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',
);
});
});
@@ -1,135 +0,0 @@
import { pageLayoutCurrentLayoutsState } from '@/settings/page-layout/states/pageLayoutCurrentLayoutsState';
import { pageLayoutDraftState } from '@/settings/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);
});
});
@@ -1,205 +0,0 @@
import { SETTINGS_PAGE_LAYOUT_TABS_INSTANCE_ID } from '@/settings/page-layout/constants/SettingsPageLayoutTabsInstanceId';
import {
GraphType,
WidgetType,
} from '@/settings/page-layout/mocks/mockWidgets';
import { pageLayoutCurrentLayoutsState } from '@/settings/page-layout/states/pageLayoutCurrentLayoutsState';
import { pageLayoutDraftState } from '@/settings/page-layout/states/pageLayoutDraftState';
import { PageLayoutType } from '@/settings/page-layout/states/savedPageLayoutsState';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { act, renderHook } from '@testing-library/react';
import { RecoilRoot, useRecoilValue, useSetRecoilState } from 'recoil';
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(widget.data).toBeDefined();
});
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,
);
});
});
@@ -1,41 +0,0 @@
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');
});
});
@@ -1,208 +0,0 @@
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);
});
});
@@ -1,99 +0,0 @@
import {
GraphType,
WidgetType,
} from '@/settings/page-layout/mocks/mockWidgets';
import { PageLayoutType } from '@/settings/page-layout/states/savedPageLayoutsState';
import { act, renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
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);
});
});
@@ -1,95 +0,0 @@
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);
});
});
@@ -1,73 +0,0 @@
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);
});
});
@@ -1,22 +0,0 @@
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 };
};
@@ -1,88 +0,0 @@
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useRecoilCallback } from 'recoil';
import { v4 as uuidv4 } from 'uuid';
import { SETTINGS_PAGE_LAYOUT_TABS_INSTANCE_ID } from '../constants/SettingsPageLayoutTabsInstanceId';
import { WidgetType } from '../mocks/mockWidgets';
import { pageLayoutCurrentLayoutsState } from '../states/pageLayoutCurrentLayoutsState';
import { pageLayoutDraftState } from '../states/pageLayoutDraftState';
import { pageLayoutDraggedAreaState } from '../states/pageLayoutDraggedAreaState';
import { type PageLayoutWidget } from '../states/savedPageLayoutsState';
import { addWidgetToTab } from '../utils/addWidgetToTab';
import { createUpdatedTabLayouts } from '../utils/createUpdatedTabLayouts';
import { getDefaultWidgetPosition } from '../utils/getDefaultWidgetPosition';
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: PageLayoutWidget = {
id: widgetId,
pageLayoutTabId: activeTabId,
title,
type: WidgetType.IFRAME,
gridPosition: {
row: position.y,
column: position.x,
rowSpan: position.h,
columnSpan: position.w,
},
configuration: {
url,
},
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 };
};
@@ -1,45 +0,0 @@
import { useRecoilCallback } from 'recoil';
import { v4 as uuidv4 } from 'uuid';
import { pageLayoutCurrentLayoutsState } from '../states/pageLayoutCurrentLayoutsState';
import { pageLayoutDraftState } from '../states/pageLayoutDraftState';
import { type PageLayoutTab } from '../states/savedPageLayoutsState';
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 newTab: PageLayoutTab = {
id: newTabId,
title: title || `Tab ${pageLayoutDraft.tabs.length + 1}`,
position: pageLayoutDraft.tabs.length,
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 };
};
@@ -1,106 +0,0 @@
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 { SETTINGS_PAGE_LAYOUT_TABS_INSTANCE_ID } from '../constants/SettingsPageLayoutTabsInstanceId';
import { type GraphType, type WidgetType } from '../mocks/mockWidgets';
import { pageLayoutCurrentLayoutsState } from '../states/pageLayoutCurrentLayoutsState';
import { pageLayoutDraftState } from '../states/pageLayoutDraftState';
import { pageLayoutDraggedAreaState } from '../states/pageLayoutDraggedAreaState';
import { type PageLayoutWidget } from '../states/savedPageLayoutsState';
import { addWidgetToTab } from '../utils/addWidgetToTab';
import { createUpdatedTabLayouts } from '../utils/createUpdatedTabLayouts';
import {
getDefaultWidgetData,
getWidgetSize,
getWidgetTitle,
} from '../utils/getDefaultWidgetData';
import { getDefaultWidgetPosition } from '../utils/getDefaultWidgetPosition';
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: PageLayoutWidget = {
id: widgetId,
pageLayoutTabId: activeTabId,
title,
type: widgetType,
gridPosition: {
row: position.y,
column: position.x,
rowSpan: position.h,
columnSpan: position.w,
},
configuration: {
graphType,
},
data: widgetData,
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 };
};
@@ -1,42 +0,0 @@
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 };
};
@@ -1,43 +0,0 @@
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 };
};
@@ -1,28 +0,0 @@
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,
};
};
@@ -1,72 +0,0 @@
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 PageLayoutWidget } from '../states/savedPageLayoutsState';
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: PageLayoutWidget[] = 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 };
};
@@ -1,73 +0,0 @@
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';
import {
savedPageLayoutsState,
type PageLayoutWidget,
type SavedPageLayout,
} from '../states/savedPageLayoutsState';
export const usePageLayoutSaveHandler = () => {
const navigateSettings = useNavigateSettings();
const { id } = useParams<{ id: string }>();
const isEditMode = id && id !== 'new';
const savePageLayout = useRecoilCallback(
({ snapshot, set }) =>
async (widgetsWithPositions?: PageLayoutWidget[]) => {
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: SavedPageLayout = {
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 };
};
@@ -1,14 +0,0 @@
import { useRecoilCallback } from 'recoil';
import { pageLayoutSelectedCellsState } from '../states/pageLayoutSelectedCellsState';
export const useStartPageLayoutDragSelection = () => {
const startPageLayoutDragSelection = useRecoilCallback(
({ set }) =>
() => {
set(pageLayoutSelectedCellsState, new Set());
},
[],
);
return { startPageLayoutDragSelection };
};
@@ -1,23 +0,0 @@
import { useRecoilCallback } from 'recoil';
import { type PageLayoutWidget } from '../states/savedPageLayoutsState';
import { pageLayoutDraftState } from '../states/pageLayoutDraftState';
export const useUpdatePageLayoutWidget = () => {
const updatePageLayoutWidget = useRecoilCallback(
({ set }) =>
(widgetId: string, updates: Partial<PageLayoutWidget>) => {
set(pageLayoutDraftState, (prev) => ({
...prev,
tabs: prev.tabs.map((tab) => ({
...tab,
widgets: tab.widgets.map((widget) =>
widget.id === widgetId ? { ...widget, ...updates } : widget,
),
})),
}));
},
[],
);
return { updatePageLayoutWidget };
};
@@ -1,249 +0,0 @@
import { type Layouts } from 'react-grid-layout';
import { type PageLayoutWidget } from '../states/savedPageLayoutsState';
export enum WidgetType {
VIEW = 'VIEW',
IFRAME = 'IFRAME',
FIELDS = 'FIELDS',
GRAPH = 'GRAPH',
}
export enum GraphType {
NUMBER = 'NUMBER',
GAUGE = 'GAUGE',
PIE = 'PIE',
BAR = 'BAR',
LINE = 'LINE',
}
export const mockPageLayoutWidgets: PageLayoutWidget[] = [
{
id: 'widget-1',
pageLayoutTabId: 'tab-overview',
type: WidgetType.GRAPH,
title: 'Sales Pipeline',
objectMetadataId: null,
gridPosition: {
row: 0,
column: 0,
rowSpan: 2,
columnSpan: 3,
},
configuration: {
graphType: GraphType.NUMBER,
},
data: {
value: '1,234',
trendPercentage: 12.5,
},
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
deletedAt: null,
},
{
id: 'widget-2',
pageLayoutTabId: 'tab-overview',
type: WidgetType.GRAPH,
title: 'Conversion Rate',
objectMetadataId: null,
gridPosition: {
row: 0,
column: 6,
rowSpan: 5,
columnSpan: 3,
},
configuration: {
graphType: GraphType.GAUGE,
},
data: {
value: 0.5,
min: 0,
max: 1,
label: 'Conversion rate',
},
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
deletedAt: null,
},
{
id: 'widget-3',
pageLayoutTabId: 'tab-analytics',
type: WidgetType.GRAPH,
title: 'Lead Distribution',
objectMetadataId: null,
gridPosition: {
row: 2,
column: 0,
rowSpan: 5,
columnSpan: 6,
},
configuration: {
graphType: GraphType.PIE,
},
data: {
items: [
{
id: 'qualified',
value: 35,
label: 'Qualified',
to: '/leads/qualified',
},
{
id: 'contacted',
value: 25,
label: 'Contacted',
to: '/leads/contacted',
},
{
id: 'unqualified',
value: 20,
label: 'Unqualified',
to: '/leads/unqualified',
},
{
id: 'proposal',
value: 15,
label: 'Proposal',
to: '/leads/proposal',
},
{
id: 'negotiation',
value: 5,
label: 'Negotiation',
to: '/leads/negotiation',
},
],
},
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
deletedAt: null,
},
{
id: 'widget-4',
pageLayoutTabId: 'tab-reports',
type: WidgetType.GRAPH,
title: 'Monthly Performance',
objectMetadataId: null,
gridPosition: {
row: 0,
column: 9,
rowSpan: 8,
columnSpan: 4,
},
configuration: {
graphType: GraphType.BAR,
},
data: {
items: [
{
month: 'Jan',
sales: 120,
leads: 45,
conversions: 12,
to: '/metrics/january',
},
{
month: 'Feb',
sales: 150,
leads: 52,
conversions: 15,
to: '/metrics/february',
},
{
month: 'Mar',
sales: 180,
leads: 48,
conversions: 18,
to: '/metrics/march',
},
{
month: 'Apr',
sales: 140,
leads: 60,
conversions: 14,
to: '/metrics/april',
},
{
month: 'May',
sales: 200,
leads: 55,
conversions: 20,
to: '/metrics/may',
},
],
indexBy: 'month',
keys: ['sales', 'leads', 'conversions'],
showLegend: true,
showGrid: true,
displayType: 'number',
xAxisLabel: 'Month',
yAxisLabel: 'Count',
},
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
deletedAt: null,
},
];
export const mockLayouts: Layouts = {
desktop: [
{
i: 'widget-1',
x: 0,
y: 0,
w: 3,
h: 2,
},
{
i: 'widget-2',
x: 6,
y: 0,
w: 3,
h: 5,
},
{
i: 'widget-3',
x: 0,
y: 2,
w: 6,
h: 5,
},
{
i: 'widget-4',
x: 9,
y: 0,
w: 4,
h: 8,
},
],
mobile: [
{
i: 'widget-1',
x: 0,
y: 0,
w: 1,
h: 2,
},
{
i: 'widget-2',
x: 0,
y: 2,
w: 1,
h: 5,
},
{
i: 'widget-3',
x: 0,
y: 7,
w: 1,
h: 5,
},
{
i: 'widget-4',
x: 0,
y: 12,
w: 1,
h: 5,
},
],
};
@@ -1,8 +0,0 @@
import { createState } from 'twenty-ui/utilities';
import { type PageLayoutBreakpoint } from '../constants/PageLayoutBreakpoints';
export const pageLayoutCurrentBreakpointState =
createState<PageLayoutBreakpoint>({
key: 'pageLayoutCurrentBreakpointState',
defaultValue: 'desktop',
});
@@ -1,9 +0,0 @@
import { type Layouts } from 'react-grid-layout';
import { createState } from 'twenty-ui/utilities';
export type TabLayouts = Record<string, Layouts>;
export const pageLayoutCurrentLayoutsState = createState<TabLayouts>({
key: 'pageLayoutCurrentLayoutsState',
defaultValue: {},
});
@@ -1,17 +0,0 @@
import { createState } from 'twenty-ui/utilities';
import { PageLayoutType, type SavedPageLayout } from './savedPageLayoutsState';
export type DraftPageLayout = Omit<
SavedPageLayout,
'id' | 'createdAt' | 'updatedAt' | 'deletedAt'
>;
export const pageLayoutDraftState = createState<DraftPageLayout>({
key: 'pageLayoutDraftState',
defaultValue: {
name: '',
type: PageLayoutType.DASHBOARD,
objectMetadataId: null,
tabs: [],
},
});
@@ -1,13 +0,0 @@
import { createState } from 'twenty-ui/utilities';
type DraggedArea = {
x: number;
y: number;
w: number;
h: number;
} | null;
export const pageLayoutDraggedAreaState = createState<DraggedArea>({
key: 'pageLayoutDraggedAreaState',
defaultValue: null,
});
@@ -1,6 +0,0 @@
import { createState } from 'twenty-ui/utilities';
export const pageLayoutEditingWidgetIdState = createState<string | null>({
key: 'pageLayoutEditingWidgetIdState',
defaultValue: null,
});
@@ -1,9 +0,0 @@
import { createState } from 'twenty-ui/utilities';
import { type SavedPageLayout } from './savedPageLayoutsState';
export const pageLayoutPersistedState = createState<
SavedPageLayout | undefined
>({
key: 'pageLayoutPersistedState',
defaultValue: undefined,
});
@@ -1,6 +0,0 @@
import { createState } from 'twenty-ui/utilities';
export const pageLayoutSelectedCellsState = createState<Set<string>>({
key: 'pageLayoutSelectedCellsState',
defaultValue: new Set(),
});
@@ -1,56 +0,0 @@
import { type WidgetType } from '@/settings/page-layout/mocks/mockWidgets';
import { createState } from 'twenty-ui/utilities';
export enum PageLayoutType {
DASHBOARD = 'DASHBOARD',
RECORD_INDEX = 'RECORD_INDEX',
RECORD_PAGE = 'RECORD_PAGE',
}
export type GridPosition = {
row: number;
column: number;
rowSpan: number;
columnSpan: number;
};
export type PageLayoutWidget = {
id: string;
pageLayoutTabId: string;
title: string;
type: WidgetType;
objectMetadataId?: string | null;
gridPosition: GridPosition;
configuration?: Record<string, unknown> | null;
data?: any;
createdAt: string;
updatedAt: string;
deletedAt?: string | null;
};
export type PageLayoutTab = {
id: string;
title: string;
position: number;
pageLayoutId: string;
widgets: PageLayoutWidget[];
createdAt: string;
updatedAt: string;
deletedAt?: string | null;
};
export type SavedPageLayout = {
id: string;
name: string;
type: PageLayoutType;
objectMetadataId?: string | null;
tabs: PageLayoutTab[];
createdAt: string;
updatedAt: string;
deletedAt?: string | null;
};
export const savedPageLayoutsState = createState<SavedPageLayout[]>({
key: 'savedPageLayoutsState',
defaultValue: [],
});
@@ -1,89 +0,0 @@
import { WidgetType } from '../../mocks/mockWidgets';
import {
type PageLayoutTab,
type PageLayoutWidget,
} from '../../states/savedPageLayoutsState';
import { addWidgetToTab } from '../addWidgetToTab';
describe('addWidgetToTab', () => {
const mockWidget: PageLayoutWidget = {
id: 'widget-1',
pageLayoutTabId: 'tab-1',
title: 'Test Widget',
type: WidgetType.GRAPH,
gridPosition: { row: 0, column: 0, rowSpan: 2, columnSpan: 2 },
objectMetadataId: null,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
deletedAt: null,
};
const mockTabs: PageLayoutTab[] = [
{
id: 'tab-1',
title: 'Tab 1',
position: 0,
pageLayoutId: 'layout-1',
widgets: [],
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
deletedAt: null,
},
{
id: 'tab-2',
title: 'Tab 2',
position: 1,
pageLayoutId: 'layout-1',
widgets: [],
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
deletedAt: null,
},
];
it('should add widget to the correct tab', () => {
const result = addWidgetToTab(mockTabs, 'tab-1', mockWidget);
expect(result[0].widgets).toHaveLength(1);
expect(result[0].widgets[0]).toEqual(mockWidget);
expect(result[1].widgets).toHaveLength(0);
});
it('should not modify other tabs', () => {
const result = addWidgetToTab(mockTabs, 'tab-1', mockWidget);
expect(result[1]).toEqual(mockTabs[1]);
expect(result[1].widgets).toHaveLength(0);
});
it('should handle non-existent tab ID gracefully', () => {
const result = addWidgetToTab(mockTabs, 'non-existent-tab', mockWidget);
// All tabs should remain unchanged
expect(result[0].widgets).toHaveLength(0);
expect(result[1].widgets).toHaveLength(0);
});
it('should add multiple widgets to the same tab', () => {
const secondWidget: PageLayoutWidget = {
...mockWidget,
id: 'widget-2',
title: 'Second Widget',
};
let result = addWidgetToTab(mockTabs, 'tab-1', mockWidget);
result = addWidgetToTab(result, 'tab-1', secondWidget);
expect(result[0].widgets).toHaveLength(2);
expect(result[0].widgets[0]).toEqual(mockWidget);
expect(result[0].widgets[1]).toEqual(secondWidget);
});
it('should return a new array without mutating the original', () => {
const result = addWidgetToTab(mockTabs, 'tab-1', mockWidget);
expect(result).not.toBe(mockTabs);
expect(mockTabs[0].widgets).toHaveLength(0);
expect(result[0].widgets).toHaveLength(1);
});
});
@@ -1,138 +0,0 @@
import { calculateGridBoundsFromSelectedCells } from '../calculateGridBoundsFromSelectedCells';
describe('calculateGridBoundsFromSelectedCells', () => {
it('should return null for empty array', () => {
expect(calculateGridBoundsFromSelectedCells([])).toBeNull();
});
it('should calculate bounds for single cell', () => {
expect(calculateGridBoundsFromSelectedCells(['cell-2-3'])).toEqual({
x: 2,
y: 3,
w: 1,
h: 1,
});
});
it('should calculate bounds for rectangular selection', () => {
expect(
calculateGridBoundsFromSelectedCells([
'cell-1-1',
'cell-2-1',
'cell-1-2',
'cell-2-2',
]),
).toEqual({
x: 1,
y: 1,
w: 2,
h: 2,
});
});
it('should handle non-contiguous selection', () => {
expect(
calculateGridBoundsFromSelectedCells([
'cell-0-0',
'cell-5-3',
'cell-2-1',
]),
).toEqual({
x: 0,
y: 0,
w: 6,
h: 4,
});
});
it('should handle large grid selections', () => {
expect(
calculateGridBoundsFromSelectedCells(['cell-0-0', 'cell-11-24']),
).toEqual({
x: 0,
y: 0,
w: 12,
h: 25,
});
});
it('should handle single row selection', () => {
expect(
calculateGridBoundsFromSelectedCells([
'cell-0-5',
'cell-1-5',
'cell-2-5',
'cell-3-5',
]),
).toEqual({
x: 0,
y: 5,
w: 4,
h: 1,
});
});
it('should handle single column selection', () => {
expect(
calculateGridBoundsFromSelectedCells([
'cell-3-0',
'cell-3-1',
'cell-3-2',
'cell-3-3',
]),
).toEqual({
x: 3,
y: 0,
w: 1,
h: 4,
});
});
it('should handle duplicate cell IDs in selection', () => {
expect(
calculateGridBoundsFromSelectedCells([
'cell-1-1',
'cell-1-1',
'cell-2-2',
'cell-2-2',
]),
).toEqual({
x: 1,
y: 1,
w: 2,
h: 2,
});
});
it('should handle L-shaped selection', () => {
expect(
calculateGridBoundsFromSelectedCells([
'cell-0-0',
'cell-1-0',
'cell-0-1',
'cell-0-2',
]),
).toEqual({
x: 0,
y: 0,
w: 2,
h: 3,
});
});
it('should handle sparse diagonal selection', () => {
expect(
calculateGridBoundsFromSelectedCells([
'cell-0-0',
'cell-1-1',
'cell-2-2',
'cell-3-3',
]),
).toEqual({
x: 0,
y: 0,
w: 4,
h: 4,
});
});
});
@@ -1,40 +0,0 @@
import { GRID_MIN_ROWS } from '../../constants/GridMinRows';
import { calculateTotalGridRows } from '../calculateTotalGridRows';
describe('calculateTotalGridRows', () => {
it('should return minimum rows for empty layouts', () => {
expect(calculateTotalGridRows({})).toBe(GRID_MIN_ROWS);
});
it('should calculate rows based on content when exceeding minimum', () => {
const layouts = {
desktop: [
{ i: '1', x: 0, y: 0, w: 2, h: 2 },
{ i: '2', x: 2, y: 20, w: 2, h: 3 },
],
};
expect(calculateTotalGridRows(layouts)).toBe(33);
});
it('should respect minimum rows even with content', () => {
const layouts = {
desktop: [{ i: '1', x: 0, y: 0, w: 1, h: 1 }],
};
expect(calculateTotalGridRows(layouts)).toBe(GRID_MIN_ROWS);
});
it('should handle custom min and buffer values', () => {
const layouts = {
desktop: [{ i: '1', x: 0, y: 10, w: 1, h: 5 }],
};
expect(calculateTotalGridRows(layouts, 10, 5)).toBe(20);
});
it('should consider both desktop and mobile layouts', () => {
const layouts = {
desktop: [{ i: '1', x: 0, y: 5, w: 2, h: 2 }],
mobile: [{ i: '1', x: 0, y: 25, w: 1, h: 3 }],
};
expect(calculateTotalGridRows(layouts)).toBe(38);
});
});
@@ -1,102 +0,0 @@
import { GraphType, WidgetType } from '../../mocks/mockWidgets';
import { type PageLayoutWidget } from '../../states/savedPageLayoutsState';
import { convertLayoutsToWidgets } from '../convertLayoutsToWidgets';
describe('convertLayoutsToWidgets', () => {
const mockWidgets: PageLayoutWidget[] = [
{
id: 'widget-1',
pageLayoutTabId: 'tab-1',
title: 'Widget 1',
type: WidgetType.GRAPH,
objectMetadataId: null,
gridPosition: {
row: 0,
column: 0,
rowSpan: 2,
columnSpan: 2,
},
configuration: {
graphType: GraphType.NUMBER,
},
data: { value: 100 },
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
deletedAt: null,
},
{
id: 'widget-2',
pageLayoutTabId: 'tab-1',
title: 'Widget 2',
type: WidgetType.GRAPH,
objectMetadataId: null,
gridPosition: {
row: 0,
column: 2,
rowSpan: 2,
columnSpan: 2,
},
configuration: {
graphType: GraphType.PIE,
},
data: { items: [] },
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
deletedAt: null,
},
];
it('should map layout positions to widgets', () => {
const layouts = {
desktop: [
{ i: 'widget-1', x: 2, y: 3, w: 4, h: 5 },
{ i: 'widget-2', x: 6, y: 7, w: 8, h: 9 },
],
};
const result = convertLayoutsToWidgets(mockWidgets, layouts);
expect(result[0].gridPosition).toEqual({
column: 2,
row: 3,
columnSpan: 4,
rowSpan: 5,
});
expect(result[1].gridPosition).toEqual({
column: 6,
row: 7,
columnSpan: 8,
rowSpan: 9,
});
});
it('should use defaults when layout not found', () => {
const layouts = {
desktop: [{ i: 'widget-1', x: 1, y: 1, w: 1, h: 1 }],
};
const result = convertLayoutsToWidgets(mockWidgets, layouts);
expect(result[1].gridPosition).toEqual({
column: 0,
row: 0,
columnSpan: 2,
rowSpan: 2,
});
});
it('should handle mobile layout', () => {
const layouts = {
mobile: [{ i: 'widget-1', x: 0, y: 4, w: 1, h: 6 }],
};
const result = convertLayoutsToWidgets(mockWidgets, layouts);
expect(result[0].gridPosition).toEqual({
column: 0,
row: 4,
columnSpan: 1,
rowSpan: 6,
});
});
});
@@ -1,79 +0,0 @@
import { type TabLayouts } from '../../states/pageLayoutCurrentLayoutsState';
import { createEmptyTabLayout } from '../createEmptyTabLayout';
describe('createEmptyTabLayout', () => {
const mockTabLayouts: TabLayouts = {
'tab-1': {
desktop: [{ i: 'widget-1', x: 0, y: 0, w: 2, h: 2 }],
mobile: [{ i: 'widget-1', x: 0, y: 0, w: 1, h: 2 }],
},
'tab-2': {
desktop: [{ i: 'widget-2', x: 0, y: 0, w: 3, h: 3 }],
mobile: [{ i: 'widget-2', x: 0, y: 0, w: 1, h: 3 }],
},
};
it('should create empty layout for a new tab', () => {
const result = createEmptyTabLayout(mockTabLayouts, 'tab-3');
expect(result['tab-3']).toBeDefined();
expect(result['tab-3'].desktop).toEqual([]);
expect(result['tab-3'].mobile).toEqual([]);
});
it('should preserve existing tabs', () => {
const result = createEmptyTabLayout(mockTabLayouts, 'tab-3');
expect(result['tab-1']).toEqual(mockTabLayouts['tab-1']);
expect(result['tab-2']).toEqual(mockTabLayouts['tab-2']);
expect(Object.keys(result)).toHaveLength(3);
});
it('should overwrite existing tab with empty layout', () => {
const result = createEmptyTabLayout(mockTabLayouts, 'tab-1');
expect(result['tab-1'].desktop).toEqual([]);
expect(result['tab-1'].mobile).toEqual([]);
expect(result['tab-2']).toEqual(mockTabLayouts['tab-2']);
});
it('should work with empty initial state', () => {
const emptyLayouts: TabLayouts = {};
const result = createEmptyTabLayout(emptyLayouts, 'tab-1');
expect(result['tab-1']).toBeDefined();
expect(result['tab-1'].desktop).toEqual([]);
expect(result['tab-1'].mobile).toEqual([]);
expect(Object.keys(result)).toHaveLength(1);
});
it('should return a new object without mutating the original', () => {
const originalLayouts = JSON.parse(JSON.stringify(mockTabLayouts));
const result = createEmptyTabLayout(mockTabLayouts, 'tab-3');
expect(result).not.toBe(mockTabLayouts);
expect(mockTabLayouts).toEqual(originalLayouts);
expect(Object.keys(mockTabLayouts)).toHaveLength(2);
expect(Object.keys(result)).toHaveLength(3);
});
it('should handle multiple new tabs', () => {
let result = createEmptyTabLayout(mockTabLayouts, 'tab-3');
result = createEmptyTabLayout(result, 'tab-4');
result = createEmptyTabLayout(result, 'tab-5');
expect(Object.keys(result)).toHaveLength(5);
expect(result['tab-3'].desktop).toEqual([]);
expect(result['tab-4'].desktop).toEqual([]);
expect(result['tab-5'].desktop).toEqual([]);
});
it('should create consistent structure for desktop and mobile', () => {
const result = createEmptyTabLayout(mockTabLayouts, 'new-tab');
expect(result['new-tab']).toHaveProperty('desktop');
expect(result['new-tab']).toHaveProperty('mobile');
expect(Array.isArray(result['new-tab'].desktop)).toBe(true);
expect(Array.isArray(result['new-tab'].mobile)).toBe(true);
});
});
@@ -1,82 +0,0 @@
import { type TabLayouts } from '../../states/pageLayoutCurrentLayoutsState';
import { createUpdatedTabLayouts } from '../createUpdatedTabLayouts';
describe('createUpdatedTabLayouts', () => {
const mockTabLayouts: TabLayouts = {
'tab-1': {
desktop: [{ i: 'widget-1', x: 0, y: 0, w: 2, h: 2 }],
mobile: [{ i: 'widget-1', x: 0, y: 0, w: 1, h: 2 }],
},
'tab-2': {
desktop: [],
mobile: [],
},
};
const newLayout = { i: 'widget-2', x: 2, y: 0, w: 3, h: 3 };
it('should add new layout to existing tab', () => {
const result = createUpdatedTabLayouts(mockTabLayouts, 'tab-1', newLayout);
expect(result['tab-1'].desktop).toHaveLength(2);
expect(result['tab-1'].desktop[1]).toEqual(newLayout);
expect(result['tab-1'].mobile).toHaveLength(2);
expect(result['tab-1'].mobile[1]).toEqual({ ...newLayout, w: 1, x: 0 });
});
it('should add layout to empty tab', () => {
const result = createUpdatedTabLayouts(mockTabLayouts, 'tab-2', newLayout);
expect(result['tab-2'].desktop).toHaveLength(1);
expect(result['tab-2'].desktop[0]).toEqual(newLayout);
expect(result['tab-2'].mobile).toHaveLength(1);
expect(result['tab-2'].mobile[0]).toEqual({ ...newLayout, w: 1, x: 0 });
});
it('should create new tab entry if tab does not exist', () => {
const result = createUpdatedTabLayouts(mockTabLayouts, 'tab-3', newLayout);
expect(result['tab-3']).toBeDefined();
expect(result['tab-3'].desktop).toHaveLength(1);
expect(result['tab-3'].desktop[0]).toEqual(newLayout);
expect(result['tab-3'].mobile).toHaveLength(1);
});
it('should not modify other tabs', () => {
const result = createUpdatedTabLayouts(mockTabLayouts, 'tab-1', newLayout);
expect(result['tab-2']).toEqual(mockTabLayouts['tab-2']);
});
it('should handle mobile layout transformation correctly', () => {
const wideLayout = { i: 'widget-3', x: 5, y: 2, w: 6, h: 4 };
const result = createUpdatedTabLayouts(mockTabLayouts, 'tab-1', wideLayout);
const mobileLayout =
result['tab-1'].mobile[result['tab-1'].mobile.length - 1];
expect(mobileLayout.w).toBe(1);
expect(mobileLayout.x).toBe(0);
expect(mobileLayout.y).toBe(wideLayout.y);
expect(mobileLayout.h).toBe(wideLayout.h);
});
it('should return a new object without mutating the original', () => {
const originalLayouts = JSON.parse(JSON.stringify(mockTabLayouts));
const result = createUpdatedTabLayouts(mockTabLayouts, 'tab-1', newLayout);
expect(result).not.toBe(mockTabLayouts);
expect(mockTabLayouts).toEqual(originalLayouts);
expect(result['tab-1'].desktop).toHaveLength(2);
expect(mockTabLayouts['tab-1'].desktop).toHaveLength(1);
});
it('should throw an error for malformed tab layouts', () => {
const malformedLayouts: TabLayouts = {
'tab-1': {} as any,
};
expect(() => {
createUpdatedTabLayouts(malformedLayouts, 'tab-1', newLayout);
}).toThrow();
});
});
@@ -1,15 +0,0 @@
import { generateCellId } from '../generateCellId';
describe('generateCellId', () => {
it('should generate cell ID with correct format', () => {
expect(generateCellId(3, 5)).toBe('cell-3-5');
});
it('should handle zero values', () => {
expect(generateCellId(0, 0)).toBe('cell-0-0');
});
it('should handle large numbers', () => {
expect(generateCellId(100, 200)).toBe('cell-100-200');
});
});
@@ -1,23 +0,0 @@
import { getDefaultWidgetPosition } from '../getDefaultWidgetPosition';
describe('getDefaultWidgetPosition', () => {
it('should return dragged area when provided', () => {
const draggedArea = { x: 2, y: 3, w: 4, h: 5 };
const defaultSize = { w: 2, h: 2 };
expect(getDefaultWidgetPosition(draggedArea, defaultSize)).toEqual(
draggedArea,
);
});
it('should return default position with size when no dragged area', () => {
const defaultSize = { w: 3, h: 4 };
expect(getDefaultWidgetPosition(null, defaultSize)).toEqual({
x: 0,
y: 0,
w: 3,
h: 4,
});
});
});
@@ -1,18 +0,0 @@
import { parseCellIdToCoordinates } from '../parseCellIdToCoordinates';
describe('parseCellIdToCoordinates', () => {
it('should parse cell ID correctly', () => {
expect(parseCellIdToCoordinates('cell-3-5')).toEqual({ col: 3, row: 5 });
});
it('should handle zero coordinates', () => {
expect(parseCellIdToCoordinates('cell-0-0')).toEqual({ col: 0, row: 0 });
});
it('should handle double-digit coordinates', () => {
expect(parseCellIdToCoordinates('cell-12-25')).toEqual({
col: 12,
row: 25,
});
});
});
@@ -1,109 +0,0 @@
import { WidgetType } from '../../mocks/mockWidgets';
import { type PageLayoutTab } from '../../states/savedPageLayoutsState';
import { removeWidgetFromTab } from '../removeWidgetFromTab';
describe('removeWidgetFromTab', () => {
const mockTabs: PageLayoutTab[] = [
{
id: 'tab-1',
title: 'Tab 1',
position: 0,
pageLayoutId: 'layout-1',
widgets: [
{
id: 'widget-1',
pageLayoutTabId: 'tab-1',
title: 'Widget 1',
type: WidgetType.GRAPH,
gridPosition: { row: 0, column: 0, rowSpan: 2, columnSpan: 2 },
objectMetadataId: null,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
deletedAt: null,
},
{
id: 'widget-2',
pageLayoutTabId: 'tab-1',
title: 'Widget 2',
type: WidgetType.GRAPH,
gridPosition: { row: 2, column: 0, rowSpan: 2, columnSpan: 2 },
objectMetadataId: null,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
deletedAt: null,
},
],
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
deletedAt: null,
},
{
id: 'tab-2',
title: 'Tab 2',
position: 1,
pageLayoutId: 'layout-1',
widgets: [
{
id: 'widget-3',
pageLayoutTabId: 'tab-2',
title: 'Widget 3',
type: WidgetType.IFRAME,
gridPosition: { row: 0, column: 0, rowSpan: 2, columnSpan: 2 },
objectMetadataId: null,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
deletedAt: null,
},
],
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
deletedAt: null,
},
];
it('should remove widget from the correct tab', () => {
const result = removeWidgetFromTab(mockTabs, 'tab-1', 'widget-1');
expect(result[0].widgets).toHaveLength(1);
expect(result[0].widgets[0].id).toBe('widget-2');
expect(result[1].widgets).toHaveLength(1);
expect(result[1].widgets[0].id).toBe('widget-3');
});
it('should not affect other tabs', () => {
const result = removeWidgetFromTab(mockTabs, 'tab-1', 'widget-1');
expect(result[1]).toEqual(mockTabs[1]);
expect(result[1].widgets).toHaveLength(1);
});
it('should handle removing non-existent widget gracefully', () => {
const result = removeWidgetFromTab(mockTabs, 'tab-1', 'non-existent');
expect(result[0].widgets).toHaveLength(2);
expect(result[0].widgets).toEqual(mockTabs[0].widgets);
});
it('should handle removing from non-existent tab gracefully', () => {
const result = removeWidgetFromTab(mockTabs, 'non-existent', 'widget-1');
expect(result).toEqual(mockTabs);
});
it('should remove all widgets if called multiple times', () => {
let result = removeWidgetFromTab(mockTabs, 'tab-1', 'widget-1');
result = removeWidgetFromTab(result, 'tab-1', 'widget-2');
expect(result[0].widgets).toHaveLength(0);
expect(result[1].widgets).toHaveLength(1);
});
it('should return a new array without mutating the original', () => {
const originalTabs = structuredClone(mockTabs);
const result = removeWidgetFromTab(mockTabs, 'tab-1', 'widget-1');
expect(result).not.toBe(mockTabs);
expect(mockTabs).toEqual(originalTabs);
expect(result[0].widgets).toHaveLength(1);
});
});
@@ -1,126 +0,0 @@
import { type TabLayouts } from '../../states/pageLayoutCurrentLayoutsState';
import { removeWidgetLayoutFromTab } from '../removeWidgetLayoutFromTab';
describe('removeWidgetLayoutFromTab', () => {
const mockTabLayouts: TabLayouts = {
'tab-1': {
desktop: [
{ i: 'widget-1', x: 0, y: 0, w: 2, h: 2 },
{ i: 'widget-2', x: 2, y: 0, w: 3, h: 3 },
{ i: 'widget-3', x: 5, y: 0, w: 2, h: 2 },
],
mobile: [
{ i: 'widget-1', x: 0, y: 0, w: 1, h: 2 },
{ i: 'widget-2', x: 0, y: 2, w: 1, h: 3 },
{ i: 'widget-3', x: 0, y: 5, w: 1, h: 2 },
],
},
'tab-2': {
desktop: [{ i: 'widget-4', x: 0, y: 0, w: 4, h: 4 }],
mobile: [{ i: 'widget-4', x: 0, y: 0, w: 1, h: 4 }],
},
};
it('should remove widget layout from the correct tab', () => {
const result = removeWidgetLayoutFromTab(
mockTabLayouts,
'tab-1',
'widget-2',
);
expect(result['tab-1'].desktop).toHaveLength(2);
expect(result['tab-1'].desktop.map((l) => l.i)).toEqual([
'widget-1',
'widget-3',
]);
expect(result['tab-1'].mobile).toHaveLength(2);
expect(result['tab-1'].mobile.map((l) => l.i)).toEqual([
'widget-1',
'widget-3',
]);
});
it('should not affect other tabs', () => {
const result = removeWidgetLayoutFromTab(
mockTabLayouts,
'tab-1',
'widget-1',
);
expect(result['tab-2']).toEqual(mockTabLayouts['tab-2']);
expect(result['tab-2'].desktop).toHaveLength(1);
});
it('should handle non-existent tab gracefully', () => {
const result = removeWidgetLayoutFromTab(
mockTabLayouts,
'non-existent',
'widget-1',
);
expect(result).toEqual(mockTabLayouts);
});
it('should handle non-existent widget gracefully', () => {
const result = removeWidgetLayoutFromTab(
mockTabLayouts,
'tab-1',
'non-existent',
);
expect(result['tab-1'].desktop).toHaveLength(3);
expect(result['tab-1']).toEqual(mockTabLayouts['tab-1']);
});
it('should remove all widgets from a tab', () => {
let result = removeWidgetLayoutFromTab(mockTabLayouts, 'tab-1', 'widget-1');
result = removeWidgetLayoutFromTab(result, 'tab-1', 'widget-2');
result = removeWidgetLayoutFromTab(result, 'tab-1', 'widget-3');
expect(result['tab-1'].desktop).toHaveLength(0);
expect(result['tab-1'].mobile).toHaveLength(0);
});
it('should handle empty tab layouts', () => {
const emptyLayouts: TabLayouts = {
'tab-1': {
desktop: [],
mobile: [],
},
};
const result = removeWidgetLayoutFromTab(emptyLayouts, 'tab-1', 'widget-1');
expect(result['tab-1'].desktop).toHaveLength(0);
expect(result['tab-1'].mobile).toHaveLength(0);
});
it('should return a new object without mutating the original', () => {
const originalLayouts = JSON.parse(JSON.stringify(mockTabLayouts));
const result = removeWidgetLayoutFromTab(
mockTabLayouts,
'tab-1',
'widget-1',
);
expect(result).not.toBe(mockTabLayouts);
expect(mockTabLayouts).toEqual(originalLayouts);
expect(result['tab-1'].desktop).toHaveLength(2);
expect(mockTabLayouts['tab-1'].desktop).toHaveLength(3);
});
it('should remove widget from both desktop and mobile layouts', () => {
const result = removeWidgetLayoutFromTab(
mockTabLayouts,
'tab-1',
'widget-2',
);
const desktopIds = result['tab-1'].desktop.map((l) => l.i);
const mobileIds = result['tab-1'].mobile.map((l) => l.i);
expect(desktopIds).not.toContain('widget-2');
expect(mobileIds).not.toContain('widget-2');
expect(desktopIds).toEqual(mobileIds);
});
});
@@ -1,20 +0,0 @@
import {
type PageLayoutTab,
type PageLayoutWidget,
} from '../states/savedPageLayoutsState';
export const addWidgetToTab = (
tabs: PageLayoutTab[],
activeTabId: string,
newWidget: PageLayoutWidget,
): PageLayoutTab[] => {
return tabs.map((tab) => {
if (tab.id === activeTabId) {
return {
...tab,
widgets: [...tab.widgets, newWidget],
};
}
return tab;
});
};
@@ -1,30 +0,0 @@
import { parseCellIdToCoordinates } from './parseCellIdToCoordinates';
export type GridBounds = {
x: number;
y: number;
w: number;
h: number;
};
export const calculateGridBoundsFromSelectedCells = (
selectedCellIds: string[],
): GridBounds | null => {
if (selectedCellIds.length === 0) {
return null;
}
const cellCoords = selectedCellIds.map(parseCellIdToCoordinates);
const minCol = Math.min(...cellCoords.map((c) => c.col));
const maxCol = Math.max(...cellCoords.map((c) => c.col));
const minRow = Math.min(...cellCoords.map((c) => c.row));
const maxRow = Math.max(...cellCoords.map((c) => c.row));
return {
x: minCol,
y: minRow,
w: maxCol - minCol + 1,
h: maxRow - minRow + 1,
};
};
@@ -1,18 +0,0 @@
import { type Layouts } from 'react-grid-layout';
import { GRID_BUFFER_ROWS } from '../constants/GridBufferRows';
import { GRID_MIN_ROWS } from '../constants/GridMinRows';
export const calculateTotalGridRows = (
layouts: Layouts,
minRows = GRID_MIN_ROWS,
bufferRows = GRID_BUFFER_ROWS,
): number => {
const allLayouts = [...(layouts.desktop || []), ...(layouts.mobile || [])];
const contentRows =
allLayouts.length === 0
? 0
: Math.max(...allLayouts.map((item) => item.y + item.h));
return Math.max(minRows, contentRows + bufferRows);
};
@@ -1,22 +0,0 @@
import { type Layouts } from 'react-grid-layout';
import { type PageLayoutWidget } from '../states/savedPageLayoutsState';
export const convertLayoutsToWidgets = (
widgets: PageLayoutWidget[],
layouts: Layouts,
): PageLayoutWidget[] => {
const activeLayouts = layouts.desktop || layouts.mobile || [];
return widgets.map((widget) => {
const layout = activeLayouts.find((l) => l.i === widget.id);
return {
...widget,
gridPosition: {
row: layout?.y ?? 0,
column: layout?.x ?? 0,
rowSpan: layout?.h ?? 2,
columnSpan: layout?.w ?? 2,
},
};
});
};
@@ -1,11 +0,0 @@
import { type TabLayouts } from '../states/pageLayoutCurrentLayoutsState';
export const createEmptyTabLayout = (
allTabLayouts: TabLayouts,
tabId: string,
): TabLayouts => {
return {
...allTabLayouts,
[tabId]: { desktop: [], mobile: [] },
};
};
@@ -1,20 +0,0 @@
import { type TabLayouts } from '../states/pageLayoutCurrentLayoutsState';
export const createUpdatedTabLayouts = (
allTabLayouts: TabLayouts,
activeTabId: string,
newLayout: { i: string; x: number; y: number; w: number; h: number },
): TabLayouts => {
const currentTabLayouts = allTabLayouts[activeTabId] || {
desktop: [],
mobile: [],
};
return {
...allTabLayouts,
[activeTabId]: {
desktop: [...currentTabLayouts.desktop, newLayout],
mobile: [...currentTabLayouts.mobile, { ...newLayout, w: 1, x: 0 }],
},
};
};
@@ -1,3 +0,0 @@
export const generateCellId = (col: number, row: number): string => {
return `cell-${col}-${row}`;
};
@@ -1,137 +0,0 @@
import { GraphType } from '../mocks/mockWidgets';
export const getDefaultWidgetData = (graphType: GraphType) => {
switch (graphType) {
case GraphType.NUMBER:
return {
value: '1,234',
trendPercentage: 15.2,
};
case GraphType.GAUGE:
return {
value: 0.7,
min: 0,
max: 1,
label: 'Progress',
};
case GraphType.PIE:
return {
items: [
{ id: 'segment1', value: 35, label: 'Segment A' },
{ id: 'segment2', value: 28, label: 'Segment B' },
{ id: 'segment3', value: 20, label: 'Segment C' },
{ id: 'segment4', value: 17, label: 'Segment D' },
],
};
case GraphType.BAR:
return {
items: [
{ category: 'Jan', value: 45 },
{ category: 'Feb', value: 52 },
{ category: 'Mar', value: 48 },
{ category: 'Apr', value: 61 },
{ category: 'May', value: 55 },
],
indexBy: 'category',
keys: ['value'],
seriesLabels: { value: 'Value' },
layout: 'vertical' as const,
};
case GraphType.LINE:
return {
series: [
{
id: 'revenue',
label: 'Revenue',
color: 'blue',
data: [
{ x: 0, y: 30 },
{ x: 1, y: 50 },
{ x: 2, y: 45 },
{ x: 3, y: 70 },
{ x: 4, y: 65 },
{ x: 5, y: 80 },
{ x: 6, y: 75 },
{ x: 7, y: 85 },
],
enableArea: true,
},
{
id: 'costs',
label: 'Costs',
color: 'red',
data: [
{ x: 0, y: 60 },
{ x: 1, y: 45 },
{ x: 2, y: 55 },
{ x: 3, y: 40 },
{ x: 4, y: 60 },
{ x: 5, y: 50 },
{ x: 6, y: 70 },
{ x: 7, y: 65 },
],
enableArea: true,
},
{
id: 'profit',
label: 'Profit',
color: 'turquoise',
data: [
{ x: 0, y: 45 },
{ x: 1, y: 60 },
{ x: 2, y: 35 },
{ x: 3, y: 55 },
{ x: 4, y: 50 },
{ x: 5, y: 65 },
{ x: 6, y: 40 },
{ x: 7, y: 75 },
],
enableArea: true,
},
],
enableArea: true,
showLegend: true,
showGrid: true,
enablePoints: false,
curve: 'monotoneX',
displayType: 'shortNumber',
prefix: '$',
};
default:
return {};
}
};
export const getWidgetTitle = (graphType: GraphType, index: number): string => {
const baseNames: Record<GraphType, string> = {
[GraphType.NUMBER]: 'Number',
[GraphType.GAUGE]: 'Gauge',
[GraphType.PIE]: 'Pie Chart',
[GraphType.BAR]: 'Bar Chart',
[GraphType.LINE]: 'Line Chart',
};
return `${baseNames[graphType] || 'Widget'} ${index + 1}`;
};
export const getWidgetSize = (graphType: GraphType) => {
switch (graphType) {
case GraphType.NUMBER:
return { w: 3, h: 2 };
case GraphType.GAUGE:
return { w: 3, h: 3 };
case GraphType.PIE:
return { w: 4, h: 4 };
case GraphType.BAR:
return { w: 6, h: 4 };
case GraphType.LINE:
return { w: 6, h: 10 };
default:
return { w: 4, h: 4 };
}
};
@@ -1,17 +0,0 @@
import { type GridBounds } from './calculateGridBoundsFromSelectedCells';
export const getDefaultWidgetPosition = (
draggedArea: GridBounds | null,
defaultSize: { w: number; h: number },
): GridBounds => {
if (draggedArea !== null) {
return draggedArea;
}
return {
x: 0,
y: 0,
w: defaultSize.w,
h: defaultSize.h,
};
};
@@ -1,9 +0,0 @@
export type CellCoordinate = {
col: number;
row: number;
};
export const parseCellIdToCoordinates = (cellId: string): CellCoordinate => {
const [col, row] = cellId.split('-').slice(1).map(Number);
return { col, row };
};
@@ -1,17 +0,0 @@
import { type PageLayoutTab } from '../states/savedPageLayoutsState';
export const removeWidgetFromTab = (
tabs: PageLayoutTab[],
tabId: string,
widgetId: string,
): PageLayoutTab[] => {
return tabs.map((tab) => {
if (tab.id === tabId) {
return {
...tab,
widgets: tab.widgets.filter((w) => w.id !== widgetId),
};
}
return tab;
});
};
@@ -1,24 +0,0 @@
import { type TabLayouts } from '../states/pageLayoutCurrentLayoutsState';
export const removeWidgetLayoutFromTab = (
allTabLayouts: TabLayouts,
tabId: string,
widgetId: string,
): TabLayouts => {
if (!allTabLayouts[tabId]) {
return allTabLayouts;
}
const currentTabLayouts = allTabLayouts[tabId];
return {
...allTabLayouts,
[tabId]: {
desktop: currentTabLayouts.desktop.filter(
(layout) => layout.i !== widgetId,
),
mobile: currentTabLayouts.mobile.filter(
(layout) => layout.i !== widgetId,
),
},
};
};