[Page Layouts] - Add tabs (#14318)
This commit is contained in:
+7
-4
@@ -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 => {
|
||||
|
||||
+44
-27
@@ -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: [] },
|
||||
});
|
||||
}
|
||||
},
|
||||
[],
|
||||
|
||||
+31
-9
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
+57
-24
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+135
@@ -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);
|
||||
});
|
||||
});
|
||||
+137
-10
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+42
-37
@@ -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);
|
||||
|
||||
+4
-3
@@ -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;
|
||||
|
||||
|
||||
+52
-12
@@ -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 };
|
||||
|
||||
+15
-6
@@ -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) => {
|
||||
|
||||
+45
@@ -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 };
|
||||
};
|
||||
+47
-43
@@ -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);
|
||||
|
||||
+20
-20
@@ -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),
|
||||
}));
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
+12
-24
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
+4
-2
@@ -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: {},
|
||||
});
|
||||
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { createState } from 'twenty-ui/utilities';
|
||||
|
||||
export const pageLayoutCurrentTabIdForCreationState = createState<
|
||||
string | null
|
||||
>({
|
||||
key: 'pageLayoutCurrentTabIdForCreationState',
|
||||
defaultValue: null,
|
||||
});
|
||||
+4
-2
@@ -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: [],
|
||||
},
|
||||
});
|
||||
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
import { createState } from 'twenty-ui/utilities';
|
||||
import { type Widget } from '../mocks/mockWidgets';
|
||||
|
||||
export const pageLayoutWidgetsState = createState<Widget[]>({
|
||||
key: 'pageLayoutWidgetsState',
|
||||
defaultValue: [],
|
||||
});
|
||||
+36
-13
@@ -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[]>({
|
||||
|
||||
+89
@@ -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);
|
||||
});
|
||||
});
|
||||
+25
-2
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
+79
@@ -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);
|
||||
});
|
||||
});
|
||||
+82
@@ -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();
|
||||
});
|
||||
});
|
||||
+109
@@ -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);
|
||||
});
|
||||
});
|
||||
+126
@@ -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;
|
||||
});
|
||||
};
|
||||
+3
-12
@@ -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: [] },
|
||||
};
|
||||
};
|
||||
+20
@@ -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;
|
||||
});
|
||||
};
|
||||
+24
@@ -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;
|
||||
};
|
||||
|
||||
+7
-1
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user