[Page Layouts] - Add tabs (#14318)

This commit is contained in:
nitin
2025-09-06 14:57:37 +05:30
committed by GitHub
parent 821ae7c60d
commit ab9b5ca4c5
39 changed files with 1545 additions and 307 deletions
@@ -2,9 +2,10 @@ import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
import { useCreatePageLayoutIframeWidget } from '@/settings/page-layout/hooks/useCreatePageLayoutIframeWidget';
import { usePageLayoutWidgetUpdate } from '@/settings/page-layout/hooks/usePageLayoutWidgetUpdate';
import { pageLayoutDraftState } from '@/settings/page-layout/states/pageLayoutDraftState';
import { pageLayoutEditingWidgetIdState } from '@/settings/page-layout/states/pageLayoutEditingWidgetIdState';
import { pageLayoutWidgetsState } from '@/settings/page-layout/states/pageLayoutWidgetsState';
import styled from '@emotion/styled';
import { isString } from '@sniptt/guards';
import { useState } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { isValidUrl } from 'twenty-shared/utils';
@@ -36,15 +37,17 @@ export const CommandMenuPageLayoutIframeConfig = () => {
const { handleUpdateWidget } = usePageLayoutWidgetUpdate();
const [pageLayoutEditingWidgetId, setPageLayoutEditingWidgetId] =
useRecoilState(pageLayoutEditingWidgetIdState);
const pageLayoutWidgets = useRecoilValue(pageLayoutWidgetsState);
const pageLayoutDraft = useRecoilValue(pageLayoutDraftState);
const editingWidget = pageLayoutWidgets.find(
const allWidgets = pageLayoutDraft.tabs.flatMap((tab) => tab.widgets);
const editingWidget = allWidgets.find(
(w) => w.id === pageLayoutEditingWidgetId,
);
const isEditMode = !!editingWidget;
const [title, setTitle] = useState(editingWidget?.title || '');
const [url, setUrl] = useState(editingWidget?.configuration?.url || '');
const configUrl = editingWidget?.configuration?.url;
const [url, setUrl] = useState(isString(configUrl) ? configUrl : '');
const [urlError, setUrlError] = useState('');
const validateUrl = (urlString: string): boolean => {
@@ -2,12 +2,14 @@ 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 { type Widget } from '../mocks/mockWidgets';
import { pageLayoutCurrentLayoutsState } from '../states/pageLayoutCurrentLayoutsState';
import {
pageLayoutCurrentLayoutsState,
type TabLayouts,
} from '../states/pageLayoutCurrentLayoutsState';
import { pageLayoutDraftState } from '../states/pageLayoutDraftState';
import { pageLayoutPersistedState } from '../states/pageLayoutPersistedState';
import { pageLayoutWidgetsState } from '../states/pageLayoutWidgetsState';
import {
PageLayoutType,
savedPageLayoutsState,
@@ -40,39 +42,54 @@ export const PageLayoutInitializationEffect = ({
set(pageLayoutDraftState, {
name: layout.name,
type: layout.type,
widgets: layout.widgets,
workspaceId: layout.workspaceId,
objectMetadataId: layout.objectMetadataId,
tabs: layout.tabs,
});
const widgets: Widget[] = layout.widgets.map((w) => ({
id: w.id,
title: w.title,
type: w.type,
configuration: w.configuration,
data: w.data,
}));
set(pageLayoutWidgetsState, widgets);
const layouts = layout.widgets.map((w) => ({
i: w.id,
x: w.gridPosition.column,
y: w.gridPosition.row,
w: w.gridPosition.columnSpan,
h: w.gridPosition.rowSpan,
}));
set(pageLayoutCurrentLayoutsState, {
desktop: layouts,
mobile: layouts.map((l) => ({ ...l, w: 1, x: 0 })),
});
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,
widgets: [],
workspaceId: undefined,
objectMetadataId: null,
tabs: [defaultTab],
});
set(pageLayoutPersistedState, undefined);
set(pageLayoutWidgetsState, []);
set(pageLayoutCurrentLayoutsState, { desktop: [], mobile: [] });
set(pageLayoutCurrentLayoutsState, {
[defaultTab.id]: { desktop: [], mobile: [] },
});
}
},
[],
@@ -26,7 +26,9 @@ describe('usePageLayoutDraftState', () => {
result.current.setPageLayoutDraft({
name: ' ',
type: PageLayoutType.DASHBOARD,
widgets: [],
workspaceId: undefined,
objectMetadataId: null,
tabs: [],
});
});
@@ -43,7 +45,9 @@ describe('usePageLayoutDraftState', () => {
result.current.setPageLayoutDraft({
name: 'Updated Name',
type: PageLayoutType.DASHBOARD,
widgets: [],
workspaceId: undefined,
objectMetadataId: null,
tabs: [],
});
});
@@ -61,14 +65,32 @@ describe('usePageLayoutDraftState', () => {
result.current.setPageLayoutDraft({
name: 'Test Layout',
type: PageLayoutType.DASHBOARD,
widgets: [
workspaceId: undefined,
objectMetadataId: null,
tabs: [
{
id: 'widget-1',
title: 'New Widget',
type: WidgetType.GRAPH,
gridPosition: { row: 2, column: 2, rowSpan: 2, columnSpan: 2 },
configuration: { graphType: GraphSubType.BAR },
data: {},
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: GraphSubType.BAR },
data: {},
objectMetadataId: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
},
],
},
],
});
@@ -1,12 +1,19 @@
import { act, renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { RecoilRoot, useRecoilValue } from 'recoil';
import { pageLayoutCurrentLayoutsState } from '../../states/pageLayoutCurrentLayoutsState';
import { usePageLayoutHandleLayoutChange } from '../usePageLayoutHandleLayoutChange';
describe('usePageLayoutHandleLayoutChange', () => {
it('should update layouts and draft state when layout changes', () => {
const { result } = renderHook(() => usePageLayoutHandleLayoutChange(), {
wrapper: RecoilRoot,
});
it('should update layouts for specific tab only', () => {
const { result } = renderHook(
() => ({
handler: usePageLayoutHandleLayoutChange('tab-1'),
layouts: useRecoilValue(pageLayoutCurrentLayoutsState),
}),
{
wrapper: RecoilRoot,
},
);
const newLayouts = {
desktop: [
@@ -20,43 +27,69 @@ describe('usePageLayoutHandleLayoutChange', () => {
};
act(() => {
result.current.handleLayoutChange([], newLayouts);
result.current.handler.handleLayoutChange([], newLayouts);
});
expect(typeof result.current.handleLayoutChange).toBe('function');
expect(result.current.layouts['tab-1']).toEqual(newLayouts);
expect(result.current.layouts['tab-2']).toBeUndefined();
});
it('should handle empty layouts', () => {
const { result } = renderHook(() => usePageLayoutHandleLayoutChange(), {
wrapper: RecoilRoot,
});
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 emptyLayouts = {
desktop: [],
mobile: [],
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.handleLayoutChange([], emptyLayouts);
result.current.handler.handleLayoutChange([], tab1Layouts);
});
expect(typeof result.current.handleLayoutChange).toBe('function');
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 maintain callback reference across renders', () => {
const { result, rerender } = renderHook(
() => usePageLayoutHandleLayoutChange(),
it('should not update layouts when activeTabId is null', () => {
const { result } = renderHook(
() => ({
handler: usePageLayoutHandleLayoutChange(null),
layouts: useRecoilValue(pageLayoutCurrentLayoutsState),
}),
{
wrapper: RecoilRoot,
},
);
const firstCallback = result.current.handleLayoutChange;
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 }],
};
rerender();
act(() => {
result.current.handler.handleLayoutChange([], newLayouts);
});
const secondCallback = result.current.handleLayoutChange;
expect(firstCallback).toBe(secondCallback);
expect(Object.keys(result.current.layouts)).toHaveLength(0);
});
});
@@ -0,0 +1,135 @@
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 { usePageLayoutTabCreate } from '../usePageLayoutTabCreate';
jest.mock('uuid', () => ({
v4: jest.fn(),
}));
describe('usePageLayoutTabCreate', () => {
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: usePageLayoutTabCreate(),
pageLayoutCurrentLayouts: useRecoilValue(pageLayoutCurrentLayoutsState),
pageLayoutDraft: useRecoilValue(pageLayoutDraftState),
}),
{
wrapper: RecoilRoot,
},
);
let newTabId: string;
act(() => {
newTabId = result.current.createTab.handleCreateTab();
});
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: usePageLayoutTabCreate(),
pageLayoutDraft: useRecoilValue(pageLayoutDraftState),
}),
{
wrapper: RecoilRoot,
},
);
act(() => {
result.current.createTab.handleCreateTab('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: usePageLayoutTabCreate(),
pageLayoutDraft: useRecoilValue(pageLayoutDraftState),
}),
{
wrapper: RecoilRoot,
},
);
act(() => {
result.current.createTab.handleCreateTab();
});
act(() => {
result.current.createTab.handleCreateTab();
});
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: usePageLayoutTabCreate(),
pageLayoutCurrentLayouts: useRecoilValue(pageLayoutCurrentLayoutsState),
}),
{
wrapper: RecoilRoot,
},
);
let tabId1: string = '';
act(() => {
tabId1 = result.current.createTab.handleCreateTab();
});
let tabId2: string = '';
act(() => {
tabId2 = result.current.createTab.handleCreateTab();
});
expect(result.current.pageLayoutCurrentLayouts[tabId1]).toEqual({
desktop: [],
mobile: [],
});
expect(result.current.pageLayoutCurrentLayouts[tabId2]).toEqual({
desktop: [],
mobile: [],
});
expect(tabId1).not.toBe(tabId2);
});
});
@@ -2,8 +2,12 @@ import {
GraphSubType,
WidgetType,
} from '@/settings/page-layout/mocks/mockWidgets';
import { pageLayoutCurrentLayoutsState } from '@/settings/page-layout/states/pageLayoutCurrentLayoutsState';
import { pageLayoutCurrentTabIdForCreationState } from '@/settings/page-layout/states/pageLayoutCurrentTabIdForCreation';
import { pageLayoutDraftState } from '@/settings/page-layout/states/pageLayoutDraftState';
import { PageLayoutType } from '@/settings/page-layout/states/savedPageLayoutsState';
import { act, renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { RecoilRoot, useRecoilValue, useSetRecoilState } from 'recoil';
import { usePageLayoutWidgetCreate } from '../usePageLayoutWidgetCreate';
jest.mock('uuid', () => ({
@@ -15,21 +19,110 @@ describe('usePageLayoutWidgetCreate', () => {
jest.clearAllMocks();
});
it('should create a new widget with default position', () => {
const { result } = renderHook(() => usePageLayoutWidgetCreate(), {
wrapper: RecoilRoot,
it('should create widget in the correct tab with isolated layouts', () => {
const { result } = renderHook(
() => {
const setActiveTabId = useSetRecoilState(
pageLayoutCurrentTabIdForCreationState,
);
const setPageLayoutDraft = useSetRecoilState(pageLayoutDraftState);
const pageLayoutDraft = useRecoilValue(pageLayoutDraftState);
const allWidgets = pageLayoutDraft.tabs.flatMap((tab) => tab.widgets);
const pageLayoutCurrentLayouts = useRecoilValue(
pageLayoutCurrentLayoutsState,
);
const createWidget = usePageLayoutWidgetCreate();
return {
setActiveTabId,
setPageLayoutDraft,
allWidgets,
pageLayoutCurrentLayouts,
createWidget,
};
},
{
wrapper: RecoilRoot,
},
);
act(() => {
result.current.setPageLayoutDraft({
name: 'Test Layout',
type: PageLayoutType.DASHBOARD,
workspaceId: undefined,
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.handleCreateWidget(WidgetType.GRAPH, GraphSubType.BAR);
result.current.createWidget.handleCreateWidget(
WidgetType.GRAPH,
GraphSubType.BAR,
);
});
expect(typeof result.current.handleCreateWidget).toBe('function');
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(() => usePageLayoutWidgetCreate(), {
wrapper: RecoilRoot,
const { result } = renderHook(
() => {
const setPageLayoutDraft = useSetRecoilState(pageLayoutDraftState);
const setActiveTabId = useSetRecoilState(
pageLayoutCurrentTabIdForCreationState,
);
const createWidget = usePageLayoutWidgetCreate();
return {
setPageLayoutDraft,
setActiveTabId,
createWidget,
};
},
{
wrapper: RecoilRoot,
},
);
act(() => {
result.current.setPageLayoutDraft({
name: 'Test Layout',
type: PageLayoutType.DASHBOARD,
workspaceId: undefined,
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 = [
@@ -41,10 +134,44 @@ describe('usePageLayoutWidgetCreate', () => {
graphTypes.forEach((graphType) => {
act(() => {
result.current.handleCreateWidget(WidgetType.GRAPH, graphType);
result.current.createWidget.handleCreateWidget(
WidgetType.GRAPH,
graphType,
);
});
});
expect(typeof result.current.handleCreateWidget).toBe('function');
expect(typeof result.current.createWidget.handleCreateWidget).toBe(
'function',
);
});
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 = usePageLayoutWidgetCreate();
return { allWidgets, pageLayoutCurrentLayouts, createWidget };
},
{
wrapper: RecoilRoot,
},
);
act(() => {
result.current.createWidget.handleCreateWidget(
WidgetType.GRAPH,
GraphSubType.BAR,
);
});
expect(result.current.allWidgets).toHaveLength(0);
expect(Object.keys(result.current.pageLayoutCurrentLayouts)).toHaveLength(
0,
);
});
});
@@ -1,74 +1,79 @@
import { useRecoilCallback } from 'recoil';
import { v4 as uuidv4 } from 'uuid';
import { type Widget, WidgetType } from '../mocks/mockWidgets';
import { WidgetType } from '../mocks/mockWidgets';
import { pageLayoutCurrentLayoutsState } from '../states/pageLayoutCurrentLayoutsState';
import { pageLayoutCurrentTabIdForCreationState } from '../states/pageLayoutCurrentTabIdForCreation';
import { pageLayoutDraftState } from '../states/pageLayoutDraftState';
import { pageLayoutDraggedAreaState } from '../states/pageLayoutDraggedAreaState';
import { pageLayoutWidgetsState } from '../states/pageLayoutWidgetsState';
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 createPageLayoutIframeWidget = useRecoilCallback(
({ snapshot, set }) =>
(title: string, url: string) => {
const pageLayoutWidgets = snapshot
.getLoadable(pageLayoutWidgetsState)
.getValue();
const pageLayoutCurrentLayouts = snapshot
const allTabLayouts = snapshot
.getLoadable(pageLayoutCurrentLayoutsState)
.getValue();
const pageLayoutDraggedArea = snapshot
.getLoadable(pageLayoutDraggedAreaState)
.getValue();
const newWidget: Widget = {
id: `widget-${uuidv4()}`,
type: WidgetType.IFRAME,
title,
configuration: {
url,
},
};
const activeTabId = snapshot
.getLoadable(pageLayoutCurrentTabIdForCreationState)
.getValue();
if (!activeTabId) {
return;
}
const widgetId = `widget-${uuidv4()}`;
const defaultSize = { w: 6, h: 6 };
const position = getDefaultWidgetPosition(
pageLayoutDraggedArea,
defaultSize,
);
const newLayout = {
i: newWidget.id,
x: position.x,
y: position.y,
w: position.w,
h: position.h,
};
const updatedWidgets = [...pageLayoutWidgets, newWidget];
set(pageLayoutWidgetsState, updatedWidgets);
const updatedLayouts = {
desktop: [...(pageLayoutCurrentLayouts.desktop || []), newLayout],
mobile: [
...(pageLayoutCurrentLayouts.mobile || []),
{ ...newLayout, w: 1, x: 0 },
],
};
set(pageLayoutCurrentLayoutsState, updatedLayouts);
const widgetWithPosition = {
...newWidget,
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,
widgets: [...prev.widgets, widgetWithPosition],
tabs: addWidgetToTab(prev.tabs, activeTabId, newWidget),
}));
set(pageLayoutDraggedAreaState, null);
@@ -12,10 +12,11 @@ export const usePageLayoutDraftState = () => {
? !isDeeplyEqual(pageLayoutDraft, {
name: pageLayoutPersisted.name,
type: pageLayoutPersisted.type,
widgets: pageLayoutPersisted.widgets,
workspaceId: pageLayoutPersisted.workspaceId,
objectMetadataId: pageLayoutPersisted.objectMetadataId,
tabs: pageLayoutPersisted.tabs,
})
: pageLayoutDraft.name.trim().length > 0 ||
pageLayoutDraft.widgets.length > 0;
: pageLayoutDraft.name.trim().length > 0 || pageLayoutDraft.tabs.length > 0;
const canSave = pageLayoutDraft.name?.trim().length > 0;
@@ -1,31 +1,71 @@
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 { pageLayoutWidgetsState } from '../states/pageLayoutWidgetsState';
import { type PageLayoutWidget } from '../states/savedPageLayoutsState';
import { convertLayoutsToWidgets } from '../utils/convertLayoutsToWidgets';
export const usePageLayoutHandleLayoutChange = () => {
export const usePageLayoutHandleLayoutChange = (activeTabId: string | null) => {
const handleLayoutChange = useRecoilCallback(
({ snapshot, set }) =>
(_: Layout[], allLayouts: Layouts) => {
set(pageLayoutCurrentLayoutsState, allLayouts);
const pageLayoutWidgets = snapshot
.getLoadable(pageLayoutWidgetsState)
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(
pageLayoutWidgets,
currentTab.widgets,
allLayouts,
);
set(pageLayoutDraftState, (prev) => ({
...prev,
widgets: updatedWidgets,
}));
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 };
@@ -6,11 +6,10 @@ import { pageLayoutDraftState } from '../states/pageLayoutDraftState';
import { pageLayoutPersistedState } from '../states/pageLayoutPersistedState';
import {
savedPageLayoutsState,
type PageLayoutWidget,
type SavedPageLayout,
} from '../states/savedPageLayoutsState';
type WidgetWithGridPosition = SavedPageLayout['widgets'][0];
export const usePageLayoutSaveHandler = () => {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
@@ -18,7 +17,7 @@ export const usePageLayoutSaveHandler = () => {
const savePageLayout = useRecoilCallback(
({ snapshot, set }) =>
async (widgetsWithPositions?: WidgetWithGridPosition[]) => {
async (widgetsWithPositions?: PageLayoutWidget[]) => {
const pageLayoutDraft = snapshot
.getLoadable(pageLayoutDraftState)
.getValue();
@@ -30,17 +29,27 @@ export const usePageLayoutSaveHandler = () => {
? savedPageLayouts.find((layout) => layout.id === id)
: undefined;
const widgets = widgetsWithPositions || pageLayoutDraft.widgets;
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,
workspaceId: pageLayoutDraft.workspaceId,
objectMetadataId: pageLayoutDraft.objectMetadataId,
tabs: updatedTabs,
createdAt: isEditMode
? existingLayout?.createdAt || new Date().toISOString()
? (existingLayout?.createdAt ?? new Date().toISOString())
: new Date().toISOString(),
updatedAt: new Date().toISOString(),
widgets,
deletedAt: null,
};
set(savedPageLayoutsState, (prev) => {
@@ -0,0 +1,45 @@
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 usePageLayoutTabCreate = () => {
const handleCreateTab = 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 { handleCreateTab };
};
@@ -1,14 +1,13 @@
import { useRecoilCallback } from 'recoil';
import { v4 as uuidv4 } from 'uuid';
import {
type GraphSubType,
type Widget,
type WidgetType,
} from '../mocks/mockWidgets';
import { type GraphSubType, type WidgetType } from '../mocks/mockWidgets';
import { pageLayoutCurrentLayoutsState } from '../states/pageLayoutCurrentLayoutsState';
import { pageLayoutCurrentTabIdForCreationState } from '../states/pageLayoutCurrentTabIdForCreation';
import { pageLayoutDraftState } from '../states/pageLayoutDraftState';
import { pageLayoutDraggedAreaState } from '../states/pageLayoutDraggedAreaState';
import { pageLayoutWidgetsState } from '../states/pageLayoutWidgetsState';
import { type PageLayoutWidget } from '../states/savedPageLayoutsState';
import { addWidgetToTab } from '../utils/addWidgetToTab';
import { createUpdatedTabLayouts } from '../utils/createUpdatedTabLayouts';
import {
getDefaultWidgetData,
getWidgetSize,
@@ -22,31 +21,30 @@ export const usePageLayoutWidgetCreate = () => {
(widgetType: WidgetType, graphType: GraphSubType) => {
const widgetData = getDefaultWidgetData(graphType);
const pageLayoutWidgets = snapshot
.getLoadable(pageLayoutWidgetsState)
const pageLayoutDraft = snapshot
.getLoadable(pageLayoutDraftState)
.getValue();
const pageLayoutCurrentLayouts = snapshot
const allTabLayouts = snapshot
.getLoadable(pageLayoutCurrentLayoutsState)
.getValue();
const pageLayoutDraggedArea = snapshot
.getLoadable(pageLayoutDraggedAreaState)
.getValue();
const activeTabId = snapshot
.getLoadable(pageLayoutCurrentTabIdForCreationState)
.getValue();
const existingWidgetCount = pageLayoutWidgets.filter(
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 newWidget: Widget = {
id: `widget-${uuidv4()}`,
type: widgetType,
title,
configuration: {
graphType,
},
data: widgetData,
};
const widgetId = `widget-${uuidv4()}`;
const defaultSize = getWidgetSize(graphType);
const position = getDefaultWidgetPosition(
@@ -54,39 +52,45 @@ export const usePageLayoutWidgetCreate = () => {
defaultSize,
);
const newLayout = {
i: newWidget.id,
x: position.x,
y: position.y,
w: position.w,
h: position.h,
};
const updatedWidgets = [...pageLayoutWidgets, newWidget];
set(pageLayoutWidgetsState, updatedWidgets);
const updatedLayouts = {
desktop: [...(pageLayoutCurrentLayouts.desktop || []), newLayout],
mobile: [
...(pageLayoutCurrentLayouts.mobile || []),
{ ...newLayout, w: 1, x: 0 },
],
};
set(pageLayoutCurrentLayoutsState, updatedLayouts);
const widgetWithPosition = {
...newWidget,
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,
widgets: [...prev.widgets, widgetWithPosition],
tabs: addWidgetToTab(prev.tabs, activeTabId, newWidget),
}));
set(pageLayoutDraggedAreaState, null);
@@ -1,38 +1,38 @@
import { useRecoilCallback } from 'recoil';
import { pageLayoutCurrentLayoutsState } from '../states/pageLayoutCurrentLayoutsState';
import { pageLayoutDraftState } from '../states/pageLayoutDraftState';
import { pageLayoutWidgetsState } from '../states/pageLayoutWidgetsState';
import { removeWidgetFromTab } from '../utils/removeWidgetFromTab';
import { removeWidgetLayoutFromTab } from '../utils/removeWidgetLayoutFromTab';
export const usePageLayoutWidgetDelete = () => {
const handleRemoveWidget = useRecoilCallback(
({ snapshot, set }) =>
(widgetId: string) => {
const pageLayoutWidgets = snapshot
.getLoadable(pageLayoutWidgetsState)
const pageLayoutDraft = snapshot
.getLoadable(pageLayoutDraftState)
.getValue();
const pageLayoutCurrentLayouts = snapshot
const allTabLayouts = snapshot
.getLoadable(pageLayoutCurrentLayoutsState)
.getValue();
const updatedWidgets = pageLayoutWidgets.filter(
(w) => w.id !== widgetId,
const tabWithWidget = pageLayoutDraft.tabs.find((tab) =>
tab.widgets.some((w) => w.id === widgetId),
);
set(pageLayoutWidgetsState, updatedWidgets);
const tabId = tabWithWidget?.id;
const updatedLayouts = {
desktop: (pageLayoutCurrentLayouts.desktop || []).filter(
(layout) => layout.i !== widgetId,
),
mobile: (pageLayoutCurrentLayouts.mobile || []).filter(
(layout) => layout.i !== widgetId,
),
};
set(pageLayoutCurrentLayoutsState, updatedLayouts);
if (tabId !== undefined) {
const updatedLayouts = removeWidgetLayoutFromTab(
allTabLayouts,
tabId,
widgetId,
);
set(pageLayoutCurrentLayoutsState, updatedLayouts);
set(pageLayoutDraftState, (prev) => ({
...prev,
widgets: prev.widgets.filter((w) => w.id !== widgetId),
}));
set(pageLayoutDraftState, (prev) => ({
...prev,
tabs: removeWidgetFromTab(prev.tabs, tabId, widgetId),
}));
}
},
[],
);
@@ -1,32 +1,20 @@
import { useRecoilCallback } from 'recoil';
import { type Widget } from '../mocks/mockWidgets';
import { type PageLayoutWidget } from '../states/savedPageLayoutsState';
import { pageLayoutDraftState } from '../states/pageLayoutDraftState';
import { pageLayoutWidgetsState } from '../states/pageLayoutWidgetsState';
export const usePageLayoutWidgetUpdate = () => {
const handleUpdateWidget = useRecoilCallback(
({ snapshot, set }) =>
(widgetId: string, updates: Partial<Widget>) => {
const pageLayoutWidgets = snapshot
.getLoadable(pageLayoutWidgetsState)
.getValue();
const pageLayoutDraft = snapshot
.getLoadable(pageLayoutDraftState)
.getValue();
const updatedWidgets = pageLayoutWidgets.map((widget) =>
widget.id === widgetId ? { ...widget, ...updates } : widget,
);
set(pageLayoutWidgetsState, updatedWidgets);
const updatedDraftWidgets = pageLayoutDraft.widgets.map((widget) =>
widget.id === widgetId ? { ...widget, ...updates } : widget,
);
set(pageLayoutDraftState, {
...pageLayoutDraft,
widgets: updatedDraftWidgets,
});
({ 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,
),
})),
}));
},
[],
);
@@ -1,4 +1,5 @@
import { type Layouts } from 'react-grid-layout';
import { type PageLayoutWidget } from '../states/savedPageLayoutsState';
export enum WidgetType {
VIEW = 'VIEW',
@@ -14,19 +15,19 @@ export enum GraphSubType {
BAR = 'BAR',
}
export type Widget = {
id: string;
type: WidgetType;
title: string;
configuration?: Record<string, string>;
data?: any;
};
export const mockWidgets: Widget[] = [
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: GraphSubType.NUMBER,
},
@@ -34,11 +35,22 @@ export const mockWidgets: Widget[] = [
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: GraphSubType.GAUGE,
},
@@ -48,11 +60,22 @@ export const mockWidgets: Widget[] = [
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: GraphSubType.PIE,
},
@@ -90,11 +113,22 @@ export const mockWidgets: Widget[] = [
},
],
},
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: GraphSubType.BAR,
},
@@ -144,6 +178,9 @@ export const mockWidgets: Widget[] = [
xAxisLabel: 'Month',
yAxisLabel: 'Count',
},
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
deletedAt: null,
},
];
@@ -1,7 +1,9 @@
import { type Layouts } from 'react-grid-layout';
import { createState } from 'twenty-ui/utilities';
export const pageLayoutCurrentLayoutsState = createState<Layouts>({
export type TabLayouts = Record<string, Layouts>;
export const pageLayoutCurrentLayoutsState = createState<TabLayouts>({
key: 'pageLayoutCurrentLayoutsState',
defaultValue: { desktop: [], mobile: [] },
defaultValue: {},
});
@@ -0,0 +1,8 @@
import { createState } from 'twenty-ui/utilities';
export const pageLayoutCurrentTabIdForCreationState = createState<
string | null
>({
key: 'pageLayoutCurrentTabIdForCreationState',
defaultValue: null,
});
@@ -3,7 +3,7 @@ import { PageLayoutType, type SavedPageLayout } from './savedPageLayoutsState';
export type DraftPageLayout = Omit<
SavedPageLayout,
'id' | 'createdAt' | 'updatedAt'
'id' | 'createdAt' | 'updatedAt' | 'deletedAt'
>;
export const pageLayoutDraftState = createState<DraftPageLayout>({
@@ -11,6 +11,8 @@ export const pageLayoutDraftState = createState<DraftPageLayout>({
defaultValue: {
name: '',
type: PageLayoutType.DASHBOARD,
widgets: [],
workspaceId: undefined,
objectMetadataId: null,
tabs: [],
},
});
@@ -1,7 +0,0 @@
import { createState } from 'twenty-ui/utilities';
import { type Widget } from '../mocks/mockWidgets';
export const pageLayoutWidgetsState = createState<Widget[]>({
key: 'pageLayoutWidgetsState',
defaultValue: [],
});
@@ -7,25 +7,48 @@ export enum PageLayoutType {
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;
workspaceId?: string;
objectMetadataId?: string | null;
tabs: PageLayoutTab[];
createdAt: string;
updatedAt: string;
widgets: Array<{
id: string;
title: string;
type: WidgetType;
gridPosition: {
row: number;
column: number;
rowSpan: number;
columnSpan: number;
};
configuration?: Record<string, string>;
data?: any; // TODO: Remove when backend connected - data will be fetched dynamically
}>;
deletedAt?: string | null;
};
export const savedPageLayoutsState = createState<SavedPageLayout[]>({
@@ -0,0 +1,89 @@
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,25 +1,48 @@
import { GraphSubType, WidgetType, type Widget } from '../../mocks/mockWidgets';
import { GraphSubType, WidgetType } from '../../mocks/mockWidgets';
import { type PageLayoutWidget } from '../../states/savedPageLayoutsState';
import { convertLayoutsToWidgets } from '../convertLayoutsToWidgets';
describe('convertLayoutsToWidgets', () => {
const mockWidgets: Widget[] = [
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: GraphSubType.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: GraphSubType.PIE,
},
data: { items: [] },
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
deletedAt: null,
},
];
@@ -0,0 +1,79 @@
import { type TabLayouts } from '../../states/pageLayoutCurrentLayoutsState';
import { createEmptyTabLayout } from '../createEmptyTabLayout';
describe('createEmptyTabLayout', () => {
const mockTabLayouts: TabLayouts = {
'tab-1': {
desktop: [{ i: 'widget-1', x: 0, y: 0, w: 2, h: 2 }],
mobile: [{ i: 'widget-1', x: 0, y: 0, w: 1, h: 2 }],
},
'tab-2': {
desktop: [{ i: 'widget-2', x: 0, y: 0, w: 3, h: 3 }],
mobile: [{ i: 'widget-2', x: 0, y: 0, w: 1, h: 3 }],
},
};
it('should create empty layout for a new tab', () => {
const result = createEmptyTabLayout(mockTabLayouts, 'tab-3');
expect(result['tab-3']).toBeDefined();
expect(result['tab-3'].desktop).toEqual([]);
expect(result['tab-3'].mobile).toEqual([]);
});
it('should preserve existing tabs', () => {
const result = createEmptyTabLayout(mockTabLayouts, 'tab-3');
expect(result['tab-1']).toEqual(mockTabLayouts['tab-1']);
expect(result['tab-2']).toEqual(mockTabLayouts['tab-2']);
expect(Object.keys(result)).toHaveLength(3);
});
it('should overwrite existing tab with empty layout', () => {
const result = createEmptyTabLayout(mockTabLayouts, 'tab-1');
expect(result['tab-1'].desktop).toEqual([]);
expect(result['tab-1'].mobile).toEqual([]);
expect(result['tab-2']).toEqual(mockTabLayouts['tab-2']);
});
it('should work with empty initial state', () => {
const emptyLayouts: TabLayouts = {};
const result = createEmptyTabLayout(emptyLayouts, 'tab-1');
expect(result['tab-1']).toBeDefined();
expect(result['tab-1'].desktop).toEqual([]);
expect(result['tab-1'].mobile).toEqual([]);
expect(Object.keys(result)).toHaveLength(1);
});
it('should return a new object without mutating the original', () => {
const originalLayouts = JSON.parse(JSON.stringify(mockTabLayouts));
const result = createEmptyTabLayout(mockTabLayouts, 'tab-3');
expect(result).not.toBe(mockTabLayouts);
expect(mockTabLayouts).toEqual(originalLayouts);
expect(Object.keys(mockTabLayouts)).toHaveLength(2);
expect(Object.keys(result)).toHaveLength(3);
});
it('should handle multiple new tabs', () => {
let result = createEmptyTabLayout(mockTabLayouts, 'tab-3');
result = createEmptyTabLayout(result, 'tab-4');
result = createEmptyTabLayout(result, 'tab-5');
expect(Object.keys(result)).toHaveLength(5);
expect(result['tab-3'].desktop).toEqual([]);
expect(result['tab-4'].desktop).toEqual([]);
expect(result['tab-5'].desktop).toEqual([]);
});
it('should create consistent structure for desktop and mobile', () => {
const result = createEmptyTabLayout(mockTabLayouts, 'new-tab');
expect(result['new-tab']).toHaveProperty('desktop');
expect(result['new-tab']).toHaveProperty('mobile');
expect(Array.isArray(result['new-tab'].desktop)).toBe(true);
expect(Array.isArray(result['new-tab'].mobile)).toBe(true);
});
});
@@ -0,0 +1,82 @@
import { type TabLayouts } from '../../states/pageLayoutCurrentLayoutsState';
import { createUpdatedTabLayouts } from '../createUpdatedTabLayouts';
describe('createUpdatedTabLayouts', () => {
const mockTabLayouts: TabLayouts = {
'tab-1': {
desktop: [{ i: 'widget-1', x: 0, y: 0, w: 2, h: 2 }],
mobile: [{ i: 'widget-1', x: 0, y: 0, w: 1, h: 2 }],
},
'tab-2': {
desktop: [],
mobile: [],
},
};
const newLayout = { i: 'widget-2', x: 2, y: 0, w: 3, h: 3 };
it('should add new layout to existing tab', () => {
const result = createUpdatedTabLayouts(mockTabLayouts, 'tab-1', newLayout);
expect(result['tab-1'].desktop).toHaveLength(2);
expect(result['tab-1'].desktop[1]).toEqual(newLayout);
expect(result['tab-1'].mobile).toHaveLength(2);
expect(result['tab-1'].mobile[1]).toEqual({ ...newLayout, w: 1, x: 0 });
});
it('should add layout to empty tab', () => {
const result = createUpdatedTabLayouts(mockTabLayouts, 'tab-2', newLayout);
expect(result['tab-2'].desktop).toHaveLength(1);
expect(result['tab-2'].desktop[0]).toEqual(newLayout);
expect(result['tab-2'].mobile).toHaveLength(1);
expect(result['tab-2'].mobile[0]).toEqual({ ...newLayout, w: 1, x: 0 });
});
it('should create new tab entry if tab does not exist', () => {
const result = createUpdatedTabLayouts(mockTabLayouts, 'tab-3', newLayout);
expect(result['tab-3']).toBeDefined();
expect(result['tab-3'].desktop).toHaveLength(1);
expect(result['tab-3'].desktop[0]).toEqual(newLayout);
expect(result['tab-3'].mobile).toHaveLength(1);
});
it('should not modify other tabs', () => {
const result = createUpdatedTabLayouts(mockTabLayouts, 'tab-1', newLayout);
expect(result['tab-2']).toEqual(mockTabLayouts['tab-2']);
});
it('should handle mobile layout transformation correctly', () => {
const wideLayout = { i: 'widget-3', x: 5, y: 2, w: 6, h: 4 };
const result = createUpdatedTabLayouts(mockTabLayouts, 'tab-1', wideLayout);
const mobileLayout =
result['tab-1'].mobile[result['tab-1'].mobile.length - 1];
expect(mobileLayout.w).toBe(1);
expect(mobileLayout.x).toBe(0);
expect(mobileLayout.y).toBe(wideLayout.y);
expect(mobileLayout.h).toBe(wideLayout.h);
});
it('should return a new object without mutating the original', () => {
const originalLayouts = JSON.parse(JSON.stringify(mockTabLayouts));
const result = createUpdatedTabLayouts(mockTabLayouts, 'tab-1', newLayout);
expect(result).not.toBe(mockTabLayouts);
expect(mockTabLayouts).toEqual(originalLayouts);
expect(result['tab-1'].desktop).toHaveLength(2);
expect(mockTabLayouts['tab-1'].desktop).toHaveLength(1);
});
it('should throw an error for malformed tab layouts', () => {
const malformedLayouts: TabLayouts = {
'tab-1': {} as any,
};
expect(() => {
createUpdatedTabLayouts(malformedLayouts, 'tab-1', newLayout);
}).toThrow();
});
});
@@ -0,0 +1,109 @@
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);
});
});
@@ -0,0 +1,126 @@
import { type TabLayouts } from '../../states/pageLayoutCurrentLayoutsState';
import { removeWidgetLayoutFromTab } from '../removeWidgetLayoutFromTab';
describe('removeWidgetLayoutFromTab', () => {
const mockTabLayouts: TabLayouts = {
'tab-1': {
desktop: [
{ i: 'widget-1', x: 0, y: 0, w: 2, h: 2 },
{ i: 'widget-2', x: 2, y: 0, w: 3, h: 3 },
{ i: 'widget-3', x: 5, y: 0, w: 2, h: 2 },
],
mobile: [
{ i: 'widget-1', x: 0, y: 0, w: 1, h: 2 },
{ i: 'widget-2', x: 0, y: 2, w: 1, h: 3 },
{ i: 'widget-3', x: 0, y: 5, w: 1, h: 2 },
],
},
'tab-2': {
desktop: [{ i: 'widget-4', x: 0, y: 0, w: 4, h: 4 }],
mobile: [{ i: 'widget-4', x: 0, y: 0, w: 1, h: 4 }],
},
};
it('should remove widget layout from the correct tab', () => {
const result = removeWidgetLayoutFromTab(
mockTabLayouts,
'tab-1',
'widget-2',
);
expect(result['tab-1'].desktop).toHaveLength(2);
expect(result['tab-1'].desktop.map((l) => l.i)).toEqual([
'widget-1',
'widget-3',
]);
expect(result['tab-1'].mobile).toHaveLength(2);
expect(result['tab-1'].mobile.map((l) => l.i)).toEqual([
'widget-1',
'widget-3',
]);
});
it('should not affect other tabs', () => {
const result = removeWidgetLayoutFromTab(
mockTabLayouts,
'tab-1',
'widget-1',
);
expect(result['tab-2']).toEqual(mockTabLayouts['tab-2']);
expect(result['tab-2'].desktop).toHaveLength(1);
});
it('should handle non-existent tab gracefully', () => {
const result = removeWidgetLayoutFromTab(
mockTabLayouts,
'non-existent',
'widget-1',
);
expect(result).toEqual(mockTabLayouts);
});
it('should handle non-existent widget gracefully', () => {
const result = removeWidgetLayoutFromTab(
mockTabLayouts,
'tab-1',
'non-existent',
);
expect(result['tab-1'].desktop).toHaveLength(3);
expect(result['tab-1']).toEqual(mockTabLayouts['tab-1']);
});
it('should remove all widgets from a tab', () => {
let result = removeWidgetLayoutFromTab(mockTabLayouts, 'tab-1', 'widget-1');
result = removeWidgetLayoutFromTab(result, 'tab-1', 'widget-2');
result = removeWidgetLayoutFromTab(result, 'tab-1', 'widget-3');
expect(result['tab-1'].desktop).toHaveLength(0);
expect(result['tab-1'].mobile).toHaveLength(0);
});
it('should handle empty tab layouts', () => {
const emptyLayouts: TabLayouts = {
'tab-1': {
desktop: [],
mobile: [],
},
};
const result = removeWidgetLayoutFromTab(emptyLayouts, 'tab-1', 'widget-1');
expect(result['tab-1'].desktop).toHaveLength(0);
expect(result['tab-1'].mobile).toHaveLength(0);
});
it('should return a new object without mutating the original', () => {
const originalLayouts = JSON.parse(JSON.stringify(mockTabLayouts));
const result = removeWidgetLayoutFromTab(
mockTabLayouts,
'tab-1',
'widget-1',
);
expect(result).not.toBe(mockTabLayouts);
expect(mockTabLayouts).toEqual(originalLayouts);
expect(result['tab-1'].desktop).toHaveLength(2);
expect(mockTabLayouts['tab-1'].desktop).toHaveLength(3);
});
it('should remove widget from both desktop and mobile layouts', () => {
const result = removeWidgetLayoutFromTab(
mockTabLayouts,
'tab-1',
'widget-2',
);
const desktopIds = result['tab-1'].desktop.map((l) => l.i);
const mobileIds = result['tab-1'].mobile.map((l) => l.i);
expect(desktopIds).not.toContain('widget-2');
expect(mobileIds).not.toContain('widget-2');
expect(desktopIds).toEqual(mobileIds);
});
});
@@ -0,0 +1,20 @@
import {
type 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,19 +1,10 @@
import { type Layouts } from 'react-grid-layout';
import { type Widget } from '../mocks/mockWidgets';
export type WidgetWithGridPosition = Widget & {
gridPosition: {
row: number;
column: number;
rowSpan: number;
columnSpan: number;
};
};
import { type PageLayoutWidget } from '../states/savedPageLayoutsState';
export const convertLayoutsToWidgets = (
widgets: Widget[],
widgets: PageLayoutWidget[],
layouts: Layouts,
): WidgetWithGridPosition[] => {
): PageLayoutWidget[] => {
const activeLayouts = layouts.desktop || layouts.mobile || [];
return widgets.map((widget) => {
@@ -0,0 +1,11 @@
import { type TabLayouts } from '../states/pageLayoutCurrentLayoutsState';
export const createEmptyTabLayout = (
allTabLayouts: TabLayouts,
tabId: string,
): TabLayouts => {
return {
...allTabLayouts,
[tabId]: { desktop: [], mobile: [] },
};
};
@@ -0,0 +1,20 @@
import { type TabLayouts } from '../states/pageLayoutCurrentLayoutsState';
export const createUpdatedTabLayouts = (
allTabLayouts: TabLayouts,
activeTabId: string,
newLayout: { i: string; x: number; y: number; w: number; h: number },
): TabLayouts => {
const currentTabLayouts = allTabLayouts[activeTabId] || {
desktop: [],
mobile: [],
};
return {
...allTabLayouts,
[activeTabId]: {
desktop: [...currentTabLayouts.desktop, newLayout],
mobile: [...currentTabLayouts.mobile, { ...newLayout, w: 1, x: 0 }],
},
};
};
@@ -3,9 +3,10 @@ import { GraphWidgetGaugeChart } from '@/dashboards/widgets/graph/components/Gra
import { GraphWidgetNumberChart } from '@/dashboards/widgets/graph/components/GraphWidgetNumberChart';
import { GraphWidgetPieChart } from '@/dashboards/widgets/graph/components/GraphWidgetPieChart';
import { type ReactNode } from 'react';
import { GraphSubType, type Widget } from '../mocks/mockWidgets';
import { GraphSubType } from '../mocks/mockWidgets';
import { type PageLayoutWidget } from '../states/savedPageLayoutsState';
type GraphRenderer = (widget: Widget) => ReactNode;
type GraphRenderer = (widget: PageLayoutWidget) => ReactNode;
const graphRenderers: Record<GraphSubType, GraphRenderer> = {
[GraphSubType.NUMBER]: (widget) => (
@@ -50,14 +51,18 @@ const graphRenderers: Record<GraphSubType, GraphRenderer> = {
),
};
export const renderGraphWidget = (widget: Widget): ReactNode => {
const graphType = widget.configuration?.graphType as GraphSubType | undefined;
export const renderGraphWidget = (widget: PageLayoutWidget): ReactNode => {
const graphType = widget.configuration?.graphType;
if (!graphType) {
if (!graphType || typeof graphType !== 'string') {
return null;
}
const renderer = graphRenderers[graphType];
if (!Object.values(GraphSubType).includes(graphType as GraphSubType)) {
return null;
}
const renderer = graphRenderers[graphType as GraphSubType];
if (!renderer) {
return null;
}
@@ -0,0 +1,17 @@
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;
});
};
@@ -0,0 +1,24 @@
import { type TabLayouts } from '../states/pageLayoutCurrentLayoutsState';
export const removeWidgetLayoutFromTab = (
allTabLayouts: TabLayouts,
tabId: string,
widgetId: string,
): TabLayouts => {
if (!allTabLayouts[tabId]) {
return allTabLayouts;
}
const currentTabLayouts = allTabLayouts[tabId];
return {
...allTabLayouts,
[tabId]: {
desktop: currentTabLayouts.desktop.filter(
(layout) => layout.i !== widgetId,
),
mobile: currentTabLayouts.mobile.filter(
(layout) => layout.i !== widgetId,
),
},
};
};
@@ -1,20 +1,21 @@
import { IframeWidget } from '@/dashboards/widgets/iframe/components/IframeWidget';
import { isString } from '@sniptt/guards';
import { type ReactNode } from 'react';
import { WidgetType, type Widget } from '../mocks/mockWidgets';
import { WidgetType } from '../mocks/mockWidgets';
import { type PageLayoutWidget } from '../states/savedPageLayoutsState';
import { renderGraphWidget } from './graphRegistry';
export const renderWidget = (widget: Widget): ReactNode => {
export const renderWidget = (widget: PageLayoutWidget): ReactNode => {
switch (widget.type) {
case WidgetType.GRAPH:
return renderGraphWidget(widget);
case WidgetType.IFRAME:
case WidgetType.IFRAME: {
const url = widget.configuration?.url;
return (
<IframeWidget
url={widget.configuration?.url ?? ''}
title={widget.title}
/>
<IframeWidget url={isString(url) ? url : ''} title={widget.title} />
);
}
case WidgetType.VIEW:
return null;
@@ -10,7 +10,8 @@ import { useRecoilComponentState } from '@/ui/utilities/state/component-state/ho
import styled from '@emotion/styled';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { TabButton } from 'twenty-ui/input';
import { IconPlus } from 'twenty-ui/display';
import { IconButton, TabButton } from 'twenty-ui/input';
import { TabListDropdown } from './TabListDropdown';
import { TabListFromUrlOptionalEffect } from './TabListFromUrlOptionalEffect';
import { TabMoreButton } from './TabMoreButton';
@@ -51,6 +52,13 @@ const StyledHiddenMeasurement = styled.div`
visibility: hidden;
`;
const StyledAddButton = styled.div`
display: flex;
align-items: center;
height: ${({ theme }) => theme.spacing(10)};
margin-left: ${TAB_LIST_GAP}px;
`;
export const TabList = ({
tabs,
loading,
@@ -58,6 +66,7 @@ export const TabList = ({
isInRightDrawer,
className,
componentInstanceId,
onAddTab,
}: TabListProps) => {
const visibleTabs = tabs.filter((tab) => !tab.hide);
const navigate = useNavigate();
@@ -70,6 +79,7 @@ export const TabList = ({
const [tabWidthsById, setTabWidthsById] = useState<TabWidthsById>({});
const [containerWidth, setContainerWidth] = useState(0);
const [moreButtonWidth, setMoreButtonWidth] = useState(0);
const [addButtonWidth, setAddButtonWidth] = useState(0);
const activeTabExists = visibleTabs.some((tab) => tab.id === activeTabId);
const initialActiveTabId = activeTabExists ? activeTabId : visibleTabs[0]?.id;
@@ -80,8 +90,16 @@ export const TabList = ({
tabWidthsById,
containerWidth,
moreButtonWidth,
addButtonWidth: onAddTab ? addButtonWidth : 0,
});
}, [tabWidthsById, containerWidth, moreButtonWidth, visibleTabs]);
}, [
tabWidthsById,
containerWidth,
moreButtonWidth,
addButtonWidth,
visibleTabs,
onAddTab,
]);
const hiddenTabsCount = visibleTabs.length - visibleTabCount;
const hasHiddenTabs = hiddenTabsCount > 0;
@@ -150,6 +168,15 @@ export const TabList = ({
[],
);
const handleAddButtonWidthChange = useCallback(
(dimensions: { width: number; height: number }) => {
setAddButtonWidth((prev) => {
return prev !== dimensions.width ? dimensions.width : prev;
});
},
[],
);
if (visibleTabs.length === 0) {
return null;
}
@@ -187,6 +214,19 @@ export const TabList = ({
<NodeDimension onDimensionChange={handleMoreButtonWidthChange}>
<TabMoreButton hiddenTabsCount={1} active={false} />
</NodeDimension>
{onAddTab && (
<NodeDimension onDimensionChange={handleAddButtonWidthChange}>
<StyledAddButton>
<IconButton
Icon={IconPlus}
size="small"
variant="tertiary"
onClick={onAddTab}
/>
</StyledAddButton>
</NodeDimension>
)}
</StyledHiddenMeasurement>
)}
@@ -227,6 +267,17 @@ export const TabList = ({
loading={loading}
/>
)}
{onAddTab && (
<StyledAddButton>
<IconButton
Icon={IconPlus}
size="small"
variant="tertiary"
onClick={onAddTab}
/>
</StyledAddButton>
)}
</StyledContainer>
</NodeDimension>
</>
@@ -7,4 +7,5 @@ export type TabListProps = {
className?: string;
isInRightDrawer?: boolean;
componentInstanceId: string;
onAddTab?: () => void;
};
@@ -8,6 +8,7 @@ type CalculateVisibleTabCountParams = {
tabWidthsById: TabWidthsById;
containerWidth: number;
moreButtonWidth: number;
addButtonWidth?: number;
};
export const calculateVisibleTabCount = ({
@@ -15,12 +16,17 @@ export const calculateVisibleTabCount = ({
tabWidthsById,
containerWidth,
moreButtonWidth,
addButtonWidth = 0,
}: CalculateVisibleTabCountParams): number => {
if (Object.keys(tabWidthsById).length === 0 || containerWidth === 0) {
return visibleTabs.length;
}
const availableWidth = containerWidth - TAB_LIST_LEFT_PADDING;
// Subtract add button width if present
const availableWidth =
containerWidth -
TAB_LIST_LEFT_PADDING -
(addButtonWidth > 0 ? addButtonWidth + TAB_LIST_GAP : 0);
let totalWidth = 0;
for (let i = 0; i < visibleTabs.length; i++) {
@@ -13,20 +13,25 @@ import { usePageLayoutDraftState } from '@/settings/page-layout/hooks/usePageLay
import { usePageLayoutDragSelection } from '@/settings/page-layout/hooks/usePageLayoutDragSelection';
import { usePageLayoutHandleLayoutChange } from '@/settings/page-layout/hooks/usePageLayoutHandleLayoutChange';
import { usePageLayoutSaveHandler } from '@/settings/page-layout/hooks/usePageLayoutSaveHandler';
import { usePageLayoutTabCreate } from '@/settings/page-layout/hooks/usePageLayoutTabCreate';
import { usePageLayoutWidgetDelete } from '@/settings/page-layout/hooks/usePageLayoutWidgetDelete';
import { WidgetType } from '@/settings/page-layout/mocks/mockWidgets';
import { pageLayoutCurrentBreakpointState } from '@/settings/page-layout/states/pageLayoutCurrentBreakpointState';
import { pageLayoutCurrentLayoutsState } from '@/settings/page-layout/states/pageLayoutCurrentLayoutsState';
import { pageLayoutCurrentTabIdForCreationState } from '@/settings/page-layout/states/pageLayoutCurrentTabIdForCreation';
import { pageLayoutEditingWidgetIdState } from '@/settings/page-layout/states/pageLayoutEditingWidgetIdState';
import { pageLayoutSelectedCellsState } from '@/settings/page-layout/states/pageLayoutSelectedCellsState';
import { pageLayoutWidgetsState } from '@/settings/page-layout/states/pageLayoutWidgetsState';
import { type PageLayoutWidget } from '@/settings/page-layout/states/savedPageLayoutsState';
import { calculateTotalGridRows } from '@/settings/page-layout/utils/calculateTotalGridRows';
import { convertLayoutsToWidgets } from '@/settings/page-layout/utils/convertLayoutsToWidgets';
import { generateCellId } from '@/settings/page-layout/utils/generateCellId';
import { renderWidget } from '@/settings/page-layout/utils/widgetRegistry';
import { SettingsPath } from '@/types/SettingsPath';
import { TitleInput } from '@/ui/input/components/TitleInput';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { type SingleTabProps } from '@/ui/layout/tab-list/types/SingleTabProps';
import { DragSelect } from '@/ui/utilities/drag-select/components/DragSelect';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useCallback, useMemo, useRef, useState } from 'react';
@@ -67,6 +72,10 @@ const StyledGridContainer = styled.div`
}
`;
const StyledTabList = styled(TabList)`
padding-left: ${({ theme }) => theme.spacing(2)};
`;
const StyledGridOverlay = styled.div<{
isDragSelecting?: boolean;
breakpoint: PageLayoutBreakpoint;
@@ -134,12 +143,34 @@ export const SettingsPageLayoutEdit = () => {
const pageLayoutCurrentLayouts = useRecoilValue(
pageLayoutCurrentLayoutsState,
);
const pageLayoutWidgets = useRecoilValue(pageLayoutWidgetsState);
const setPageLayoutCurrentTabIdForCreation = useSetRecoilState(
pageLayoutCurrentTabIdForCreationState,
);
const { navigateCommandMenu } = useNavigateCommandMenu();
const setPageLayoutEditingWidgetId = useSetRecoilState(
pageLayoutEditingWidgetIdState,
);
const gridContainerRef = useRef<HTMLDivElement>(null);
const activeTabId = useRecoilComponentValue(
activeTabIdComponentState,
'page-layout-tabs',
);
const activeTabWidgets = useMemo(() => {
if (!activeTabId) return [];
const activeTab = pageLayoutDraft.tabs.find(
(tab) => tab.id === activeTabId,
);
return activeTab?.widgets || [];
}, [pageLayoutDraft.tabs, activeTabId]);
const allWidgets = useMemo(
() => pageLayoutDraft.tabs.flatMap((tab) => tab.widgets),
[pageLayoutDraft.tabs],
);
const {
handleDragSelectionStart,
handleDragSelectionChange,
@@ -147,23 +178,28 @@ export const SettingsPageLayoutEdit = () => {
} = usePageLayoutDragSelection();
const handleOpenAddWidget = useCallback(() => {
setPageLayoutCurrentTabIdForCreation(activeTabId);
navigateCommandMenu({
page: CommandMenuPages.PageLayoutWidgetTypeSelect,
pageTitle: 'Add Widget',
pageIcon: IconAppWindow,
resetNavigationStack: true,
});
}, [navigateCommandMenu]);
}, [navigateCommandMenu, activeTabId, setPageLayoutCurrentTabIdForCreation]);
const { handleRemoveWidget } = usePageLayoutWidgetDelete();
const { handleLayoutChange } = usePageLayoutHandleLayoutChange();
const { handleLayoutChange } = usePageLayoutHandleLayoutChange(activeTabId);
const { handleCreateTab } = usePageLayoutTabCreate();
const handleEditWidget = useCallback(
(widgetId: string) => {
const widget = pageLayoutWidgets.find((w) => w.id === widgetId);
const widget = allWidgets.find((w) => w.id === widgetId);
if (!widget) return;
setPageLayoutEditingWidgetId(widgetId);
setPageLayoutCurrentTabIdForCreation(
widget.pageLayoutTabId || activeTabId,
);
if (widget.type === WidgetType.IFRAME) {
navigateCommandMenu({
@@ -174,36 +210,70 @@ export const SettingsPageLayoutEdit = () => {
});
}
},
[pageLayoutWidgets, setPageLayoutEditingWidgetId, navigateCommandMenu],
[
allWidgets,
setPageLayoutEditingWidgetId,
navigateCommandMenu,
setPageLayoutCurrentTabIdForCreation,
activeTabId,
],
);
const gridContainerRef = useRef<HTMLDivElement>(null);
const isEmptyState = activeTabWidgets.length === 0;
const isEmptyState = pageLayoutWidgets.length === 0;
const gridRows = useMemo(
() => calculateTotalGridRows(pageLayoutCurrentLayouts),
[pageLayoutCurrentLayouts],
);
const gridRows = useMemo(() => {
const currentTabLayouts = pageLayoutCurrentLayouts[activeTabId || ''] || {
desktop: [],
mobile: [],
};
return calculateTotalGridRows(currentTabLayouts);
}, [pageLayoutCurrentLayouts, activeTabId]);
const handleCancel = () => {
navigateSettings(SettingsPath.PageLayout);
};
const setActiveTabId = useSetRecoilState(
activeTabIdComponentState.atomFamily({
instanceId: 'page-layout-tabs',
}),
);
const handleAddTab = useCallback(() => {
const newTabId = handleCreateTab();
setActiveTabId(newTabId);
}, [handleCreateTab, setActiveTabId]);
const tabListTabs: SingleTabProps[] = useMemo(() => {
return [...pageLayoutDraft.tabs]
.sort((a, b) => a.position - b.position)
.map((tab) => ({
id: tab.id,
title: tab.title,
}));
}, [pageLayoutDraft.tabs]);
const handleSaveClick = async () => {
setIsSaving(true);
try {
const widgetsWithPositions = convertLayoutsToWidgets(
pageLayoutWidgets,
pageLayoutCurrentLayouts,
const allWidgets: PageLayoutWidget[] = pageLayoutDraft.tabs.flatMap(
(tab) =>
tab.widgets.map((widget) => ({
...widget,
pageLayoutTabId: widget.pageLayoutTabId || tab.id,
objectMetadataId: widget.objectMetadataId || null,
createdAt: widget.createdAt || new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: widget.deletedAt || null,
})),
);
setPageLayoutDraft((prev) => ({
...prev,
widgets: widgetsWithPositions,
tabs: pageLayoutDraft.tabs,
}));
await savePageLayout(widgetsWithPositions);
await savePageLayout(allWidgets);
} finally {
setIsSaving(false);
}
@@ -245,7 +315,7 @@ export const SettingsPageLayoutEdit = () => {
isSaveDisabled={
!isDirty ||
!pageLayoutDraft.name.trim() ||
pageLayoutWidgets.length === 0
allWidgets.length === 0
}
/>
{!isEmptyState && (
@@ -260,6 +330,14 @@ export const SettingsPageLayoutEdit = () => {
</StyledActionButtonContainer>
}
>
{pageLayoutDraft.tabs.length > 0 && (
<StyledTabList
tabs={tabListTabs}
behaveAsLinks={false}
componentInstanceId="page-layout-tabs"
onAddTab={handleAddTab}
/>
)}
<StyledGridContainer ref={gridContainerRef}>
<StyledGridOverlay
isDragSelecting={pageLayoutCurrentBreakpoint !== 'mobile'}
@@ -284,7 +362,11 @@ export const SettingsPageLayoutEdit = () => {
</StyledGridOverlay>
<ResponsiveGridLayout
className="layout"
layouts={isEmptyState ? EMPTY_LAYOUT : pageLayoutCurrentLayouts}
layouts={
isEmptyState || !activeTabId
? EMPTY_LAYOUT
: pageLayoutCurrentLayouts[activeTabId] || EMPTY_LAYOUT
}
breakpoints={PAGE_LAYOUT_CONFIG.breakpoints}
cols={PAGE_LAYOUT_CONFIG.columns}
rowHeight={55}
@@ -308,7 +390,7 @@ export const SettingsPageLayoutEdit = () => {
<PageLayoutWidgetPlaceholder title="" isEmpty />
</div>
) : (
pageLayoutWidgets.map((widget) => (
activeTabWidgets.map((widget) => (
<div key={widget.id} data-select-disable="true">
<PageLayoutWidgetPlaceholder
title={widget.title}
@@ -1,4 +1,5 @@
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { savedPageLayoutsState } from '@/settings/page-layout/states/savedPageLayoutsState';
import { SettingsPath } from '@/types/SettingsPath';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { Table } from '@/ui/layout/table/components/Table';
@@ -20,10 +21,9 @@ import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { UndecoratedLink } from 'twenty-ui/navigation';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import { savedPageLayoutsState } from '@/settings/page-layout/states/savedPageLayoutsState';
const StyledTableRow = styled(TableRow)`
grid-template-columns: 180px 120px 100px 36px 36px;
grid-template-columns: 1fr 180px 80px 80px 36px 36px;
`;
const StyledNameTableCell = styled(TableCell)`
@@ -84,6 +84,7 @@ export const SettingsPageLayouts = () => {
<StyledTableRow>
<TableHeader>{t`Name`}</TableHeader>
<TableHeader>{t`Type`}</TableHeader>
<TableHeader>{t`Tabs`}</TableHeader>
<TableHeader>{t`Widgets`}</TableHeader>
<TableHeader></TableHeader>
<TableHeader></TableHeader>
@@ -101,7 +102,13 @@ export const SettingsPageLayouts = () => {
{layout.name}
</StyledNameTableCell>
<TableCell>{layout.type}</TableCell>
<TableCell>{layout.widgets.length}</TableCell>
<TableCell>{layout.tabs.length}</TableCell>
<TableCell>
{layout.tabs.reduce(
(total, tab) => total + tab.widgets.length,
0,
)}
</TableCell>
<StyledActionTableCell
onClick={(event) => {
event.stopPropagation();