diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/CommandMenuPageLayoutIframeConfig.tsx b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/CommandMenuPageLayoutIframeConfig.tsx index b54c5e0c89..783fd0333d 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/CommandMenuPageLayoutIframeConfig.tsx +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/CommandMenuPageLayoutIframeConfig.tsx @@ -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 => { diff --git a/packages/twenty-front/src/modules/settings/page-layout/components/PageLayoutInitializationEffect.tsx b/packages/twenty-front/src/modules/settings/page-layout/components/PageLayoutInitializationEffect.tsx index e0ed4a173c..9b1b2003ed 100644 --- a/packages/twenty-front/src/modules/settings/page-layout/components/PageLayoutInitializationEffect.tsx +++ b/packages/twenty-front/src/modules/settings/page-layout/components/PageLayoutInitializationEffect.tsx @@ -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: [] }, + }); } }, [], diff --git a/packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutDraftState.test.ts b/packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutDraftState.test.ts index 0cdf2116ae..dd60729f8b 100644 --- a/packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutDraftState.test.ts +++ b/packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutDraftState.test.ts @@ -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, + }, + ], }, ], }); diff --git a/packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutHandleLayoutChange.test.ts b/packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutHandleLayoutChange.test.ts index c98a1d6c90..53966ca12d 100644 --- a/packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutHandleLayoutChange.test.ts +++ b/packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutHandleLayoutChange.test.ts @@ -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); }); }); diff --git a/packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutTabCreate.test.ts b/packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutTabCreate.test.ts new file mode 100644 index 0000000000..de03664e2b --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutTabCreate.test.ts @@ -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); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutWidgetCreate.test.ts b/packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutWidgetCreate.test.ts index 47220e3a9f..836c3e3c1e 100644 --- a/packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutWidgetCreate.test.ts +++ b/packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutWidgetCreate.test.ts @@ -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, + ); }); }); diff --git a/packages/twenty-front/src/modules/settings/page-layout/hooks/useCreatePageLayoutIframeWidget.ts b/packages/twenty-front/src/modules/settings/page-layout/hooks/useCreatePageLayoutIframeWidget.ts index 6d9b46b6c3..05f51655b9 100644 --- a/packages/twenty-front/src/modules/settings/page-layout/hooks/useCreatePageLayoutIframeWidget.ts +++ b/packages/twenty-front/src/modules/settings/page-layout/hooks/useCreatePageLayoutIframeWidget.ts @@ -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); diff --git a/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutDraftState.ts b/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutDraftState.ts index d589e209fd..4539402504 100644 --- a/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutDraftState.ts +++ b/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutDraftState.ts @@ -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; diff --git a/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutHandleLayoutChange.ts b/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutHandleLayoutChange.ts index 1cb791fe6b..b7cb7882d2 100644 --- a/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutHandleLayoutChange.ts +++ b/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutHandleLayoutChange.ts @@ -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 }; diff --git a/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutSaveHandler.ts b/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutSaveHandler.ts index b0eb0ac9d7..6f837443f7 100644 --- a/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutSaveHandler.ts +++ b/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutSaveHandler.ts @@ -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) => { diff --git a/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutTabCreate.ts b/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutTabCreate.ts new file mode 100644 index 0000000000..03f5eb3aef --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutTabCreate.ts @@ -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 }; +}; diff --git a/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutWidgetCreate.ts b/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutWidgetCreate.ts index 1f5244b93a..e9582f159b 100644 --- a/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutWidgetCreate.ts +++ b/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutWidgetCreate.ts @@ -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); diff --git a/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutWidgetDelete.ts b/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutWidgetDelete.ts index 3e3bb4143b..faf0b62211 100644 --- a/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutWidgetDelete.ts +++ b/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutWidgetDelete.ts @@ -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), + })); + } }, [], ); diff --git a/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutWidgetUpdate.ts b/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutWidgetUpdate.ts index dfdcf1d9bc..ce1c1242d3 100644 --- a/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutWidgetUpdate.ts +++ b/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutWidgetUpdate.ts @@ -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) => { - 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) => { + set(pageLayoutDraftState, (prev) => ({ + ...prev, + tabs: prev.tabs.map((tab) => ({ + ...tab, + widgets: tab.widgets.map((widget) => + widget.id === widgetId ? { ...widget, ...updates } : widget, + ), + })), + })); }, [], ); diff --git a/packages/twenty-front/src/modules/settings/page-layout/mocks/mockWidgets.ts b/packages/twenty-front/src/modules/settings/page-layout/mocks/mockWidgets.ts index 80592930d2..5a1ba46ed9 100644 --- a/packages/twenty-front/src/modules/settings/page-layout/mocks/mockWidgets.ts +++ b/packages/twenty-front/src/modules/settings/page-layout/mocks/mockWidgets.ts @@ -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; - 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, }, ]; diff --git a/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutCurrentLayoutsState.ts b/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutCurrentLayoutsState.ts index 1edc017f62..4f9f945fc2 100644 --- a/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutCurrentLayoutsState.ts +++ b/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutCurrentLayoutsState.ts @@ -1,7 +1,9 @@ import { type Layouts } from 'react-grid-layout'; import { createState } from 'twenty-ui/utilities'; -export const pageLayoutCurrentLayoutsState = createState({ +export type TabLayouts = Record; + +export const pageLayoutCurrentLayoutsState = createState({ key: 'pageLayoutCurrentLayoutsState', - defaultValue: { desktop: [], mobile: [] }, + defaultValue: {}, }); diff --git a/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutCurrentTabIdForCreation.ts b/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutCurrentTabIdForCreation.ts new file mode 100644 index 0000000000..cf513be3e6 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutCurrentTabIdForCreation.ts @@ -0,0 +1,8 @@ +import { createState } from 'twenty-ui/utilities'; + +export const pageLayoutCurrentTabIdForCreationState = createState< + string | null +>({ + key: 'pageLayoutCurrentTabIdForCreationState', + defaultValue: null, +}); diff --git a/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutDraftState.ts b/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutDraftState.ts index bb69b28ae6..e7b20c5fa6 100644 --- a/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutDraftState.ts +++ b/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutDraftState.ts @@ -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({ @@ -11,6 +11,8 @@ export const pageLayoutDraftState = createState({ defaultValue: { name: '', type: PageLayoutType.DASHBOARD, - widgets: [], + workspaceId: undefined, + objectMetadataId: null, + tabs: [], }, }); diff --git a/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutWidgetsState.ts b/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutWidgetsState.ts deleted file mode 100644 index ae751042e8..0000000000 --- a/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutWidgetsState.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createState } from 'twenty-ui/utilities'; -import { type Widget } from '../mocks/mockWidgets'; - -export const pageLayoutWidgetsState = createState({ - key: 'pageLayoutWidgetsState', - defaultValue: [], -}); diff --git a/packages/twenty-front/src/modules/settings/page-layout/states/savedPageLayoutsState.ts b/packages/twenty-front/src/modules/settings/page-layout/states/savedPageLayoutsState.ts index 154f4e1b53..29a2a506c2 100644 --- a/packages/twenty-front/src/modules/settings/page-layout/states/savedPageLayoutsState.ts +++ b/packages/twenty-front/src/modules/settings/page-layout/states/savedPageLayoutsState.ts @@ -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 | 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; - data?: any; // TODO: Remove when backend connected - data will be fetched dynamically - }>; + deletedAt?: string | null; }; export const savedPageLayoutsState = createState({ diff --git a/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/addWidgetToTab.test.ts b/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/addWidgetToTab.test.ts new file mode 100644 index 0000000000..94041629ab --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/addWidgetToTab.test.ts @@ -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); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/convertLayoutsToWidgets.test.ts b/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/convertLayoutsToWidgets.test.ts index 964498b3e0..b697a866a9 100644 --- a/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/convertLayoutsToWidgets.test.ts +++ b/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/convertLayoutsToWidgets.test.ts @@ -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, }, ]; diff --git a/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/createEmptyTabLayout.test.ts b/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/createEmptyTabLayout.test.ts new file mode 100644 index 0000000000..675389d31f --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/createEmptyTabLayout.test.ts @@ -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); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/createUpdatedTabLayouts.test.ts b/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/createUpdatedTabLayouts.test.ts new file mode 100644 index 0000000000..75f4e24c10 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/createUpdatedTabLayouts.test.ts @@ -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(); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/removeWidgetFromTab.test.ts b/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/removeWidgetFromTab.test.ts new file mode 100644 index 0000000000..7890f86258 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/removeWidgetFromTab.test.ts @@ -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); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/removeWidgetLayoutFromTab.test.ts b/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/removeWidgetLayoutFromTab.test.ts new file mode 100644 index 0000000000..6b1d5b51e5 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/removeWidgetLayoutFromTab.test.ts @@ -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); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/page-layout/utils/addWidgetToTab.ts b/packages/twenty-front/src/modules/settings/page-layout/utils/addWidgetToTab.ts new file mode 100644 index 0000000000..50a2f3b94b --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/utils/addWidgetToTab.ts @@ -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; + }); +}; diff --git a/packages/twenty-front/src/modules/settings/page-layout/utils/convertLayoutsToWidgets.ts b/packages/twenty-front/src/modules/settings/page-layout/utils/convertLayoutsToWidgets.ts index e62ccc4056..1f42c4ef44 100644 --- a/packages/twenty-front/src/modules/settings/page-layout/utils/convertLayoutsToWidgets.ts +++ b/packages/twenty-front/src/modules/settings/page-layout/utils/convertLayoutsToWidgets.ts @@ -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) => { diff --git a/packages/twenty-front/src/modules/settings/page-layout/utils/createEmptyTabLayout.ts b/packages/twenty-front/src/modules/settings/page-layout/utils/createEmptyTabLayout.ts new file mode 100644 index 0000000000..dd0e44a89c --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/utils/createEmptyTabLayout.ts @@ -0,0 +1,11 @@ +import { type TabLayouts } from '../states/pageLayoutCurrentLayoutsState'; + +export const createEmptyTabLayout = ( + allTabLayouts: TabLayouts, + tabId: string, +): TabLayouts => { + return { + ...allTabLayouts, + [tabId]: { desktop: [], mobile: [] }, + }; +}; diff --git a/packages/twenty-front/src/modules/settings/page-layout/utils/createUpdatedTabLayouts.ts b/packages/twenty-front/src/modules/settings/page-layout/utils/createUpdatedTabLayouts.ts new file mode 100644 index 0000000000..3654aebd51 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/utils/createUpdatedTabLayouts.ts @@ -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 }], + }, + }; +}; diff --git a/packages/twenty-front/src/modules/settings/page-layout/utils/graphRegistry.tsx b/packages/twenty-front/src/modules/settings/page-layout/utils/graphRegistry.tsx index 1ca3220763..c9a8547678 100644 --- a/packages/twenty-front/src/modules/settings/page-layout/utils/graphRegistry.tsx +++ b/packages/twenty-front/src/modules/settings/page-layout/utils/graphRegistry.tsx @@ -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.NUMBER]: (widget) => ( @@ -50,14 +51,18 @@ const graphRenderers: Record = { ), }; -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; } diff --git a/packages/twenty-front/src/modules/settings/page-layout/utils/removeWidgetFromTab.ts b/packages/twenty-front/src/modules/settings/page-layout/utils/removeWidgetFromTab.ts new file mode 100644 index 0000000000..abb6fe42c7 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/utils/removeWidgetFromTab.ts @@ -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; + }); +}; diff --git a/packages/twenty-front/src/modules/settings/page-layout/utils/removeWidgetLayoutFromTab.ts b/packages/twenty-front/src/modules/settings/page-layout/utils/removeWidgetLayoutFromTab.ts new file mode 100644 index 0000000000..c08d50b7c5 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/utils/removeWidgetLayoutFromTab.ts @@ -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, + ), + }, + }; +}; diff --git a/packages/twenty-front/src/modules/settings/page-layout/utils/widgetRegistry.tsx b/packages/twenty-front/src/modules/settings/page-layout/utils/widgetRegistry.tsx index ec2ffdfc57..9adb4324e9 100644 --- a/packages/twenty-front/src/modules/settings/page-layout/utils/widgetRegistry.tsx +++ b/packages/twenty-front/src/modules/settings/page-layout/utils/widgetRegistry.tsx @@ -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 ( - + ); + } case WidgetType.VIEW: return null; diff --git a/packages/twenty-front/src/modules/ui/layout/tab-list/components/TabList.tsx b/packages/twenty-front/src/modules/ui/layout/tab-list/components/TabList.tsx index dad7ff5157..3a62edaa5d 100644 --- a/packages/twenty-front/src/modules/ui/layout/tab-list/components/TabList.tsx +++ b/packages/twenty-front/src/modules/ui/layout/tab-list/components/TabList.tsx @@ -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({}); 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 = ({ + + {onAddTab && ( + + + + + + )} )} @@ -227,6 +267,17 @@ export const TabList = ({ loading={loading} /> )} + + {onAddTab && ( + + + + )} diff --git a/packages/twenty-front/src/modules/ui/layout/tab-list/types/TabListProps.ts b/packages/twenty-front/src/modules/ui/layout/tab-list/types/TabListProps.ts index 99f812efa5..7f663f883c 100644 --- a/packages/twenty-front/src/modules/ui/layout/tab-list/types/TabListProps.ts +++ b/packages/twenty-front/src/modules/ui/layout/tab-list/types/TabListProps.ts @@ -7,4 +7,5 @@ export type TabListProps = { className?: string; isInRightDrawer?: boolean; componentInstanceId: string; + onAddTab?: () => void; }; diff --git a/packages/twenty-front/src/modules/ui/layout/tab-list/utils/calculateVisibleTabCount.ts b/packages/twenty-front/src/modules/ui/layout/tab-list/utils/calculateVisibleTabCount.ts index fb1e8edf3f..72e4d4ed35 100644 --- a/packages/twenty-front/src/modules/ui/layout/tab-list/utils/calculateVisibleTabCount.ts +++ b/packages/twenty-front/src/modules/ui/layout/tab-list/utils/calculateVisibleTabCount.ts @@ -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++) { diff --git a/packages/twenty-front/src/pages/settings/page-layout/SettingsPageLayoutEdit.tsx b/packages/twenty-front/src/pages/settings/page-layout/SettingsPageLayoutEdit.tsx index 60e6d70f5c..a8effe2c50 100644 --- a/packages/twenty-front/src/pages/settings/page-layout/SettingsPageLayoutEdit.tsx +++ b/packages/twenty-front/src/pages/settings/page-layout/SettingsPageLayoutEdit.tsx @@ -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(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(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 = () => { } > + {pageLayoutDraft.tabs.length > 0 && ( + + )} { { ) : ( - pageLayoutWidgets.map((widget) => ( + activeTabWidgets.map((widget) => (
{ {t`Name`} {t`Type`} + {t`Tabs`} {t`Widgets`} @@ -101,7 +102,13 @@ export const SettingsPageLayouts = () => { {layout.name} {layout.type} - {layout.widgets.length} + {layout.tabs.length} + + {layout.tabs.reduce( + (total, tab) => total + tab.widgets.length, + 0, + )} + { event.stopPropagation();