diff --git a/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useCanMovePageLayoutWidgetDown.test.tsx b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useCanMovePageLayoutWidgetDown.test.tsx new file mode 100644 index 0000000000..9c7a039845 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useCanMovePageLayoutWidgetDown.test.tsx @@ -0,0 +1,164 @@ +import { useCanMovePageLayoutWidgetDown } from '@/page-layout/hooks/useCanMovePageLayoutWidgetDown'; +import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; +import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout'; +import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; +import { renderHook } from '@testing-library/react'; +import { createStore } from 'jotai'; +import { type ReactNode } from 'react'; +import { + PageLayoutTabLayoutMode, + PageLayoutType, + WidgetType, +} from '~/generated-metadata/graphql'; +import { + PAGE_LAYOUT_TEST_INSTANCE_ID, + PageLayoutTestWrapper, +} from './PageLayoutTestWrapper'; + +const makeWidget = ( + id: string, + index: number, + tabId: string = 'tab-1', +): PageLayoutWidget => + ({ + id, + pageLayoutTabId: tabId, + title: id, + type: WidgetType.FIELDS, + isOverridden: false, + gridPosition: { column: 0, columnSpan: 1, row: 0, rowSpan: 1 }, + configuration: { __typename: 'FieldsConfiguration' as const }, + position: { + __typename: 'PageLayoutWidgetVerticalListPosition' as const, + layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, + index, + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + deletedAt: null, + }) as unknown as PageLayoutWidget; + +const makeTab = ( + id: string, + widgets: PageLayoutWidget[], + position: number = 0, + layoutMode: PageLayoutTabLayoutMode = PageLayoutTabLayoutMode.VERTICAL_LIST, +) => ({ + id, + applicationId: '', + title: id, + position, + layoutMode, + pageLayoutId: '', + widgets, + isOverridden: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + deletedAt: null, +}); + +const makeDraft = (tabs: ReturnType[]): DraftPageLayout => ({ + id: 'test-layout', + name: 'Test Layout', + type: PageLayoutType.RECORD_PAGE, + objectMetadataId: null, + tabs, +}); + +describe('useCanMovePageLayoutWidgetDown', () => { + const getWrapper = + (store = createStore()) => + ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + const getDraftAtom = () => + pageLayoutDraftComponentState.atomFamily({ + instanceId: PAGE_LAYOUT_TEST_INSTANCE_ID, + }); + + it('should return true when widget is not the last one', () => { + const store = createStore(); + const wrapper = getWrapper(store); + + const widgetA = makeWidget('widget-a', 0); + const widgetB = makeWidget('widget-b', 1); + + store.set( + getDraftAtom(), + makeDraft([makeTab('tab-1', [widgetA, widgetB])]), + ); + + const { result } = renderHook( + () => useCanMovePageLayoutWidgetDown(PAGE_LAYOUT_TEST_INSTANCE_ID), + { wrapper }, + ); + + expect(result.current.canMovePageLayoutWidgetDown('widget-a')).toBe(true); + }); + + it('should return false when widget is the last one', () => { + const store = createStore(); + const wrapper = getWrapper(store); + + const widgetA = makeWidget('widget-a', 0); + const widgetB = makeWidget('widget-b', 1); + + store.set( + getDraftAtom(), + makeDraft([makeTab('tab-1', [widgetA, widgetB])]), + ); + + const { result } = renderHook( + () => useCanMovePageLayoutWidgetDown(PAGE_LAYOUT_TEST_INSTANCE_ID), + { wrapper }, + ); + + expect(result.current.canMovePageLayoutWidgetDown('widget-b')).toBe(false); + }); + + it('should return false when widget is not found', () => { + const store = createStore(); + const wrapper = getWrapper(store); + + const widgetA = makeWidget('widget-a', 0); + + store.set(getDraftAtom(), makeDraft([makeTab('tab-1', [widgetA])])); + + const { result } = renderHook( + () => useCanMovePageLayoutWidgetDown(PAGE_LAYOUT_TEST_INSTANCE_ID), + { wrapper }, + ); + + expect(result.current.canMovePageLayoutWidgetDown('non-existent')).toBe( + false, + ); + }); + + it('should return false for non-VERTICAL_LIST tab', () => { + const store = createStore(); + const wrapper = getWrapper(store); + + const widgetA = makeWidget('widget-a', 0); + const widgetB = makeWidget('widget-b', 1); + + store.set( + getDraftAtom(), + makeDraft([ + makeTab('tab-1', [widgetA, widgetB], 0, PageLayoutTabLayoutMode.CANVAS), + ]), + ); + + const { result } = renderHook( + () => useCanMovePageLayoutWidgetDown(PAGE_LAYOUT_TEST_INSTANCE_ID), + { wrapper }, + ); + + expect(result.current.canMovePageLayoutWidgetDown('widget-a')).toBe(false); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useCanMovePageLayoutWidgetUp.test.tsx b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useCanMovePageLayoutWidgetUp.test.tsx new file mode 100644 index 0000000000..cf089362f6 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useCanMovePageLayoutWidgetUp.test.tsx @@ -0,0 +1,164 @@ +import { useCanMovePageLayoutWidgetUp } from '@/page-layout/hooks/useCanMovePageLayoutWidgetUp'; +import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; +import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout'; +import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; +import { renderHook } from '@testing-library/react'; +import { createStore } from 'jotai'; +import { type ReactNode } from 'react'; +import { + PageLayoutTabLayoutMode, + PageLayoutType, + WidgetType, +} from '~/generated-metadata/graphql'; +import { + PAGE_LAYOUT_TEST_INSTANCE_ID, + PageLayoutTestWrapper, +} from './PageLayoutTestWrapper'; + +const makeWidget = ( + id: string, + index: number, + tabId: string = 'tab-1', +): PageLayoutWidget => + ({ + id, + pageLayoutTabId: tabId, + title: id, + type: WidgetType.FIELDS, + isOverridden: false, + gridPosition: { column: 0, columnSpan: 1, row: 0, rowSpan: 1 }, + configuration: { __typename: 'FieldsConfiguration' as const }, + position: { + __typename: 'PageLayoutWidgetVerticalListPosition' as const, + layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, + index, + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + deletedAt: null, + }) as unknown as PageLayoutWidget; + +const makeTab = ( + id: string, + widgets: PageLayoutWidget[], + position: number = 0, + layoutMode: PageLayoutTabLayoutMode = PageLayoutTabLayoutMode.VERTICAL_LIST, +) => ({ + id, + applicationId: '', + title: id, + position, + layoutMode, + pageLayoutId: '', + widgets, + isOverridden: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + deletedAt: null, +}); + +const makeDraft = (tabs: ReturnType[]): DraftPageLayout => ({ + id: 'test-layout', + name: 'Test Layout', + type: PageLayoutType.RECORD_PAGE, + objectMetadataId: null, + tabs, +}); + +describe('useCanMovePageLayoutWidgetUp', () => { + const getWrapper = + (store = createStore()) => + ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + const getDraftAtom = () => + pageLayoutDraftComponentState.atomFamily({ + instanceId: PAGE_LAYOUT_TEST_INSTANCE_ID, + }); + + it('should return true when widget is not the first one', () => { + const store = createStore(); + const wrapper = getWrapper(store); + + const widgetA = makeWidget('widget-a', 0); + const widgetB = makeWidget('widget-b', 1); + + store.set( + getDraftAtom(), + makeDraft([makeTab('tab-1', [widgetA, widgetB])]), + ); + + const { result } = renderHook( + () => useCanMovePageLayoutWidgetUp(PAGE_LAYOUT_TEST_INSTANCE_ID), + { wrapper }, + ); + + expect(result.current.canMovePageLayoutWidgetUp('widget-b')).toBe(true); + }); + + it('should return false when widget is the first one', () => { + const store = createStore(); + const wrapper = getWrapper(store); + + const widgetA = makeWidget('widget-a', 0); + const widgetB = makeWidget('widget-b', 1); + + store.set( + getDraftAtom(), + makeDraft([makeTab('tab-1', [widgetA, widgetB])]), + ); + + const { result } = renderHook( + () => useCanMovePageLayoutWidgetUp(PAGE_LAYOUT_TEST_INSTANCE_ID), + { wrapper }, + ); + + expect(result.current.canMovePageLayoutWidgetUp('widget-a')).toBe(false); + }); + + it('should return false when widget is not found', () => { + const store = createStore(); + const wrapper = getWrapper(store); + + const widgetA = makeWidget('widget-a', 0); + + store.set(getDraftAtom(), makeDraft([makeTab('tab-1', [widgetA])])); + + const { result } = renderHook( + () => useCanMovePageLayoutWidgetUp(PAGE_LAYOUT_TEST_INSTANCE_ID), + { wrapper }, + ); + + expect(result.current.canMovePageLayoutWidgetUp('non-existent')).toBe( + false, + ); + }); + + it('should return false for non-VERTICAL_LIST tab', () => { + const store = createStore(); + const wrapper = getWrapper(store); + + const widgetA = makeWidget('widget-a', 0); + const widgetB = makeWidget('widget-b', 1); + + store.set( + getDraftAtom(), + makeDraft([ + makeTab('tab-1', [widgetA, widgetB], 0, PageLayoutTabLayoutMode.CANVAS), + ]), + ); + + const { result } = renderHook( + () => useCanMovePageLayoutWidgetUp(PAGE_LAYOUT_TEST_INSTANCE_ID), + { wrapper }, + ); + + expect(result.current.canMovePageLayoutWidgetUp('widget-b')).toBe(false); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useCreateWidgetFromClick.test.tsx b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useCreateWidgetFromClick.test.tsx index 3b4ebcff7e..748f4663c6 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useCreateWidgetFromClick.test.tsx +++ b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useCreateWidgetFromClick.test.tsx @@ -1,7 +1,7 @@ -import { useNavigatePageLayoutSidePanel } from '@/side-panel/pages/page-layout/hooks/useNavigatePageLayoutSidePanel'; import { useCreateWidgetFromClick } from '@/page-layout/hooks/useCreateWidgetFromClick'; import { pageLayoutDraggedAreaComponentState } from '@/page-layout/states/pageLayoutDraggedAreaComponentState'; import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState'; +import { useNavigatePageLayoutSidePanel } from '@/side-panel/pages/page-layout/hooks/useNavigatePageLayoutSidePanel'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { act, renderHook } from '@testing-library/react'; import { type ReactNode } from 'react'; @@ -28,7 +28,7 @@ describe('useCreateWidgetFromClick', () => { it('should set dragged area and navigate to widget selection when called with a cellId', () => { const { result } = renderHook( () => ({ - createWidget: useCreateWidgetFromClick(), + createWidget: useCreateWidgetFromClick(PAGE_LAYOUT_TEST_INSTANCE_ID), draggedArea: useAtomComponentStateValue( pageLayoutDraggedAreaComponentState, PAGE_LAYOUT_TEST_INSTANCE_ID, @@ -52,7 +52,7 @@ describe('useCreateWidgetFromClick', () => { expect(result.current.draggedArea).toEqual({ x: 2, y: 3, w: 1, h: 1 }); expect(result.current.editingWidgetId).toBeNull(); expect(mockNavigatePageLayoutSidePanel).toHaveBeenCalledWith({ - sidePanelPage: SidePanelPages.PageLayoutWidgetTypeSelect, + sidePanelPage: SidePanelPages.PageLayoutDashboardWidgetTypeSelect, resetNavigationStack: true, }); }); diff --git a/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useDeletePageLayoutWidget.test.tsx b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useDeletePageLayoutWidget.test.tsx index 65d7e486b2..315551063a 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useDeletePageLayoutWidget.test.tsx +++ b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useDeletePageLayoutWidget.test.tsx @@ -1,12 +1,12 @@ +import { useDeletePageLayoutWidget } from '@/page-layout/hooks/useDeletePageLayoutWidget'; +import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState'; import { act, renderHook } from '@testing-library/react'; +import { createStore } from 'jotai'; +import { type ReactNode } from 'react'; import { PAGE_LAYOUT_TEST_INSTANCE_ID, PageLayoutTestWrapper, } from './PageLayoutTestWrapper'; -import { useDeletePageLayoutWidget } from '@/page-layout/hooks/useDeletePageLayoutWidget'; -import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState'; -import { createStore } from 'jotai'; -import { type ReactNode } from 'react'; jest.mock( '@/page-layout/widgets/record-table/hooks/useDeleteViewForRecordTableWidget', @@ -17,6 +17,15 @@ jest.mock( }), ); +jest.mock( + '@/page-layout/widgets/fields/hooks/useDeleteViewForFieldsWidget', + () => ({ + useDeleteViewForFieldsWidget: () => ({ + deleteViewForFieldsWidget: jest.fn(), + }), + }), +); + jest.mock('@/side-panel/hooks/useSidePanelMenu', () => ({ useSidePanelMenu: () => ({ closeSidePanelMenu: jest.fn(), diff --git a/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useEndPageLayoutDragSelection.test.tsx b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useEndPageLayoutDragSelection.test.tsx index 0c8590d06a..b722d3800a 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useEndPageLayoutDragSelection.test.tsx +++ b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useEndPageLayoutDragSelection.test.tsx @@ -1,8 +1,8 @@ -import { useNavigatePageLayoutSidePanel } from '@/side-panel/pages/page-layout/hooks/useNavigatePageLayoutSidePanel'; import { useEndPageLayoutDragSelection } from '@/page-layout/hooks/useEndPageLayoutDragSelection'; import { pageLayoutDraggedAreaComponentState } from '@/page-layout/states/pageLayoutDraggedAreaComponentState'; import { pageLayoutSelectedCellsComponentState } from '@/page-layout/states/pageLayoutSelectedCellsComponentState'; import { calculateGridBoundsFromSelectedCells } from '@/page-layout/utils/calculateGridBoundsFromSelectedCells'; +import { useNavigatePageLayoutSidePanel } from '@/side-panel/pages/page-layout/hooks/useNavigatePageLayoutSidePanel'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { act, renderHook } from '@testing-library/react'; import { createStore } from 'jotai'; @@ -102,7 +102,7 @@ describe('useEndPageLayoutDragSelection', () => { expect(result.current.selectedCells.size).toBe(0); expect(mockNavigatePageLayoutSidePanel).toHaveBeenCalledWith({ - sidePanelPage: SidePanelPages.PageLayoutWidgetTypeSelect, + sidePanelPage: SidePanelPages.PageLayoutDashboardWidgetTypeSelect, resetNavigationStack: true, }); }); diff --git a/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useInsertCreatedWidgetAtContext.test.tsx b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useInsertCreatedWidgetAtContext.test.tsx new file mode 100644 index 0000000000..2d36a20518 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useInsertCreatedWidgetAtContext.test.tsx @@ -0,0 +1,342 @@ +import { useInsertCreatedWidgetAtContext } from '@/page-layout/hooks/useInsertCreatedWidgetAtContext'; +import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; +import { widgetInsertionContextComponentState } from '@/page-layout/states/widgetInsertionContextComponentState'; +import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout'; +import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; +import { act, renderHook } from '@testing-library/react'; +import { createStore } from 'jotai'; +import { type ReactNode } from 'react'; +import { + PageLayoutTabLayoutMode, + PageLayoutType, + WidgetType, +} from '~/generated-metadata/graphql'; +import { + PAGE_LAYOUT_TEST_INSTANCE_ID, + PageLayoutTestWrapper, +} from './PageLayoutTestWrapper'; + +const makeWidget = ( + id: string, + index: number, + tabId: string = 'tab-1', +): PageLayoutWidget => + ({ + id, + pageLayoutTabId: tabId, + title: id, + type: WidgetType.FIELDS, + isOverridden: false, + gridPosition: { column: 0, columnSpan: 1, row: 0, rowSpan: 1 }, + configuration: { __typename: 'FieldsConfiguration' as const }, + position: { + __typename: 'PageLayoutWidgetVerticalListPosition' as const, + layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, + index, + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + deletedAt: null, + }) as unknown as PageLayoutWidget; + +const makeTab = ( + id: string, + widgets: PageLayoutWidget[], + position: number = 0, +) => ({ + id, + applicationId: '', + title: id, + position, + pageLayoutId: '', + widgets, + isOverridden: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + deletedAt: null, +}); + +const makeDraft = (tabs: ReturnType[]): DraftPageLayout => ({ + id: 'test-layout', + name: 'Test Layout', + type: PageLayoutType.RECORD_PAGE, + objectMetadataId: null, + tabs, +}); + +describe('useInsertCreatedWidgetAtContext', () => { + const getWrapper = + (store = createStore()) => + ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + const getDraftAtom = () => + pageLayoutDraftComponentState.atomFamily({ + instanceId: PAGE_LAYOUT_TEST_INSTANCE_ID, + }); + + const getInsertionContextAtom = () => + widgetInsertionContextComponentState.atomFamily({ + instanceId: PAGE_LAYOUT_TEST_INSTANCE_ID, + }); + + it('should insert widget above target', () => { + const store = createStore(); + const wrapper = getWrapper(store); + + const widgetA = makeWidget('widget-a', 0); + const widgetB = makeWidget('widget-b', 1); + const widgetC = makeWidget('widget-c', 2); + + store.set( + getDraftAtom(), + makeDraft([makeTab('tab-1', [widgetA, widgetB, widgetC])]), + ); + store.set(getInsertionContextAtom(), { + targetWidgetId: 'widget-b', + direction: 'above', + }); + + const { result } = renderHook( + () => useInsertCreatedWidgetAtContext(PAGE_LAYOUT_TEST_INSTANCE_ID), + { wrapper }, + ); + + act(() => { + result.current.insertCreatedWidgetAtContext('widget-c'); + }); + + const draft = store.get(getDraftAtom()); + const widgetIds = draft.tabs[0].widgets.map((w) => w.id); + + expect(widgetIds).toEqual(['widget-a', 'widget-c', 'widget-b']); + }); + + it('should insert widget below target', () => { + const store = createStore(); + const wrapper = getWrapper(store); + + const widgetA = makeWidget('widget-a', 0); + const widgetB = makeWidget('widget-b', 1); + const widgetC = makeWidget('widget-c', 2); + + store.set( + getDraftAtom(), + makeDraft([makeTab('tab-1', [widgetA, widgetB, widgetC])]), + ); + store.set(getInsertionContextAtom(), { + targetWidgetId: 'widget-a', + direction: 'below', + }); + + const { result } = renderHook( + () => useInsertCreatedWidgetAtContext(PAGE_LAYOUT_TEST_INSTANCE_ID), + { wrapper }, + ); + + act(() => { + result.current.insertCreatedWidgetAtContext('widget-c'); + }); + + const draft = store.get(getDraftAtom()); + const widgetIds = draft.tabs[0].widgets.map((w) => w.id); + + expect(widgetIds).toEqual(['widget-a', 'widget-c', 'widget-b']); + }); + + it('should reindex all widgets with sequential positions', () => { + const store = createStore(); + const wrapper = getWrapper(store); + + const widgetA = makeWidget('widget-a', 0); + const widgetB = makeWidget('widget-b', 1); + const widgetC = makeWidget('widget-c', 2); + + store.set( + getDraftAtom(), + makeDraft([makeTab('tab-1', [widgetA, widgetB, widgetC])]), + ); + store.set(getInsertionContextAtom(), { + targetWidgetId: 'widget-a', + direction: 'above', + }); + + const { result } = renderHook( + () => useInsertCreatedWidgetAtContext(PAGE_LAYOUT_TEST_INSTANCE_ID), + { wrapper }, + ); + + act(() => { + result.current.insertCreatedWidgetAtContext('widget-c'); + }); + + const draft = store.get(getDraftAtom()); + const positions = draft.tabs[0].widgets.map((w) => w.position); + + positions.forEach((position, index) => { + expect(position).toEqual({ + __typename: 'PageLayoutWidgetVerticalListPosition', + layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, + index, + }); + }); + }); + + it('should no-op when insertion context is null', () => { + const store = createStore(); + const wrapper = getWrapper(store); + + const widgetA = makeWidget('widget-a', 0); + const widgetB = makeWidget('widget-b', 1); + + const initialDraft = makeDraft([makeTab('tab-1', [widgetA, widgetB])]); + store.set(getDraftAtom(), initialDraft); + + const { result } = renderHook( + () => useInsertCreatedWidgetAtContext(PAGE_LAYOUT_TEST_INSTANCE_ID), + { wrapper }, + ); + + act(() => { + result.current.insertCreatedWidgetAtContext('widget-b'); + }); + + const draft = store.get(getDraftAtom()); + + expect(draft).toBe(initialDraft); + }); + + it('should no-op when target widget is not found', () => { + const store = createStore(); + const wrapper = getWrapper(store); + + const widgetA = makeWidget('widget-a', 0); + const widgetB = makeWidget('widget-b', 1); + + const initialDraft = makeDraft([makeTab('tab-1', [widgetA, widgetB])]); + store.set(getDraftAtom(), initialDraft); + store.set(getInsertionContextAtom(), { + targetWidgetId: 'non-existent', + direction: 'above', + }); + + const { result } = renderHook( + () => useInsertCreatedWidgetAtContext(PAGE_LAYOUT_TEST_INSTANCE_ID), + { wrapper }, + ); + + act(() => { + result.current.insertCreatedWidgetAtContext('widget-b'); + }); + + const draft = store.get(getDraftAtom()); + + expect(draft).toBe(initialDraft); + }); + + it('should no-op when new widget does not exist in tab', () => { + const store = createStore(); + const wrapper = getWrapper(store); + + const widgetA = makeWidget('widget-a', 0); + const widgetB = makeWidget('widget-b', 1); + + const initialDraft = makeDraft([makeTab('tab-1', [widgetA, widgetB])]); + store.set(getDraftAtom(), initialDraft); + store.set(getInsertionContextAtom(), { + targetWidgetId: 'widget-a', + direction: 'below', + }); + + const { result } = renderHook( + () => useInsertCreatedWidgetAtContext(PAGE_LAYOUT_TEST_INSTANCE_ID), + { wrapper }, + ); + + act(() => { + result.current.insertCreatedWidgetAtContext('non-existent-widget'); + }); + + const draft = store.get(getDraftAtom()); + const widgetIds = draft.tabs[0].widgets.map((w) => w.id); + + expect(widgetIds).toEqual(['widget-a', 'widget-b']); + }); + + it('should clear insertion context after insertion', () => { + const store = createStore(); + const wrapper = getWrapper(store); + + const widgetA = makeWidget('widget-a', 0); + const widgetB = makeWidget('widget-b', 1); + + store.set( + getDraftAtom(), + makeDraft([makeTab('tab-1', [widgetA, widgetB])]), + ); + store.set(getInsertionContextAtom(), { + targetWidgetId: 'widget-a', + direction: 'below', + }); + + const { result } = renderHook( + () => useInsertCreatedWidgetAtContext(PAGE_LAYOUT_TEST_INSTANCE_ID), + { wrapper }, + ); + + act(() => { + result.current.insertCreatedWidgetAtContext('widget-b'); + }); + + const insertionContext = store.get(getInsertionContextAtom()); + + expect(insertionContext).toBeNull(); + }); + + it('should only modify the tab containing the target widget', () => { + const store = createStore(); + const wrapper = getWrapper(store); + + const tab1WidgetA = makeWidget('widget-a', 0, 'tab-1'); + const tab1WidgetB = makeWidget('widget-b', 1, 'tab-1'); + const tab1WidgetC = makeWidget('widget-c', 2, 'tab-1'); + + const tab2WidgetX = makeWidget('widget-x', 0, 'tab-2'); + const tab2WidgetY = makeWidget('widget-y', 1, 'tab-2'); + + store.set( + getDraftAtom(), + makeDraft([ + makeTab('tab-1', [tab1WidgetA, tab1WidgetB, tab1WidgetC], 0), + makeTab('tab-2', [tab2WidgetX, tab2WidgetY], 1), + ]), + ); + store.set(getInsertionContextAtom(), { + targetWidgetId: 'widget-a', + direction: 'above', + }); + + const { result } = renderHook( + () => useInsertCreatedWidgetAtContext(PAGE_LAYOUT_TEST_INSTANCE_ID), + { wrapper }, + ); + + act(() => { + result.current.insertCreatedWidgetAtContext('widget-c'); + }); + + const draft = store.get(getDraftAtom()); + + const tab1WidgetIds = draft.tabs[0].widgets.map((w) => w.id); + expect(tab1WidgetIds).toEqual(['widget-c', 'widget-a', 'widget-b']); + + const tab2WidgetIds = draft.tabs[1].widgets.map((w) => w.id); + expect(tab2WidgetIds).toEqual(['widget-x', 'widget-y']); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useMovePageLayoutWidgetDown.test.tsx b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useMovePageLayoutWidgetDown.test.tsx new file mode 100644 index 0000000000..35cdd0a0b2 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useMovePageLayoutWidgetDown.test.tsx @@ -0,0 +1,191 @@ +import { useMovePageLayoutWidgetDown } from '@/page-layout/hooks/useMovePageLayoutWidgetDown'; +import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; +import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout'; +import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; +import { act, renderHook } from '@testing-library/react'; +import { createStore } from 'jotai'; +import { type ReactNode } from 'react'; +import { + PageLayoutTabLayoutMode, + PageLayoutType, + WidgetType, +} from '~/generated-metadata/graphql'; +import { + PAGE_LAYOUT_TEST_INSTANCE_ID, + PageLayoutTestWrapper, +} from './PageLayoutTestWrapper'; + +const makeWidget = ( + id: string, + index: number, + tabId: string = 'tab-1', +): PageLayoutWidget => + ({ + id, + pageLayoutTabId: tabId, + title: id, + type: WidgetType.FIELDS, + isOverridden: false, + gridPosition: { column: 0, columnSpan: 1, row: 0, rowSpan: 1 }, + configuration: { __typename: 'FieldsConfiguration' as const }, + position: { + __typename: 'PageLayoutWidgetVerticalListPosition' as const, + layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, + index, + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + deletedAt: null, + }) as unknown as PageLayoutWidget; + +const makeTab = ( + id: string, + widgets: PageLayoutWidget[], + position: number = 0, +) => ({ + id, + applicationId: '', + title: id, + position, + layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, + pageLayoutId: '', + widgets, + isOverridden: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + deletedAt: null, +}); + +const makeDraft = (tabs: ReturnType[]): DraftPageLayout => ({ + id: 'test-layout', + name: 'Test Layout', + type: PageLayoutType.RECORD_PAGE, + objectMetadataId: null, + tabs, +}); + +describe('useMovePageLayoutWidgetDown', () => { + const getWrapper = + (store = createStore()) => + ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + const getDraftAtom = () => + pageLayoutDraftComponentState.atomFamily({ + instanceId: PAGE_LAYOUT_TEST_INSTANCE_ID, + }); + + it('should swap widget with the one below it', () => { + const store = createStore(); + const wrapper = getWrapper(store); + + const widgetA = makeWidget('widget-a', 0); + const widgetB = makeWidget('widget-b', 1); + const widgetC = makeWidget('widget-c', 2); + + store.set( + getDraftAtom(), + makeDraft([makeTab('tab-1', [widgetA, widgetB, widgetC])]), + ); + + const { result } = renderHook( + () => useMovePageLayoutWidgetDown(PAGE_LAYOUT_TEST_INSTANCE_ID), + { wrapper }, + ); + + act(() => { + result.current.movePageLayoutWidgetDown('widget-a'); + }); + + const draft = store.get(getDraftAtom()); + const widgets = draft.tabs[0].widgets; + + const widgetAPosition = widgets.find((w) => w.id === 'widget-a')?.position; + const widgetBPosition = widgets.find((w) => w.id === 'widget-b')?.position; + + expect(widgetAPosition).toEqual(expect.objectContaining({ index: 1 })); + expect(widgetBPosition).toEqual(expect.objectContaining({ index: 0 })); + }); + + it('should not change draft when widget is at the bottom', () => { + const store = createStore(); + const wrapper = getWrapper(store); + + const widgetA = makeWidget('widget-a', 0); + const widgetB = makeWidget('widget-b', 1); + + const initialDraft = makeDraft([makeTab('tab-1', [widgetA, widgetB])]); + store.set(getDraftAtom(), initialDraft); + + const { result } = renderHook( + () => useMovePageLayoutWidgetDown(PAGE_LAYOUT_TEST_INSTANCE_ID), + { wrapper }, + ); + + act(() => { + result.current.movePageLayoutWidgetDown('widget-b'); + }); + + const draft = store.get(getDraftAtom()); + + expect(draft).toBe(initialDraft); + }); + + it('should not change draft when widget is not found', () => { + const store = createStore(); + const wrapper = getWrapper(store); + + const widgetA = makeWidget('widget-a', 0); + const initialDraft = makeDraft([makeTab('tab-1', [widgetA])]); + store.set(getDraftAtom(), initialDraft); + + const { result } = renderHook( + () => useMovePageLayoutWidgetDown(PAGE_LAYOUT_TEST_INSTANCE_ID), + { wrapper }, + ); + + act(() => { + result.current.movePageLayoutWidgetDown('non-existent'); + }); + + const draft = store.get(getDraftAtom()); + + expect(draft).toBe(initialDraft); + }); + + it('should only modify the correct tab', () => { + const store = createStore(); + const wrapper = getWrapper(store); + + const tab1WidgetA = makeWidget('widget-a', 0, 'tab-1'); + const tab1WidgetB = makeWidget('widget-b', 1, 'tab-1'); + const tab2WidgetX = makeWidget('widget-x', 0, 'tab-2'); + + store.set( + getDraftAtom(), + makeDraft([ + makeTab('tab-1', [tab1WidgetA, tab1WidgetB], 0), + makeTab('tab-2', [tab2WidgetX], 1), + ]), + ); + + const { result } = renderHook( + () => useMovePageLayoutWidgetDown(PAGE_LAYOUT_TEST_INSTANCE_ID), + { wrapper }, + ); + + act(() => { + result.current.movePageLayoutWidgetDown('widget-a'); + }); + + const draft = store.get(getDraftAtom()); + + expect(draft.tabs[1].widgets[0].id).toBe('widget-x'); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useMovePageLayoutWidgetUp.test.tsx b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useMovePageLayoutWidgetUp.test.tsx new file mode 100644 index 0000000000..0ff48e692f --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useMovePageLayoutWidgetUp.test.tsx @@ -0,0 +1,191 @@ +import { useMovePageLayoutWidgetUp } from '@/page-layout/hooks/useMovePageLayoutWidgetUp'; +import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; +import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout'; +import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; +import { act, renderHook } from '@testing-library/react'; +import { createStore } from 'jotai'; +import { type ReactNode } from 'react'; +import { + PageLayoutTabLayoutMode, + PageLayoutType, + WidgetType, +} from '~/generated-metadata/graphql'; +import { + PAGE_LAYOUT_TEST_INSTANCE_ID, + PageLayoutTestWrapper, +} from './PageLayoutTestWrapper'; + +const makeWidget = ( + id: string, + index: number, + tabId: string = 'tab-1', +): PageLayoutWidget => + ({ + id, + pageLayoutTabId: tabId, + title: id, + type: WidgetType.FIELDS, + isOverridden: false, + gridPosition: { column: 0, columnSpan: 1, row: 0, rowSpan: 1 }, + configuration: { __typename: 'FieldsConfiguration' as const }, + position: { + __typename: 'PageLayoutWidgetVerticalListPosition' as const, + layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, + index, + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + deletedAt: null, + }) as unknown as PageLayoutWidget; + +const makeTab = ( + id: string, + widgets: PageLayoutWidget[], + position: number = 0, +) => ({ + id, + applicationId: '', + title: id, + position, + layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, + pageLayoutId: '', + widgets, + isOverridden: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + deletedAt: null, +}); + +const makeDraft = (tabs: ReturnType[]): DraftPageLayout => ({ + id: 'test-layout', + name: 'Test Layout', + type: PageLayoutType.RECORD_PAGE, + objectMetadataId: null, + tabs, +}); + +describe('useMovePageLayoutWidgetUp', () => { + const getWrapper = + (store = createStore()) => + ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + const getDraftAtom = () => + pageLayoutDraftComponentState.atomFamily({ + instanceId: PAGE_LAYOUT_TEST_INSTANCE_ID, + }); + + it('should swap widget with the one above it', () => { + const store = createStore(); + const wrapper = getWrapper(store); + + const widgetA = makeWidget('widget-a', 0); + const widgetB = makeWidget('widget-b', 1); + const widgetC = makeWidget('widget-c', 2); + + store.set( + getDraftAtom(), + makeDraft([makeTab('tab-1', [widgetA, widgetB, widgetC])]), + ); + + const { result } = renderHook( + () => useMovePageLayoutWidgetUp(PAGE_LAYOUT_TEST_INSTANCE_ID), + { wrapper }, + ); + + act(() => { + result.current.movePageLayoutWidgetUp('widget-b'); + }); + + const draft = store.get(getDraftAtom()); + const widgets = draft.tabs[0].widgets; + + const widgetAPosition = widgets.find((w) => w.id === 'widget-a')?.position; + const widgetBPosition = widgets.find((w) => w.id === 'widget-b')?.position; + + expect(widgetBPosition).toEqual(expect.objectContaining({ index: 0 })); + expect(widgetAPosition).toEqual(expect.objectContaining({ index: 1 })); + }); + + it('should not change draft when widget is already at the top', () => { + const store = createStore(); + const wrapper = getWrapper(store); + + const widgetA = makeWidget('widget-a', 0); + const widgetB = makeWidget('widget-b', 1); + + const initialDraft = makeDraft([makeTab('tab-1', [widgetA, widgetB])]); + store.set(getDraftAtom(), initialDraft); + + const { result } = renderHook( + () => useMovePageLayoutWidgetUp(PAGE_LAYOUT_TEST_INSTANCE_ID), + { wrapper }, + ); + + act(() => { + result.current.movePageLayoutWidgetUp('widget-a'); + }); + + const draft = store.get(getDraftAtom()); + + expect(draft).toBe(initialDraft); + }); + + it('should not change draft when widget is not found', () => { + const store = createStore(); + const wrapper = getWrapper(store); + + const widgetA = makeWidget('widget-a', 0); + const initialDraft = makeDraft([makeTab('tab-1', [widgetA])]); + store.set(getDraftAtom(), initialDraft); + + const { result } = renderHook( + () => useMovePageLayoutWidgetUp(PAGE_LAYOUT_TEST_INSTANCE_ID), + { wrapper }, + ); + + act(() => { + result.current.movePageLayoutWidgetUp('non-existent'); + }); + + const draft = store.get(getDraftAtom()); + + expect(draft).toBe(initialDraft); + }); + + it('should only modify the correct tab', () => { + const store = createStore(); + const wrapper = getWrapper(store); + + const tab1WidgetA = makeWidget('widget-a', 0, 'tab-1'); + const tab1WidgetB = makeWidget('widget-b', 1, 'tab-1'); + const tab2WidgetX = makeWidget('widget-x', 0, 'tab-2'); + + store.set( + getDraftAtom(), + makeDraft([ + makeTab('tab-1', [tab1WidgetA, tab1WidgetB], 0), + makeTab('tab-2', [tab2WidgetX], 1), + ]), + ); + + const { result } = renderHook( + () => useMovePageLayoutWidgetUp(PAGE_LAYOUT_TEST_INSTANCE_ID), + { wrapper }, + ); + + act(() => { + result.current.movePageLayoutWidgetUp('widget-b'); + }); + + const draft = store.get(getDraftAtom()); + + expect(draft.tabs[1].widgets[0].id).toBe('widget-x'); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useMoveWidgetToTab.test.tsx b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useMoveWidgetToTab.test.tsx new file mode 100644 index 0000000000..19f2066f74 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useMoveWidgetToTab.test.tsx @@ -0,0 +1,260 @@ +import { useMoveWidgetToTab } from '@/page-layout/hooks/useMoveWidgetToTab'; +import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; +import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout'; +import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; +import { act, renderHook } from '@testing-library/react'; +import { createStore } from 'jotai'; +import { type ReactNode } from 'react'; +import { + PageLayoutTabLayoutMode, + PageLayoutType, + WidgetType, +} from '~/generated-metadata/graphql'; +import { + PAGE_LAYOUT_TEST_INSTANCE_ID, + PageLayoutTestWrapper, +} from './PageLayoutTestWrapper'; + +const makeWidget = ( + id: string, + index: number, + tabId: string = 'tab-1', +): PageLayoutWidget => + ({ + id, + pageLayoutTabId: tabId, + title: id, + type: WidgetType.FIELDS, + isOverridden: false, + gridPosition: { column: 0, columnSpan: 1, row: 0, rowSpan: 1 }, + configuration: { __typename: 'FieldsConfiguration' as const }, + position: { + __typename: 'PageLayoutWidgetVerticalListPosition' as const, + layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, + index, + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + deletedAt: null, + }) as unknown as PageLayoutWidget; + +const makeTab = ( + id: string, + widgets: PageLayoutWidget[], + position: number = 0, +) => ({ + id, + applicationId: '', + title: id, + position, + layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, + pageLayoutId: '', + widgets, + isOverridden: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + deletedAt: null, +}); + +const makeDraft = (tabs: ReturnType[]): DraftPageLayout => ({ + id: 'test-layout', + name: 'Test Layout', + type: PageLayoutType.RECORD_PAGE, + objectMetadataId: null, + tabs, +}); + +describe('useMoveWidgetToTab', () => { + const getWrapper = + (store = createStore()) => + ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + const getDraftAtom = () => + pageLayoutDraftComponentState.atomFamily({ + instanceId: PAGE_LAYOUT_TEST_INSTANCE_ID, + }); + + it('should move widget from source tab to destination tab', () => { + const store = createStore(); + const wrapper = getWrapper(store); + + const widgetA = makeWidget('widget-a', 0, 'tab-1'); + const widgetB = makeWidget('widget-b', 1, 'tab-1'); + const widgetX = makeWidget('widget-x', 0, 'tab-2'); + + store.set( + getDraftAtom(), + makeDraft([ + makeTab('tab-1', [widgetA, widgetB], 0), + makeTab('tab-2', [widgetX], 1), + ]), + ); + + const { result } = renderHook( + () => useMoveWidgetToTab(PAGE_LAYOUT_TEST_INSTANCE_ID), + { wrapper }, + ); + + act(() => { + result.current.moveWidgetToTab('widget-a', 'tab-2'); + }); + + const draft = store.get(getDraftAtom()); + + const tab1WidgetIds = draft.tabs[0].widgets.map((w) => w.id); + expect(tab1WidgetIds).toEqual(['widget-b']); + + const tab2WidgetIds = draft.tabs[1].widgets.map((w) => w.id); + expect(tab2WidgetIds).toEqual(['widget-x', 'widget-a']); + }); + + it('should reindex remaining widgets in source tab', () => { + const store = createStore(); + const wrapper = getWrapper(store); + + const widgetA = makeWidget('widget-a', 0, 'tab-1'); + const widgetB = makeWidget('widget-b', 1, 'tab-1'); + const widgetC = makeWidget('widget-c', 2, 'tab-1'); + + store.set( + getDraftAtom(), + makeDraft([ + makeTab('tab-1', [widgetA, widgetB, widgetC], 0), + makeTab('tab-2', [], 1), + ]), + ); + + const { result } = renderHook( + () => useMoveWidgetToTab(PAGE_LAYOUT_TEST_INSTANCE_ID), + { wrapper }, + ); + + act(() => { + result.current.moveWidgetToTab('widget-a', 'tab-2'); + }); + + const draft = store.get(getDraftAtom()); + + const tab1Positions = draft.tabs[0].widgets.map((w) => { + if (w.position && 'index' in w.position) { + return w.position.index; + } + return -1; + }); + + expect(tab1Positions).toEqual([0, 1]); + }); + + it('should set moved widget position to end of destination tab', () => { + const store = createStore(); + const wrapper = getWrapper(store); + + const widgetA = makeWidget('widget-a', 0, 'tab-1'); + const widgetX = makeWidget('widget-x', 0, 'tab-2'); + const widgetY = makeWidget('widget-y', 1, 'tab-2'); + + store.set( + getDraftAtom(), + makeDraft([ + makeTab('tab-1', [widgetA], 0), + makeTab('tab-2', [widgetX, widgetY], 1), + ]), + ); + + const { result } = renderHook( + () => useMoveWidgetToTab(PAGE_LAYOUT_TEST_INSTANCE_ID), + { wrapper }, + ); + + act(() => { + result.current.moveWidgetToTab('widget-a', 'tab-2'); + }); + + const draft = store.get(getDraftAtom()); + const movedWidget = draft.tabs[1].widgets.find((w) => w.id === 'widget-a'); + + expect(movedWidget?.position).toEqual( + expect.objectContaining({ index: 2 }), + ); + }); + + it('should not change draft when source and destination are the same tab', () => { + const store = createStore(); + const wrapper = getWrapper(store); + + const widgetA = makeWidget('widget-a', 0, 'tab-1'); + const widgetB = makeWidget('widget-b', 1, 'tab-1'); + + const initialDraft = makeDraft([makeTab('tab-1', [widgetA, widgetB])]); + store.set(getDraftAtom(), initialDraft); + + const { result } = renderHook( + () => useMoveWidgetToTab(PAGE_LAYOUT_TEST_INSTANCE_ID), + { wrapper }, + ); + + act(() => { + result.current.moveWidgetToTab('widget-a', 'tab-1'); + }); + + const draft = store.get(getDraftAtom()); + + expect(draft).toBe(initialDraft); + }); + + it('should not change draft when widget is not found', () => { + const store = createStore(); + const wrapper = getWrapper(store); + + const widgetA = makeWidget('widget-a', 0, 'tab-1'); + + const initialDraft = makeDraft([ + makeTab('tab-1', [widgetA], 0), + makeTab('tab-2', [], 1), + ]); + store.set(getDraftAtom(), initialDraft); + + const { result } = renderHook( + () => useMoveWidgetToTab(PAGE_LAYOUT_TEST_INSTANCE_ID), + { wrapper }, + ); + + act(() => { + result.current.moveWidgetToTab('non-existent', 'tab-2'); + }); + + const draft = store.get(getDraftAtom()); + + expect(draft).toBe(initialDraft); + }); + + it('should not change draft when destination tab is not found', () => { + const store = createStore(); + const wrapper = getWrapper(store); + + const widgetA = makeWidget('widget-a', 0, 'tab-1'); + + const initialDraft = makeDraft([makeTab('tab-1', [widgetA])]); + store.set(getDraftAtom(), initialDraft); + + const { result } = renderHook( + () => useMoveWidgetToTab(PAGE_LAYOUT_TEST_INSTANCE_ID), + { wrapper }, + ); + + act(() => { + result.current.moveWidgetToTab('widget-a', 'non-existent-tab'); + }); + + const draft = store.get(getDraftAtom()); + + expect(draft).toBe(initialDraft); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useCanMovePageLayoutWidgetDown.ts b/packages/twenty-front/src/modules/page-layout/hooks/useCanMovePageLayoutWidgetDown.ts new file mode 100644 index 0000000000..82319aa45e --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/hooks/useCanMovePageLayoutWidgetDown.ts @@ -0,0 +1,49 @@ +import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; +import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; +import { sortWidgetsByVerticalListPosition } from '@/page-layout/utils/sortWidgetsByVerticalListPosition'; +import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; +import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; +import { useStore } from 'jotai'; +import { useCallback } from 'react'; +import { PageLayoutTabLayoutMode } from '~/generated-metadata/graphql'; + +export const useCanMovePageLayoutWidgetDown = ( + pageLayoutIdFromProps?: string, +) => { + const pageLayoutId = useAvailableComponentInstanceIdOrThrow( + PageLayoutComponentInstanceContext, + pageLayoutIdFromProps, + ); + + const pageLayoutDraftState = useAtomComponentStateCallbackState( + pageLayoutDraftComponentState, + pageLayoutId, + ); + + const store = useStore(); + + const canMovePageLayoutWidgetDown = useCallback( + (widgetId: string) => { + const draft = store.get(pageLayoutDraftState); + + const tab = draft.tabs.find((candidateTab) => + candidateTab.widgets.some((widget) => widget.id === widgetId), + ); + + if (!tab || tab.layoutMode !== PageLayoutTabLayoutMode.VERTICAL_LIST) { + return false; + } + + const sortedWidgets = sortWidgetsByVerticalListPosition(tab.widgets); + + const widgetIndex = sortedWidgets.findIndex( + (widget) => widget.id === widgetId, + ); + + return widgetIndex >= 0 && widgetIndex < sortedWidgets.length - 1; + }, + [pageLayoutDraftState, store], + ); + + return { canMovePageLayoutWidgetDown }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useCanMovePageLayoutWidgetUp.ts b/packages/twenty-front/src/modules/page-layout/hooks/useCanMovePageLayoutWidgetUp.ts new file mode 100644 index 0000000000..7b3bdb0bd6 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/hooks/useCanMovePageLayoutWidgetUp.ts @@ -0,0 +1,49 @@ +import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; +import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; +import { sortWidgetsByVerticalListPosition } from '@/page-layout/utils/sortWidgetsByVerticalListPosition'; +import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; +import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; +import { useStore } from 'jotai'; +import { useCallback } from 'react'; +import { PageLayoutTabLayoutMode } from '~/generated-metadata/graphql'; + +export const useCanMovePageLayoutWidgetUp = ( + pageLayoutIdFromProps?: string, +) => { + const pageLayoutId = useAvailableComponentInstanceIdOrThrow( + PageLayoutComponentInstanceContext, + pageLayoutIdFromProps, + ); + + const pageLayoutDraftState = useAtomComponentStateCallbackState( + pageLayoutDraftComponentState, + pageLayoutId, + ); + + const store = useStore(); + + const canMovePageLayoutWidgetUp = useCallback( + (widgetId: string) => { + const draft = store.get(pageLayoutDraftState); + + const tab = draft.tabs.find((candidateTab) => + candidateTab.widgets.some((widget) => widget.id === widgetId), + ); + + if (!tab || tab.layoutMode !== PageLayoutTabLayoutMode.VERTICAL_LIST) { + return false; + } + + const sortedWidgets = sortWidgetsByVerticalListPosition(tab.widgets); + + const widgetIndex = sortedWidgets.findIndex( + (widget) => widget.id === widgetId, + ); + + return widgetIndex > 0; + }, + [pageLayoutDraftState, store], + ); + + return { canMovePageLayoutWidgetUp }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useCreateRecordPageFieldsWidget.ts b/packages/twenty-front/src/modules/page-layout/hooks/useCreateRecordPageFieldsWidget.ts index a7130ea07c..59e0bc143f 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/useCreateRecordPageFieldsWidget.ts +++ b/packages/twenty-front/src/modules/page-layout/hooks/useCreateRecordPageFieldsWidget.ts @@ -5,15 +5,14 @@ import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDr import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState'; import { addWidgetToTab } from '@/page-layout/utils/addWidgetToTab'; import { createDefaultFieldsWidget } from '@/page-layout/utils/createDefaultFieldsWidget'; +import { useCreateViewForFieldsWidget } from '@/page-layout/widgets/fields/hooks/useCreateViewForFieldsWidget'; import { useNavigatePageLayoutSidePanel } from '@/side-panel/pages/page-layout/hooks/useNavigatePageLayoutSidePanel'; import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord'; import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; -import { usePerformViewAPIPersist } from '@/views/hooks/internal/usePerformViewAPIPersist'; import { useStore } from 'jotai'; import { useCallback } from 'react'; import { SidePanelPages } from 'twenty-shared/types'; import { v4 as uuidv4 } from 'uuid'; -import { ViewType } from '~/generated-metadata/graphql'; export const useCreateRecordPageFieldsWidget = () => { const { tabId } = usePageLayoutContentContext(); @@ -26,7 +25,7 @@ export const useCreateRecordPageFieldsWidget = () => { const { currentPageLayout } = useCurrentPageLayoutOrThrow(); - const { performViewAPICreate } = usePerformViewAPIPersist(); + const { createViewForFieldsWidget } = useCreateViewForFieldsWidget(); const pageLayoutDraftState = useAtomComponentStateCallbackState( pageLayoutDraftComponentState, @@ -41,22 +40,12 @@ export const useCreateRecordPageFieldsWidget = () => { const store = useStore(); const createRecordPageFieldsWidget = useCallback(async () => { - const viewId = uuidv4(); + const viewId = await createViewForFieldsWidget({ + objectMetadataId: objectMetadataItem.id, + viewName: `${objectMetadataItem.labelSingular} Fields`, + }); - const result = await performViewAPICreate( - { - input: { - id: viewId, - name: `${objectMetadataItem.labelSingular} Fields`, - icon: 'IconList', - objectMetadataId: objectMetadataItem.id, - type: ViewType.FIELDS_WIDGET, - }, - }, - objectMetadataItem.id, - ); - - if (result.status === 'failed') { + if (viewId === null) { return; } @@ -81,18 +70,18 @@ export const useCreateRecordPageFieldsWidget = () => { store.set(pageLayoutEditingWidgetIdState, widgetId); navigatePageLayoutSidePanel({ - sidePanelPage: SidePanelPages.PageLayoutFieldsSettings, + sidePanelPage: SidePanelPages.RecordPageFieldsSettings, focusTitleInput: true, resetNavigationStack: true, }); }, [ + createViewForFieldsWidget, currentPageLayout.tabs, navigatePageLayoutSidePanel, objectMetadataItem.id, objectMetadataItem.labelSingular, pageLayoutDraftState, pageLayoutEditingWidgetIdState, - performViewAPICreate, store, tabId, ]); diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useCreateWidgetFromClick.ts b/packages/twenty-front/src/modules/page-layout/hooks/useCreateWidgetFromClick.ts index bdb8fdc591..34227986f1 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/useCreateWidgetFromClick.ts +++ b/packages/twenty-front/src/modules/page-layout/hooks/useCreateWidgetFromClick.ts @@ -1,19 +1,28 @@ -import { useNavigatePageLayoutSidePanel } from '@/side-panel/pages/page-layout/hooks/useNavigatePageLayoutSidePanel'; +import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; import { pageLayoutDraggedAreaComponentState } from '@/page-layout/states/pageLayoutDraggedAreaComponentState'; import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState'; import { parseCellIdToCoordinates } from '@/page-layout/utils/parseCellIdToCoordinates'; +import { useNavigatePageLayoutSidePanel } from '@/side-panel/pages/page-layout/hooks/useNavigatePageLayoutSidePanel'; +import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; import { useStore } from 'jotai'; import { useCallback } from 'react'; import { SidePanelPages } from 'twenty-shared/types'; -export const useCreateWidgetFromClick = () => { +export const useCreateWidgetFromClick = (pageLayoutIdFromProps?: string) => { + const pageLayoutId = useAvailableComponentInstanceIdOrThrow( + PageLayoutComponentInstanceContext, + pageLayoutIdFromProps, + ); + const pageLayoutDraggedAreaState = useAtomComponentStateCallbackState( pageLayoutDraggedAreaComponentState, + pageLayoutId, ); const pageLayoutEditingWidgetIdState = useAtomComponentStateCallbackState( pageLayoutEditingWidgetIdComponentState, + pageLayoutId, ); const { navigatePageLayoutSidePanel } = useNavigatePageLayoutSidePanel(); @@ -29,7 +38,7 @@ export const useCreateWidgetFromClick = () => { store.set(pageLayoutEditingWidgetIdState, null); navigatePageLayoutSidePanel({ - sidePanelPage: SidePanelPages.PageLayoutWidgetTypeSelect, + sidePanelPage: SidePanelPages.PageLayoutDashboardWidgetTypeSelect, resetNavigationStack: true, }); }, diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useDeletePageLayoutWidget.ts b/packages/twenty-front/src/modules/page-layout/hooks/useDeletePageLayoutWidget.ts index d9d1ad7a9d..5779b90949 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/useDeletePageLayoutWidget.ts +++ b/packages/twenty-front/src/modules/page-layout/hooks/useDeletePageLayoutWidget.ts @@ -4,6 +4,7 @@ import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDr import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState'; import { removeWidgetFromTab } from '@/page-layout/utils/removeWidgetFromTab'; import { removeWidgetLayoutFromTab } from '@/page-layout/utils/removeWidgetLayoutFromTab'; +import { useDeleteViewForFieldsWidget } from '@/page-layout/widgets/fields/hooks/useDeleteViewForFieldsWidget'; import { useDeleteViewForRecordTableWidget } from '@/page-layout/widgets/record-table/hooks/useDeleteViewForRecordTableWidget'; import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; @@ -36,6 +37,8 @@ export const useDeletePageLayoutWidget = (pageLayoutIdFromProps?: string) => { const { closeSidePanelMenu } = useSidePanelMenu(); + const { deleteViewForFieldsWidget } = useDeleteViewForFieldsWidget(); + const { deleteViewForRecordTableWidget } = useDeleteViewForRecordTableWidget(); @@ -67,6 +70,17 @@ export const useDeletePageLayoutWidget = (pageLayoutIdFromProps?: string) => { ); } + if ( + isDefined(widgetToDelete) && + widgetToDelete.type === WidgetType.FIELDS && + 'viewId' in widgetToDelete.configuration && + isDefined(widgetToDelete.configuration.viewId) + ) { + deleteViewForFieldsWidget( + widgetToDelete.configuration.viewId as string, + ); + } + const tabId = tabWithWidget?.id; if (isDefined(tabId)) { @@ -93,6 +107,7 @@ export const useDeletePageLayoutWidget = (pageLayoutIdFromProps?: string) => { }, [ closeSidePanelMenu, + deleteViewForFieldsWidget, deleteViewForRecordTableWidget, pageLayoutCurrentLayoutsState, pageLayoutDraftState, diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useEndPageLayoutDragSelection.ts b/packages/twenty-front/src/modules/page-layout/hooks/useEndPageLayoutDragSelection.ts index 7d0b48917b..329be603e2 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/useEndPageLayoutDragSelection.ts +++ b/packages/twenty-front/src/modules/page-layout/hooks/useEndPageLayoutDragSelection.ts @@ -1,9 +1,9 @@ -import { useNavigatePageLayoutSidePanel } from '@/side-panel/pages/page-layout/hooks/useNavigatePageLayoutSidePanel'; import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; import { pageLayoutDraggedAreaComponentState } from '@/page-layout/states/pageLayoutDraggedAreaComponentState'; import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState'; import { pageLayoutSelectedCellsComponentState } from '@/page-layout/states/pageLayoutSelectedCellsComponentState'; import { calculateGridBoundsFromSelectedCells } from '@/page-layout/utils/calculateGridBoundsFromSelectedCells'; +import { useNavigatePageLayoutSidePanel } from '@/side-panel/pages/page-layout/hooks/useNavigatePageLayoutSidePanel'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; import { useStore } from 'jotai'; @@ -50,7 +50,7 @@ export const useEndPageLayoutDragSelection = ( store.set(pageLayoutEditingWidgetIdState, null); navigatePageLayoutSidePanel({ - sidePanelPage: SidePanelPages.PageLayoutWidgetTypeSelect, + sidePanelPage: SidePanelPages.PageLayoutDashboardWidgetTypeSelect, resetNavigationStack: true, }); } diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useInsertCreatedWidgetAtContext.ts b/packages/twenty-front/src/modules/page-layout/hooks/useInsertCreatedWidgetAtContext.ts new file mode 100644 index 0000000000..ee0d0ac6c5 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/hooks/useInsertCreatedWidgetAtContext.ts @@ -0,0 +1,123 @@ +import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; +import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; +import { widgetInsertionContextComponentState } from '@/page-layout/states/widgetInsertionContextComponentState'; +import { isVerticalListPosition } from '@/page-layout/utils/isVerticalListPosition'; +import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; +import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; +import { useStore } from 'jotai'; +import { useCallback } from 'react'; +import { isDefined } from 'twenty-shared/utils'; +import { PageLayoutTabLayoutMode } from '~/generated-metadata/graphql'; + +export const useInsertCreatedWidgetAtContext = ( + pageLayoutIdFromProps?: string, +) => { + const pageLayoutId = useAvailableComponentInstanceIdOrThrow( + PageLayoutComponentInstanceContext, + pageLayoutIdFromProps, + ); + + const pageLayoutDraftState = useAtomComponentStateCallbackState( + pageLayoutDraftComponentState, + pageLayoutId, + ); + + const widgetInsertionContextState = useAtomComponentStateCallbackState( + widgetInsertionContextComponentState, + pageLayoutId, + ); + + const store = useStore(); + + const insertCreatedWidgetAtContext = useCallback( + (newWidgetId: string) => { + const insertionContext = store.get(widgetInsertionContextState); + + if (insertionContext === null) { + return; + } + + store.set(pageLayoutDraftState, (prev) => { + const tab = prev.tabs.find((candidateTab) => + candidateTab.widgets.some( + (widget) => widget.id === insertionContext.targetWidgetId, + ), + ); + + if (!tab) { + return prev; + } + + const sortedWidgets = tab.widgets.toSorted((widgetA, widgetB) => { + const indexA = + isDefined(widgetA.position) && + isVerticalListPosition(widgetA.position) + ? widgetA.position.index + : 0; + const indexB = + isDefined(widgetB.position) && + isVerticalListPosition(widgetB.position) + ? widgetB.position.index + : 0; + return indexA - indexB; + }); + + const targetIndex = sortedWidgets.findIndex( + (widget) => widget.id === insertionContext.targetWidgetId, + ); + + if (targetIndex < 0) { + return prev; + } + + const insertAtIndex = + insertionContext.direction === 'above' + ? targetIndex + : targetIndex + 1; + + const widgetsWithoutNew = sortedWidgets.filter( + (widget) => widget.id !== newWidgetId, + ); + + const newWidget = tab.widgets.find( + (widget) => widget.id === newWidgetId, + ); + + if (!newWidget) { + return prev; + } + + widgetsWithoutNew.splice(insertAtIndex, 0, newWidget); + + const reindexedWidgets = widgetsWithoutNew.map( + (widget, widgetIndex) => ({ + ...widget, + position: { + __typename: 'PageLayoutWidgetVerticalListPosition' as const, + layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, + index: widgetIndex, + }, + }), + ); + + return { + ...prev, + tabs: prev.tabs.map((currentTab) => { + if (currentTab.id !== tab.id) { + return currentTab; + } + return { + ...currentTab, + widgets: reindexedWidgets, + }; + }), + }; + }); + + store.set(widgetInsertionContextState, null); + }, + [pageLayoutDraftState, store, widgetInsertionContextState], + ); + + return { insertCreatedWidgetAtContext }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useMovePageLayoutWidgetDown.ts b/packages/twenty-front/src/modules/page-layout/hooks/useMovePageLayoutWidgetDown.ts new file mode 100644 index 0000000000..2eb1c2faf4 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/hooks/useMovePageLayoutWidgetDown.ts @@ -0,0 +1,102 @@ +import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; +import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; +import { isVerticalListPosition } from '@/page-layout/utils/isVerticalListPosition'; +import { sortWidgetsByVerticalListPosition } from '@/page-layout/utils/sortWidgetsByVerticalListPosition'; +import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; +import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; +import { useStore } from 'jotai'; +import { useCallback } from 'react'; +import { isDefined } from 'twenty-shared/utils'; +import { PageLayoutTabLayoutMode } from '~/generated-metadata/graphql'; + +export const useMovePageLayoutWidgetDown = (pageLayoutIdFromProps?: string) => { + const pageLayoutId = useAvailableComponentInstanceIdOrThrow( + PageLayoutComponentInstanceContext, + pageLayoutIdFromProps, + ); + + const pageLayoutDraftState = useAtomComponentStateCallbackState( + pageLayoutDraftComponentState, + pageLayoutId, + ); + + const store = useStore(); + + const movePageLayoutWidgetDown = useCallback( + (widgetId: string) => { + store.set(pageLayoutDraftState, (prev) => { + const tab = prev.tabs.find((candidateTab) => + candidateTab.widgets.some((widget) => widget.id === widgetId), + ); + + if (!tab) { + return prev; + } + + const sortedWidgets = sortWidgetsByVerticalListPosition(tab.widgets); + + const currentIndex = sortedWidgets.findIndex( + (widget) => widget.id === widgetId, + ); + + if (currentIndex < 0 || currentIndex >= sortedWidgets.length - 1) { + return prev; + } + + const currentWidget = sortedWidgets[currentIndex]; + const neighborWidget = sortedWidgets[currentIndex + 1]; + + const currentPositionIndex = + isDefined(currentWidget.position) && + isVerticalListPosition(currentWidget.position) + ? currentWidget.position.index + : currentIndex; + const neighborPositionIndex = + isDefined(neighborWidget.position) && + isVerticalListPosition(neighborWidget.position) + ? neighborWidget.position.index + : currentIndex + 1; + + return { + ...prev, + tabs: prev.tabs.map((currentTab) => { + if (currentTab.id !== tab.id) { + return currentTab; + } + return { + ...currentTab, + widgets: currentTab.widgets.map((widget) => { + if (widget.id === currentWidget.id) { + return { + ...widget, + position: { + __typename: + 'PageLayoutWidgetVerticalListPosition' as const, + layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, + index: neighborPositionIndex, + }, + }; + } + if (widget.id === neighborWidget.id) { + return { + ...widget, + position: { + __typename: + 'PageLayoutWidgetVerticalListPosition' as const, + layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, + index: currentPositionIndex, + }, + }; + } + return widget; + }), + }; + }), + }; + }); + }, + [pageLayoutDraftState, store], + ); + + return { movePageLayoutWidgetDown }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useMovePageLayoutWidgetUp.ts b/packages/twenty-front/src/modules/page-layout/hooks/useMovePageLayoutWidgetUp.ts new file mode 100644 index 0000000000..bce10b9c41 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/hooks/useMovePageLayoutWidgetUp.ts @@ -0,0 +1,102 @@ +import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; +import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; +import { isVerticalListPosition } from '@/page-layout/utils/isVerticalListPosition'; +import { sortWidgetsByVerticalListPosition } from '@/page-layout/utils/sortWidgetsByVerticalListPosition'; +import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; +import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; +import { useStore } from 'jotai'; +import { useCallback } from 'react'; +import { isDefined } from 'twenty-shared/utils'; +import { PageLayoutTabLayoutMode } from '~/generated-metadata/graphql'; + +export const useMovePageLayoutWidgetUp = (pageLayoutIdFromProps?: string) => { + const pageLayoutId = useAvailableComponentInstanceIdOrThrow( + PageLayoutComponentInstanceContext, + pageLayoutIdFromProps, + ); + + const pageLayoutDraftState = useAtomComponentStateCallbackState( + pageLayoutDraftComponentState, + pageLayoutId, + ); + + const store = useStore(); + + const movePageLayoutWidgetUp = useCallback( + (widgetId: string) => { + store.set(pageLayoutDraftState, (prev) => { + const tab = prev.tabs.find((candidateTab) => + candidateTab.widgets.some((widget) => widget.id === widgetId), + ); + + if (!tab) { + return prev; + } + + const sortedWidgets = sortWidgetsByVerticalListPosition(tab.widgets); + + const currentIndex = sortedWidgets.findIndex( + (widget) => widget.id === widgetId, + ); + + if (currentIndex <= 0) { + return prev; + } + + const currentWidget = sortedWidgets[currentIndex]; + const neighborWidget = sortedWidgets[currentIndex - 1]; + + const currentPositionIndex = + isDefined(currentWidget.position) && + isVerticalListPosition(currentWidget.position) + ? currentWidget.position.index + : currentIndex; + const neighborPositionIndex = + isDefined(neighborWidget.position) && + isVerticalListPosition(neighborWidget.position) + ? neighborWidget.position.index + : currentIndex - 1; + + return { + ...prev, + tabs: prev.tabs.map((currentTab) => { + if (currentTab.id !== tab.id) { + return currentTab; + } + return { + ...currentTab, + widgets: currentTab.widgets.map((widget) => { + if (widget.id === currentWidget.id) { + return { + ...widget, + position: { + __typename: + 'PageLayoutWidgetVerticalListPosition' as const, + layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, + index: neighborPositionIndex, + }, + }; + } + if (widget.id === neighborWidget.id) { + return { + ...widget, + position: { + __typename: + 'PageLayoutWidgetVerticalListPosition' as const, + layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, + index: currentPositionIndex, + }, + }; + } + return widget; + }), + }; + }), + }; + }); + }, + [pageLayoutDraftState, store], + ); + + return { movePageLayoutWidgetUp }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useMoveWidgetToTab.ts b/packages/twenty-front/src/modules/page-layout/hooks/useMoveWidgetToTab.ts new file mode 100644 index 0000000000..dcfe58c2b5 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/hooks/useMoveWidgetToTab.ts @@ -0,0 +1,108 @@ +import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; +import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; +import { isVerticalListPosition } from '@/page-layout/utils/isVerticalListPosition'; +import { sortWidgetsByVerticalListPosition } from '@/page-layout/utils/sortWidgetsByVerticalListPosition'; +import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; +import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; +import { useStore } from 'jotai'; +import { useCallback } from 'react'; +import { isDefined } from 'twenty-shared/utils'; +import { PageLayoutTabLayoutMode } from '~/generated-metadata/graphql'; + +export const useMoveWidgetToTab = (pageLayoutIdFromProps?: string) => { + const pageLayoutId = useAvailableComponentInstanceIdOrThrow( + PageLayoutComponentInstanceContext, + pageLayoutIdFromProps, + ); + + const pageLayoutDraftState = useAtomComponentStateCallbackState( + pageLayoutDraftComponentState, + pageLayoutId, + ); + + const store = useStore(); + + const moveWidgetToTab = useCallback( + (widgetId: string, destinationTabId: string) => { + store.set(pageLayoutDraftState, (prev) => { + const sourceTab = prev.tabs.find((candidateTab) => + candidateTab.widgets.some((widget) => widget.id === widgetId), + ); + + if (!sourceTab) { + return prev; + } + + if (sourceTab.id === destinationTabId) { + return prev; + } + + const destinationTab = prev.tabs.find( + (candidateTab) => candidateTab.id === destinationTabId, + ); + + if (!destinationTab) { + return prev; + } + + const widget = sourceTab.widgets.find( + (candidateWidget) => candidateWidget.id === widgetId, + ); + + if (!widget) { + return prev; + } + + const movedWidget = { + ...widget, + pageLayoutTabId: destinationTabId, + position: { + __typename: 'PageLayoutWidgetVerticalListPosition' as const, + layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, + index: destinationTab.widgets.length, + }, + }; + + return { + ...prev, + tabs: prev.tabs.map((currentTab) => { + if (currentTab.id === sourceTab.id) { + const remainingWidgets = sortWidgetsByVerticalListPosition( + currentTab.widgets, + ) + .filter((tabWidget) => tabWidget.id !== widgetId) + .map((tabWidget, widgetIndex) => ({ + ...tabWidget, + position: + isDefined(tabWidget.position) && + isVerticalListPosition(tabWidget.position) + ? { + __typename: + 'PageLayoutWidgetVerticalListPosition' as const, + layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, + index: widgetIndex, + } + : tabWidget.position, + })); + + return { + ...currentTab, + widgets: remainingWidgets, + }; + } + if (currentTab.id === destinationTabId) { + return { + ...currentTab, + widgets: [...currentTab.widgets, movedWidget], + }; + } + return currentTab; + }), + }; + }); + }, + [pageLayoutDraftState, store], + ); + + return { moveWidgetToTab }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useNavigateToMoreWidgets.ts b/packages/twenty-front/src/modules/page-layout/hooks/useNavigateToMoreWidgets.ts index 277ea99d5d..cf859fe4fe 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/useNavigateToMoreWidgets.ts +++ b/packages/twenty-front/src/modules/page-layout/hooks/useNavigateToMoreWidgets.ts @@ -7,7 +7,7 @@ export const useNavigateToMoreWidgets = () => { const navigateToMoreWidgets = useCallback(() => { navigatePageLayoutSidePanel({ - sidePanelPage: SidePanelPages.PageLayoutWidgetTypeSelect, + sidePanelPage: SidePanelPages.PageLayoutRecordPageWidgetTypeSelect, }); }, [navigatePageLayoutSidePanel]); diff --git a/packages/twenty-front/src/modules/page-layout/hooks/usePageLayoutTabWithVisibleWidgetsOrThrow.ts b/packages/twenty-front/src/modules/page-layout/hooks/usePageLayoutTabWithVisibleWidgetsOrThrow.ts index b05b0ff59a..5a85ffdfe9 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/usePageLayoutTabWithVisibleWidgetsOrThrow.ts +++ b/packages/twenty-front/src/modules/page-layout/hooks/usePageLayoutTabWithVisibleWidgetsOrThrow.ts @@ -3,9 +3,11 @@ import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutIn import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab'; import { buildWidgetVisibilityContext } from '@/page-layout/utils/buildWidgetVisibilityContext'; import { filterVisibleWidgets } from '@/page-layout/utils/filterVisibleWidgets'; +import { sortWidgetsByVerticalListPosition } from '@/page-layout/utils/sortWidgetsByVerticalListPosition'; import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; import { isDefined } from 'twenty-shared/utils'; +import { PageLayoutTabLayoutMode } from '~/generated-metadata/graphql'; export const usePageLayoutTabWithVisibleWidgetsOrThrow = ( tabId: string, @@ -26,13 +28,27 @@ export const usePageLayoutTabWithVisibleWidgetsOrThrow = ( } if (isPageLayoutInEditMode) { - return tab; + return { + ...tab, + widgets: + tab.layoutMode === PageLayoutTabLayoutMode.VERTICAL_LIST + ? sortWidgetsByVerticalListPosition(tab.widgets) + : tab.widgets, + }; } const context = buildWidgetVisibilityContext({ isMobile, isInSidePanel }); + const visibleWidgets = filterVisibleWidgets({ + widgets: tab.widgets, + context, + }); + return { ...tab, - widgets: filterVisibleWidgets({ widgets: tab.widgets, context }), + widgets: + tab.layoutMode === PageLayoutTabLayoutMode.VERTICAL_LIST + ? sortWidgetsByVerticalListPosition(visibleWidgets) + : visibleWidgets, }; }; diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useRemovePageLayoutWidgetAndPreservePosition.ts b/packages/twenty-front/src/modules/page-layout/hooks/useRemovePageLayoutWidgetAndPreservePosition.ts index 780c61137a..4215087613 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/useRemovePageLayoutWidgetAndPreservePosition.ts +++ b/packages/twenty-front/src/modules/page-layout/hooks/useRemovePageLayoutWidgetAndPreservePosition.ts @@ -3,8 +3,10 @@ import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pag import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; import { pageLayoutDraggedAreaComponentState } from '@/page-layout/states/pageLayoutDraggedAreaComponentState'; import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState'; +import { getWidgetConfigurationViewId } from '@/page-layout/utils/getWidgetConfigurationViewId'; import { removeWidgetFromTab } from '@/page-layout/utils/removeWidgetFromTab'; import { removeWidgetLayoutFromTab } from '@/page-layout/utils/removeWidgetLayoutFromTab'; +import { useDeleteViewForFieldsWidget } from '@/page-layout/widgets/fields/hooks/useDeleteViewForFieldsWidget'; import { useDeleteViewForRecordTableWidget } from '@/page-layout/widgets/record-table/hooks/useDeleteViewForRecordTableWidget'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; @@ -41,6 +43,8 @@ export const useRemovePageLayoutWidgetAndPreservePosition = ( pageLayoutId, ); + const { deleteViewForFieldsWidget } = useDeleteViewForFieldsWidget(); + const { deleteViewForRecordTableWidget } = useDeleteViewForRecordTableWidget(); @@ -59,15 +63,20 @@ export const useRemovePageLayoutWidgetAndPreservePosition = ( (widget) => widget.id === widgetId, ); - if ( - isDefined(widgetToRemove) && - widgetToRemove.type === WidgetType.RECORD_TABLE && - 'viewId' in widgetToRemove.configuration && - isDefined(widgetToRemove.configuration.viewId) - ) { - deleteViewForRecordTableWidget( - widgetToRemove.configuration.viewId as string, + if (isDefined(widgetToRemove)) { + const viewId = getWidgetConfigurationViewId( + widgetToRemove.configuration, ); + + if (isDefined(viewId)) { + if (widgetToRemove.type === WidgetType.RECORD_TABLE) { + deleteViewForRecordTableWidget(viewId); + } + + if (widgetToRemove.type === WidgetType.FIELDS) { + deleteViewForFieldsWidget(viewId); + } + } } const tabId = tabWithWidget?.id; @@ -107,6 +116,7 @@ export const useRemovePageLayoutWidgetAndPreservePosition = ( store.set(pageLayoutEditingWidgetIdState, null); }, [ + deleteViewForFieldsWidget, deleteViewForRecordTableWidget, pageLayoutCurrentLayoutsState, pageLayoutDraftState, diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useReorderPageLayoutWidgets.ts b/packages/twenty-front/src/modules/page-layout/hooks/useReorderPageLayoutWidgets.ts index d65f0f3745..7583bc731a 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/useReorderPageLayoutWidgets.ts +++ b/packages/twenty-front/src/modules/page-layout/hooks/useReorderPageLayoutWidgets.ts @@ -6,6 +6,7 @@ import { type DropResult } from '@hello-pangea/dnd'; import { useStore } from 'jotai'; import { useCallback } from 'react'; import { isDefined } from 'twenty-shared/utils'; +import { PageLayoutTabLayoutMode } from '~/generated-metadata/graphql'; export const useReorderPageLayoutWidgets = ( tabId: string, @@ -35,10 +36,19 @@ export const useReorderPageLayoutWidgets = ( const [removed] = newWidgets.splice(result.source.index, 1); newWidgets.splice(result.destination!.index, 0, removed); + const reindexedWidgets = newWidgets.map((widget, index) => ({ + ...widget, + position: { + __typename: 'PageLayoutWidgetVerticalListPosition' as const, + layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, + index, + }, + })); + return { ...prev, tabs: prev.tabs.map((t) => - t.id === tabId ? { ...t, widgets: newWidgets } : t, + t.id === tabId ? { ...t, widgets: reindexedWidgets } : t, ), }; }); diff --git a/packages/twenty-front/src/modules/page-layout/states/widgetInsertionContextComponentState.ts b/packages/twenty-front/src/modules/page-layout/states/widgetInsertionContextComponentState.ts new file mode 100644 index 0000000000..8cd444cf2b --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/states/widgetInsertionContextComponentState.ts @@ -0,0 +1,15 @@ +import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState'; + +import { PageLayoutComponentInstanceContext } from './contexts/PageLayoutComponentInstanceContext'; + +export type WidgetInsertionContext = { + targetWidgetId: string; + direction: 'above' | 'below'; +} | null; + +export const widgetInsertionContextComponentState = + createAtomComponentState({ + key: 'widgetInsertionContextComponentState', + defaultValue: null, + componentInstanceContext: PageLayoutComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/page-layout/utils/__tests__/getWidgetConfigurationViewId.test.ts b/packages/twenty-front/src/modules/page-layout/utils/__tests__/getWidgetConfigurationViewId.test.ts new file mode 100644 index 0000000000..b41cbb7702 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/__tests__/getWidgetConfigurationViewId.test.ts @@ -0,0 +1,44 @@ +import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; +import { getWidgetConfigurationViewId } from '@/page-layout/utils/getWidgetConfigurationViewId'; + +describe('getWidgetConfigurationViewId', () => { + it('should return viewId when present and is a string', () => { + const configuration = { + __typename: 'RecordTableConfiguration', + configurationType: 'RECORD_TABLE', + viewId: 'view-123', + } as unknown as PageLayoutWidget['configuration']; + + expect(getWidgetConfigurationViewId(configuration)).toBe('view-123'); + }); + + it('should return null when viewId is not present', () => { + const configuration = { + __typename: 'IframeConfiguration', + configurationType: 'IFRAME', + url: 'https://example.com', + } as unknown as PageLayoutWidget['configuration']; + + expect(getWidgetConfigurationViewId(configuration)).toBeNull(); + }); + + it('should return null when viewId is not a string', () => { + const configuration = { + __typename: 'RecordTableConfiguration', + configurationType: 'RECORD_TABLE', + viewId: 123, + } as unknown as PageLayoutWidget['configuration']; + + expect(getWidgetConfigurationViewId(configuration)).toBeNull(); + }); + + it('should return null when viewId is null', () => { + const configuration = { + __typename: 'RecordTableConfiguration', + configurationType: 'RECORD_TABLE', + viewId: null, + } as unknown as PageLayoutWidget['configuration']; + + expect(getWidgetConfigurationViewId(configuration)).toBeNull(); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/utils/__tests__/isVerticalListPosition.test.ts b/packages/twenty-front/src/modules/page-layout/utils/__tests__/isVerticalListPosition.test.ts new file mode 100644 index 0000000000..978b2f351a --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/__tests__/isVerticalListPosition.test.ts @@ -0,0 +1,35 @@ +import { isVerticalListPosition } from '@/page-layout/utils/isVerticalListPosition'; +import { + type PageLayoutWidgetPosition, + PageLayoutTabLayoutMode, +} from '~/generated-metadata/graphql'; + +describe('isVerticalListPosition', () => { + it('should return true for VERTICAL_LIST layout mode', () => { + const position: PageLayoutWidgetPosition = { + __typename: 'PageLayoutWidgetVerticalListPosition', + layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, + index: 0, + }; + + expect(isVerticalListPosition(position)).toBe(true); + }); + + it('should return false for CANVAS layout mode', () => { + const position: PageLayoutWidgetPosition = { + __typename: 'PageLayoutWidgetCanvasPosition', + layoutMode: PageLayoutTabLayoutMode.CANVAS, + } as PageLayoutWidgetPosition; + + expect(isVerticalListPosition(position)).toBe(false); + }); + + it('should return false for GRID layout mode', () => { + const position: PageLayoutWidgetPosition = { + __typename: 'PageLayoutWidgetGridPosition', + layoutMode: PageLayoutTabLayoutMode.GRID, + } as PageLayoutWidgetPosition; + + expect(isVerticalListPosition(position)).toBe(false); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/utils/__tests__/sortWidgetsByVerticalListPosition.test.ts b/packages/twenty-front/src/modules/page-layout/utils/__tests__/sortWidgetsByVerticalListPosition.test.ts new file mode 100644 index 0000000000..4c3ae0c0f3 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/__tests__/sortWidgetsByVerticalListPosition.test.ts @@ -0,0 +1,81 @@ +import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; +import { sortWidgetsByVerticalListPosition } from '@/page-layout/utils/sortWidgetsByVerticalListPosition'; +import { + PageLayoutTabLayoutMode, + WidgetConfigurationType, + WidgetType, +} from '~/generated-metadata/graphql'; + +const makeWidget = (id: string, index: number): PageLayoutWidget => + ({ + __typename: 'PageLayoutWidget', + id, + pageLayoutTabId: 'tab-1', + title: id, + type: WidgetType.FIELDS, + isOverridden: false, + gridPosition: { row: 0, column: 0, rowSpan: 1, columnSpan: 1 }, + configuration: { + __typename: 'FieldsConfiguration', + configurationType: WidgetConfigurationType.FIELDS, + }, + position: { + __typename: 'PageLayoutWidgetVerticalListPosition', + layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, + index, + }, + objectMetadataId: null, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + deletedAt: null, + }) as unknown as PageLayoutWidget; + +describe('sortWidgetsByVerticalListPosition', () => { + it('should sort widgets by index ascending', () => { + const widgets = [ + makeWidget('c', 2), + makeWidget('a', 0), + makeWidget('b', 1), + ]; + + const sorted = sortWidgetsByVerticalListPosition(widgets); + + expect(sorted.map((w) => w.id)).toEqual(['a', 'b', 'c']); + }); + + it('should not mutate the original array', () => { + const widgets = [makeWidget('b', 1), makeWidget('a', 0)]; + const original = [...widgets]; + + sortWidgetsByVerticalListPosition(widgets); + + expect(widgets.map((w) => w.id)).toEqual(original.map((w) => w.id)); + }); + + it('should handle widgets with undefined positions by treating as index 0', () => { + const widgetWithPosition = makeWidget('b', 1); + const widgetWithoutPosition = { + ...makeWidget('a', 0), + position: undefined, + } as unknown as PageLayoutWidget; + + const sorted = sortWidgetsByVerticalListPosition([ + widgetWithPosition, + widgetWithoutPosition, + ]); + + expect(sorted.map((w) => w.id)).toEqual(['a', 'b']); + }); + + it('should return empty array when given empty array', () => { + expect(sortWidgetsByVerticalListPosition([])).toEqual([]); + }); + + it('should handle single widget', () => { + const widgets = [makeWidget('a', 0)]; + + const sorted = sortWidgetsByVerticalListPosition(widgets); + + expect(sorted.map((w) => w.id)).toEqual(['a']); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/utils/getWidgetConfigurationViewId.ts b/packages/twenty-front/src/modules/page-layout/utils/getWidgetConfigurationViewId.ts new file mode 100644 index 0000000000..b0bf2201de --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/getWidgetConfigurationViewId.ts @@ -0,0 +1,11 @@ +import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; + +export const getWidgetConfigurationViewId = ( + configuration: PageLayoutWidget['configuration'], +): string | null => { + if ('viewId' in configuration && typeof configuration.viewId === 'string') { + return configuration.viewId; + } + + return null; +}; diff --git a/packages/twenty-front/src/modules/page-layout/utils/isVerticalListPosition.ts b/packages/twenty-front/src/modules/page-layout/utils/isVerticalListPosition.ts new file mode 100644 index 0000000000..39568ae260 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/isVerticalListPosition.ts @@ -0,0 +1,10 @@ +import { + type PageLayoutWidgetPosition, + type PageLayoutWidgetVerticalListPosition, + PageLayoutTabLayoutMode, +} from '~/generated-metadata/graphql'; + +export const isVerticalListPosition = ( + position: PageLayoutWidgetPosition, +): position is PageLayoutWidgetVerticalListPosition => + position.layoutMode === PageLayoutTabLayoutMode.VERTICAL_LIST; diff --git a/packages/twenty-front/src/modules/page-layout/utils/sortWidgetsByVerticalListPosition.ts b/packages/twenty-front/src/modules/page-layout/utils/sortWidgetsByVerticalListPosition.ts new file mode 100644 index 0000000000..6af91fe580 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/sortWidgetsByVerticalListPosition.ts @@ -0,0 +1,18 @@ +import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; +import { isVerticalListPosition } from '@/page-layout/utils/isVerticalListPosition'; +import { isDefined } from 'twenty-shared/utils'; + +export const sortWidgetsByVerticalListPosition = ( + widgets: PageLayoutWidget[], +): PageLayoutWidget[] => + [...widgets].sort((widgetA, widgetB) => { + const indexA = + isDefined(widgetA.position) && isVerticalListPosition(widgetA.position) + ? widgetA.position.index + : 0; + const indexB = + isDefined(widgetB.position) && isVerticalListPosition(widgetB.position) + ? widgetB.position.index + : 0; + return indexA - indexB; + }); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/components/DashboardWidgetPlaceholder.tsx b/packages/twenty-front/src/modules/page-layout/widgets/components/DashboardWidgetPlaceholder.tsx index d5c85dabb5..cccf9afd7c 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/components/DashboardWidgetPlaceholder.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/components/DashboardWidgetPlaceholder.tsx @@ -43,7 +43,7 @@ export const DashboardWidgetPlaceholder = () => { setIsPageLayoutInEditMode(true); } navigatePageLayoutSidePanel({ - sidePanelPage: SidePanelPages.PageLayoutWidgetTypeSelect, + sidePanelPage: SidePanelPages.PageLayoutDashboardWidgetTypeSelect, resetNavigationStack: true, }); }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetRenderer.tsx b/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetRenderer.tsx index bea22d7f5b..55a42d3680 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetRenderer.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetRenderer.tsx @@ -1,7 +1,6 @@ import { usePageLayoutContentContext } from '@/page-layout/contexts/PageLayoutContentContext'; import { useCurrentPageLayoutOrThrow } from '@/page-layout/hooks/useCurrentPageLayoutOrThrow'; import { useDeletePageLayoutWidget } from '@/page-layout/hooks/useDeletePageLayoutWidget'; -import { useEditPageLayoutWidget } from '@/page-layout/hooks/useEditPageLayoutWidget'; import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutInEditMode'; import { pageLayoutDraggingWidgetIdComponentState } from '@/page-layout/states/pageLayoutDraggingWidgetIdComponentState'; import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState'; @@ -20,6 +19,7 @@ import { getWidgetCardVariant } from '@/page-layout/widgets/utils/getWidgetCardV import { WidgetCard } from '@/page-layout/widgets/widget-card/components/WidgetCard'; import { WidgetCardContent } from '@/page-layout/widgets/widget-card/components/WidgetCardContent'; import { WidgetCardHeader } from '@/page-layout/widgets/widget-card/components/WidgetCardHeader'; +import { useOpenWidgetSettingsInSidePanel } from '@/side-panel/hooks/useOpenWidgetSettingsInSidePanel'; import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; @@ -54,7 +54,7 @@ type WidgetRendererProps = { export const WidgetRenderer = ({ widget }: WidgetRendererProps) => { const { theme } = useContext(ThemeContext); const { deletePageLayoutWidget } = useDeletePageLayoutWidget(); - const { handleEditWidget } = useEditPageLayoutWidget(); + const { openWidgetSettingsInSidePanel } = useOpenWidgetSettingsInSidePanel(); const isPageLayoutInEditMode = useIsPageLayoutInEditMode(); @@ -121,7 +121,7 @@ export const WidgetRenderer = ({ widget }: WidgetRendererProps) => { layoutMode !== PageLayoutTabLayoutMode.CANVAS && !hideHeaderInViewMode; const handleClick = () => { - handleEditWidget({ + openWidgetSettingsInSidePanel({ widgetId: widget.id, widgetType: widget.type, }); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useCreateViewForFieldsWidget.ts b/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useCreateViewForFieldsWidget.ts new file mode 100644 index 0000000000..13901f46f2 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useCreateViewForFieldsWidget.ts @@ -0,0 +1,42 @@ +import { usePerformViewAPIPersist } from '@/views/hooks/internal/usePerformViewAPIPersist'; +import { useCallback } from 'react'; +import { v4 as uuidv4 } from 'uuid'; +import { ViewType } from '~/generated-metadata/graphql'; + +export const useCreateViewForFieldsWidget = () => { + const { performViewAPICreate } = usePerformViewAPIPersist(); + + const createViewForFieldsWidget = useCallback( + async ({ + objectMetadataId, + viewName, + }: { + objectMetadataId: string; + viewName: string; + }) => { + const viewId = uuidv4(); + + const result = await performViewAPICreate( + { + input: { + id: viewId, + name: viewName, + icon: 'IconList', + objectMetadataId, + type: ViewType.FIELDS_WIDGET, + }, + }, + objectMetadataId, + ); + + if (result.status === 'failed') { + return null; + } + + return viewId; + }, + [performViewAPICreate], + ); + + return { createViewForFieldsWidget }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useDeleteViewForFieldsWidget.ts b/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useDeleteViewForFieldsWidget.ts new file mode 100644 index 0000000000..dc5a3d2b31 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useDeleteViewForFieldsWidget.ts @@ -0,0 +1,15 @@ +import { usePerformViewAPIPersist } from '@/views/hooks/internal/usePerformViewAPIPersist'; +import { useCallback } from 'react'; + +export const useDeleteViewForFieldsWidget = () => { + const { performViewAPIDestroy } = usePerformViewAPIPersist(); + + const deleteViewForFieldsWidget = useCallback( + async (viewId: string) => { + await performViewAPIDestroy({ id: viewId }); + }, + [performViewAPIDestroy], + ); + + return { deleteViewForFieldsWidget }; +}; diff --git a/packages/twenty-front/src/modules/side-panel/components/SidePanelGroupFormContainer.tsx b/packages/twenty-front/src/modules/side-panel/components/SidePanelGroupFormContainer.tsx new file mode 100644 index 0000000000..832ea5e77a --- /dev/null +++ b/packages/twenty-front/src/modules/side-panel/components/SidePanelGroupFormContainer.tsx @@ -0,0 +1,14 @@ +import { styled } from '@linaria/react'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; + +const StyledFormContainer = styled.div` + padding-inline: ${themeCssVariables.spacing[1]}; +`; + +export const SidePanelGroupFormContainer = ({ + children, +}: { + children: React.ReactNode; +}) => { + return {children}; +}; diff --git a/packages/twenty-front/src/modules/side-panel/components/SidePanelPageInfo.tsx b/packages/twenty-front/src/modules/side-panel/components/SidePanelPageInfo.tsx index 674e5a3566..c217ef1ae8 100644 --- a/packages/twenty-front/src/modules/side-panel/components/SidePanelPageInfo.tsx +++ b/packages/twenty-front/src/modules/side-panel/components/SidePanelPageInfo.tsx @@ -16,6 +16,7 @@ import { SidePanelPageInfoLayout } from '@/side-panel/components/SidePanelPageIn import { SidePanelPageLayoutInfo } from '@/side-panel/components/SidePanelPageLayoutInfo'; import { SidePanelRecordInfo } from '@/side-panel/components/SidePanelRecordInfo'; import { SidePanelWorkflowStepInfo } from '@/side-panel/components/SidePanelWorkflowStepInfo'; +import { isPageLayoutSidePanelPage } from '@/side-panel/pages/page-layout/utils/isPageLayoutSidePanelPage'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; import { NavigationMenuItemType, SidePanelPages } from 'twenty-shared/types'; @@ -95,15 +96,7 @@ export const SidePanelPageInfo = ({ pageChip }: SidePanelPageInfoProps) => { } const isPageLayoutPage = pageChip.page?.page - ? [ - SidePanelPages.PageLayoutWidgetTypeSelect, - SidePanelPages.PageLayoutGraphTypeSelect, - SidePanelPages.PageLayoutIframeSettings, - SidePanelPages.PageLayoutTabSettings, - SidePanelPages.PageLayoutFieldsSettings, - SidePanelPages.PageLayoutFieldSettings, - SidePanelPages.PageLayoutRecordTableSettings, - ].includes(pageChip.page?.page) + ? isPageLayoutSidePanelPage(pageChip.page.page) : false; if (isPageLayoutPage) { diff --git a/packages/twenty-front/src/modules/side-panel/components/hooks/usePageLayoutHeaderInfo.ts b/packages/twenty-front/src/modules/side-panel/components/hooks/usePageLayoutHeaderInfo.ts index da1d0e8985..71c65983d9 100644 --- a/packages/twenty-front/src/modules/side-panel/components/hooks/usePageLayoutHeaderInfo.ts +++ b/packages/twenty-front/src/modules/side-panel/components/hooks/usePageLayoutHeaderInfo.ts @@ -76,7 +76,7 @@ export const usePageLayoutHeaderInfo = ({ }; } - case SidePanelPages.PageLayoutIframeSettings: { + case SidePanelPages.DashboardIframeSettings: { if (!isDefined(pageLayoutEditingWidgetId)) { return null; } @@ -106,7 +106,7 @@ export const usePageLayoutHeaderInfo = ({ }; } - case SidePanelPages.PageLayoutGraphTypeSelect: { + case SidePanelPages.DashboardChartSettings: { if (!isDefined(pageLayoutEditingWidgetId)) { return null; } @@ -145,7 +145,7 @@ export const usePageLayoutHeaderInfo = ({ }; } - case SidePanelPages.PageLayoutFieldsSettings: { + case SidePanelPages.RecordPageFieldsSettings: { if (!isDefined(pageLayoutEditingWidgetId)) { return null; } @@ -175,7 +175,7 @@ export const usePageLayoutHeaderInfo = ({ }; } - case SidePanelPages.PageLayoutFieldSettings: { + case SidePanelPages.RecordPageFieldSettings: { if (!isDefined(pageLayoutEditingWidgetId)) { return null; } @@ -205,7 +205,7 @@ export const usePageLayoutHeaderInfo = ({ }; } - case SidePanelPages.PageLayoutRecordTableSettings: { + case SidePanelPages.DashboardRecordTableSettings: { if (!isDefined(pageLayoutEditingWidgetId)) { return null; } @@ -235,7 +235,19 @@ export const usePageLayoutHeaderInfo = ({ }; } - case SidePanelPages.PageLayoutWidgetTypeSelect: { + case SidePanelPages.PageLayoutDashboardWidgetTypeSelect: { + return { + headerIcon: IconPlus, + headerIconColor: iconColor, + headerType: '', + title: t`New widget`, + isReadonly: true, + tab: undefined, + widgetInEditMode: undefined, + }; + } + + case SidePanelPages.PageLayoutRecordPageWidgetTypeSelect: { return { headerIcon: IconPlus, headerIconColor: iconColor, diff --git a/packages/twenty-front/src/modules/side-panel/constants/SidePanelPagesConfig.tsx b/packages/twenty-front/src/modules/side-panel/constants/SidePanelPagesConfig.tsx index 9bbd6b9d83..faf53216ce 100644 --- a/packages/twenty-front/src/modules/side-panel/constants/SidePanelPagesConfig.tsx +++ b/packages/twenty-front/src/modules/side-panel/constants/SidePanelPagesConfig.tsx @@ -7,13 +7,14 @@ import { SidePanelAskAIPage } from '@/side-panel/pages/ask-ai/components/SidePan import { SidePanelCalendarEventPage } from '@/side-panel/pages/calendar-event/components/SidePanelCalendarEventPage'; import { SidePanelComposeEmailPage } from '@/side-panel/pages/compose-email/components/SidePanelComposeEmailPage'; import { SidePanelFrontComponentPage } from '@/side-panel/pages/front-component/components/SidePanelFrontComponentPage'; -import { SidePanelPageLayoutChartSettings } from '@/side-panel/pages/page-layout/components/SidePanelPageLayoutChartSettings'; -import { SidePanelPageLayoutFieldSettings } from '@/side-panel/pages/page-layout/components/SidePanelPageLayoutFieldSettings'; -import { SidePanelPageLayoutFieldsSettings } from '@/side-panel/pages/page-layout/components/SidePanelPageLayoutFieldsSettings'; -import { SidePanelPageLayoutIframeSettings } from '@/side-panel/pages/page-layout/components/SidePanelPageLayoutIframeSettings'; -import { SidePanelPageLayoutRecordTableSettings } from '@/side-panel/pages/page-layout/components/SidePanelPageLayoutRecordTableSettings'; +import { SidePanelDashboardChartSettings } from '@/side-panel/pages/page-layout/components/dashboard/SidePanelDashboardChartSettings'; +import { SidePanelDashboardIframeSettings } from '@/side-panel/pages/page-layout/components/dashboard/SidePanelDashboardIframeSettings'; +import { SidePanelDashboardRecordTableSettings } from '@/side-panel/pages/page-layout/components/dashboard/SidePanelDashboardRecordTableSettings'; +import { SidePanelRecordPageFieldSettings } from '@/side-panel/pages/page-layout/components/record-page/SidePanelRecordPageFieldSettings'; +import { SidePanelRecordPageFieldsSettings } from '@/side-panel/pages/page-layout/components/record-page/SidePanelRecordPageFieldsSettings'; +import { SidePanelPageLayoutDashboardWidgetTypeSelect } from '@/side-panel/pages/page-layout/components/SidePanelPageLayoutDashboardWidgetTypeSelect'; +import { SidePanelPageLayoutRecordPageWidgetTypeSelect } from '@/side-panel/pages/page-layout/components/SidePanelPageLayoutRecordPageWidgetTypeSelect'; import { SidePanelPageLayoutTabSettings } from '@/side-panel/pages/page-layout/components/SidePanelPageLayoutTabSettings'; -import { SidePanelPageLayoutWidgetTypeSelect } from '@/side-panel/pages/page-layout/components/SidePanelPageLayoutWidgetTypeSelect'; import { SidePanelMergeRecordPage } from '@/side-panel/pages/record-page/components/SidePanelMergeRecordPage'; import { SidePanelRecordPage } from '@/side-panel/pages/record-page/components/SidePanelRecordPage'; import { SidePanelUpdateMultipleRecords } from '@/side-panel/pages/record-page/components/SidePanelUpdateMultipleRecords'; @@ -51,31 +52,35 @@ export const SIDE_PANEL_PAGES_CONFIG = new Map( [SidePanelPages.AskAI, ], [SidePanelPages.ViewPreviousAIChats, ], [ - SidePanelPages.PageLayoutWidgetTypeSelect, - , + SidePanelPages.PageLayoutDashboardWidgetTypeSelect, + , ], [ - SidePanelPages.PageLayoutGraphTypeSelect, - , + SidePanelPages.DashboardChartSettings, + , ], [ - SidePanelPages.PageLayoutIframeSettings, - , + SidePanelPages.DashboardIframeSettings, + , ], [SidePanelPages.PageLayoutTabSettings, ], [ - SidePanelPages.PageLayoutFieldsSettings, - , + SidePanelPages.DashboardRecordTableSettings, + , ], [ - SidePanelPages.PageLayoutFieldSettings, - , + SidePanelPages.RecordPageFieldsSettings, + , ], [ - SidePanelPages.PageLayoutRecordTableSettings, - , + SidePanelPages.RecordPageFieldSettings, + , ], [SidePanelPages.ViewFrontComponent, ], + [ + SidePanelPages.PageLayoutRecordPageWidgetTypeSelect, + , + ], [ SidePanelPages.NavigationMenuItemEdit, , diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useEditPageLayoutWidget.ts b/packages/twenty-front/src/modules/side-panel/hooks/useOpenWidgetSettingsInSidePanel.ts similarity index 67% rename from packages/twenty-front/src/modules/page-layout/hooks/useEditPageLayoutWidget.ts rename to packages/twenty-front/src/modules/side-panel/hooks/useOpenWidgetSettingsInSidePanel.ts index 3026beb0d3..08983600aa 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/useEditPageLayoutWidget.ts +++ b/packages/twenty-front/src/modules/side-panel/hooks/useOpenWidgetSettingsInSidePanel.ts @@ -1,24 +1,28 @@ import { useCallback } from 'react'; -import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu'; -import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState'; -import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState'; -import { SidePanelPages } from 'twenty-shared/types'; - import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState'; +import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu'; +import { useIsDashboardPageLayout } from '@/side-panel/pages/page-layout/hooks/useIsDashboardPageLayout'; import { useNavigatePageLayoutSidePanel } from '@/side-panel/pages/page-layout/hooks/useNavigatePageLayoutSidePanel'; +import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; +import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState'; import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; import { t } from '@lingui/core/macro'; +import { SidePanelPages } from 'twenty-shared/types'; import { WidgetType } from '~/generated-metadata/graphql'; -export const useEditPageLayoutWidget = (pageLayoutIdFromProps?: string) => { +export const useOpenWidgetSettingsInSidePanel = ( + pageLayoutIdFromProps?: string, +) => { const pageLayoutId = useAvailableComponentInstanceIdOrThrow( PageLayoutComponentInstanceContext, pageLayoutIdFromProps, ); + const isDashboardPageLayout = useIsDashboardPageLayout(); + const setPageLayoutEditingWidgetId = useSetAtomComponentState( pageLayoutEditingWidgetIdComponentState, pageLayoutId, @@ -28,7 +32,7 @@ export const useEditPageLayoutWidget = (pageLayoutIdFromProps?: string) => { const { closeSidePanelMenu } = useSidePanelMenu(); const setSidePanelPage = useSetAtomState(sidePanelPageState); - const handleEditWidget = useCallback( + const openWidgetSettingsInSidePanel = useCallback( ({ widgetId, widgetType, @@ -37,8 +41,12 @@ export const useEditPageLayoutWidget = (pageLayoutIdFromProps?: string) => { widgetType: WidgetType; }) => { if (widgetType === WidgetType.IFRAME) { + if (!isDashboardPageLayout) { + return; + } + navigatePageLayoutSidePanel({ - sidePanelPage: SidePanelPages.PageLayoutIframeSettings, + sidePanelPage: SidePanelPages.DashboardIframeSettings, pageTitle: t`Edit iFrame`, resetNavigationStack: true, }); @@ -47,8 +55,12 @@ export const useEditPageLayoutWidget = (pageLayoutIdFromProps?: string) => { } if (widgetType === WidgetType.GRAPH) { + if (!isDashboardPageLayout) { + return; + } + navigatePageLayoutSidePanel({ - sidePanelPage: SidePanelPages.PageLayoutGraphTypeSelect, + sidePanelPage: SidePanelPages.DashboardChartSettings, pageTitle: t`Edit Graph`, resetNavigationStack: true, }); @@ -57,28 +69,36 @@ export const useEditPageLayoutWidget = (pageLayoutIdFromProps?: string) => { } if (widgetType === WidgetType.FIELDS) { - navigatePageLayoutSidePanel({ - sidePanelPage: SidePanelPages.PageLayoutFieldsSettings, - pageTitle: t`Edit Fields`, - resetNavigationStack: true, - }); - setPageLayoutEditingWidgetId(widgetId); + if (!isDashboardPageLayout) { + navigatePageLayoutSidePanel({ + sidePanelPage: SidePanelPages.RecordPageFieldsSettings, + pageTitle: t`Edit Fields`, + resetNavigationStack: true, + }); + setPageLayoutEditingWidgetId(widgetId); + } return; } if (widgetType === WidgetType.FIELD) { - navigatePageLayoutSidePanel({ - sidePanelPage: SidePanelPages.PageLayoutFieldSettings, - pageTitle: t`Field widget`, - resetNavigationStack: true, - }); - setPageLayoutEditingWidgetId(widgetId); + if (!isDashboardPageLayout) { + navigatePageLayoutSidePanel({ + sidePanelPage: SidePanelPages.RecordPageFieldSettings, + pageTitle: t`Field widget`, + resetNavigationStack: true, + }); + setPageLayoutEditingWidgetId(widgetId); + } return; } if (widgetType === WidgetType.RECORD_TABLE) { + if (!isDashboardPageLayout) { + return; + } + navigatePageLayoutSidePanel({ - sidePanelPage: SidePanelPages.PageLayoutRecordTableSettings, + sidePanelPage: SidePanelPages.DashboardRecordTableSettings, pageTitle: t`Edit Record Table`, resetNavigationStack: true, }); @@ -96,6 +116,7 @@ export const useEditPageLayoutWidget = (pageLayoutIdFromProps?: string) => { closeSidePanelMenu(); }, [ + isDashboardPageLayout, setPageLayoutEditingWidgetId, navigatePageLayoutSidePanel, closeSidePanelMenu, @@ -104,6 +125,6 @@ export const useEditPageLayoutWidget = (pageLayoutIdFromProps?: string) => { ); return { - handleEditWidget, + openWidgetSettingsInSidePanel, }; }; diff --git a/packages/twenty-front/src/modules/side-panel/hooks/useSidePanelCloseAnimationCompleteCleanup.ts b/packages/twenty-front/src/modules/side-panel/hooks/useSidePanelCloseAnimationCompleteCleanup.ts index d4c3acfa71..e137461030 100644 --- a/packages/twenty-front/src/modules/side-panel/hooks/useSidePanelCloseAnimationCompleteCleanup.ts +++ b/packages/twenty-front/src/modules/side-panel/hooks/useSidePanelCloseAnimationCompleteCleanup.ts @@ -8,10 +8,12 @@ import { recordStoreFamilyState } from '@/object-record/record-store/states/reco import { pageLayoutDraggedAreaComponentState } from '@/page-layout/states/pageLayoutDraggedAreaComponentState'; import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState'; import { pageLayoutTabSettingsOpenTabIdComponentState } from '@/page-layout/states/pageLayoutTabSettingsOpenTabIdComponentState'; +import { widgetInsertionContextComponentState } from '@/page-layout/states/widgetInsertionContextComponentState'; import { SIDE_PANEL_COMPONENT_INSTANCE_ID } from '@/side-panel/constants/SidePanelComponentInstanceId'; import { SIDE_PANEL_CONTEXT_CHIP_GROUPS_DROPDOWN_ID } from '@/side-panel/constants/SidePanelContextChipGroupsDropdownId'; import { SIDE_PANEL_PREVIOUS_COMPONENT_INSTANCE_ID } from '@/side-panel/constants/SidePanelPreviousComponentInstanceId'; import { SIDE_PANEL_SELECTABLE_LIST_ID } from '@/side-panel/constants/SidePanelSelectableListId'; +import { isPageLayoutSidePanelPage } from '@/side-panel/pages/page-layout/utils/isPageLayoutSidePanelPage'; import { hasUserSelectedSidePanelListItemState } from '@/side-panel/states/hasUserSelectedSidePanelListItemState'; import { isSidePanelClosingState } from '@/side-panel/states/isSidePanelClosingState'; import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState'; @@ -69,13 +71,7 @@ export const useSidePanelCloseAnimationCompleteCleanup = () => { resetContextStoreStates(SIDE_PANEL_COMPONENT_INSTANCE_ID); resetContextStoreStates(SIDE_PANEL_PREVIOUS_COMPONENT_INSTANCE_ID); - const isPageLayoutEditingPage = - currentPage === SidePanelPages.PageLayoutWidgetTypeSelect || - currentPage === SidePanelPages.PageLayoutGraphTypeSelect || - currentPage === SidePanelPages.PageLayoutIframeSettings || - currentPage === SidePanelPages.PageLayoutTabSettings; - - if (isPageLayoutEditingPage) { + if (isDefined(currentPage) && isPageLayoutSidePanelPage(currentPage)) { if ( targetedRecordsRule.mode === 'selection' && targetedRecordsRule.selectedRecordIds.length === 1 @@ -102,6 +98,12 @@ export const useSidePanelCloseAnimationCompleteCleanup = () => { }), null, ); + store.set( + widgetInsertionContextComponentState.atomFamily({ + instanceId: record.pageLayoutId, + }), + null, + ); } } } diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/SidePanelPageLayoutWidgetTypeSelect.tsx b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/SidePanelPageLayoutDashboardWidgetTypeSelect.tsx similarity index 97% rename from packages/twenty-front/src/modules/side-panel/pages/page-layout/components/SidePanelPageLayoutWidgetTypeSelect.tsx rename to packages/twenty-front/src/modules/side-panel/pages/page-layout/components/SidePanelPageLayoutDashboardWidgetTypeSelect.tsx index 37fc59bfb0..118a63e1c5 100644 --- a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/SidePanelPageLayoutWidgetTypeSelect.tsx +++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/SidePanelPageLayoutDashboardWidgetTypeSelect.tsx @@ -38,7 +38,7 @@ import { WidgetType, } from '~/generated-metadata/graphql'; -export const SidePanelPageLayoutWidgetTypeSelect = () => { +export const SidePanelPageLayoutDashboardWidgetTypeSelect = () => { const { pageLayoutId, recordId } = usePageLayoutIdFromContextStore(); const { closeSidePanelMenu } = useSidePanelMenu(); @@ -132,7 +132,7 @@ export const SidePanelPageLayoutWidgetTypeSelect = () => { } navigatePageLayoutSidePanel({ - sidePanelPage: SidePanelPages.PageLayoutGraphTypeSelect, + sidePanelPage: SidePanelPages.DashboardChartSettings, focusTitleInput: true, }); }; @@ -153,7 +153,7 @@ export const SidePanelPageLayoutWidgetTypeSelect = () => { } navigatePageLayoutSidePanel({ - sidePanelPage: SidePanelPages.PageLayoutIframeSettings, + sidePanelPage: SidePanelPages.DashboardIframeSettings, focusTitleInput: true, }); }; @@ -196,7 +196,7 @@ export const SidePanelPageLayoutWidgetTypeSelect = () => { } navigatePageLayoutSidePanel({ - sidePanelPage: SidePanelPages.PageLayoutRecordTableSettings, + sidePanelPage: SidePanelPages.DashboardRecordTableSettings, focusTitleInput: false, }); }; diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/SidePanelPageLayoutRecordPageWidgetTypeSelect.tsx b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/SidePanelPageLayoutRecordPageWidgetTypeSelect.tsx new file mode 100644 index 0000000000..8122498e94 --- /dev/null +++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/SidePanelPageLayoutRecordPageWidgetTypeSelect.tsx @@ -0,0 +1,440 @@ +import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem'; +import { FIND_MANY_FRONT_COMPONENTS } from '@/front-components/graphql/queries/findManyFrontComponents'; +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { useFieldListFieldMetadataItems } from '@/object-record/record-field-list/hooks/useFieldListFieldMetadataItems'; +import { useInsertCreatedWidgetAtContext } from '@/page-layout/hooks/useInsertCreatedWidgetAtContext'; +import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; +import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState'; +import { widgetInsertionContextComponentState } from '@/page-layout/states/widgetInsertionContextComponentState'; +import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; +import { addWidgetToTab } from '@/page-layout/utils/addWidgetToTab'; +import { createDefaultFieldWidget } from '@/page-layout/utils/createDefaultFieldWidget'; +import { createDefaultFieldsWidget } from '@/page-layout/utils/createDefaultFieldsWidget'; +import { getTabListInstanceIdFromPageLayoutAndRecord } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutAndRecord'; +import { getWidgetConfigurationViewId } from '@/page-layout/utils/getWidgetConfigurationViewId'; +import { isVerticalListPosition } from '@/page-layout/utils/isVerticalListPosition'; +import { removeWidgetFromTab } from '@/page-layout/utils/removeWidgetFromTab'; +import { useCreateViewForFieldsWidget } from '@/page-layout/widgets/fields/hooks/useCreateViewForFieldsWidget'; +import { useDeleteViewForFieldsWidget } from '@/page-layout/widgets/fields/hooks/useDeleteViewForFieldsWidget'; +import { useDeleteViewForRecordTableWidget } from '@/page-layout/widgets/record-table/hooks/useDeleteViewForRecordTableWidget'; +import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup'; +import { SidePanelList } from '@/side-panel/components/SidePanelList'; +import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu'; +import { useNavigatePageLayoutSidePanel } from '@/side-panel/pages/page-layout/hooks/useNavigatePageLayoutSidePanel'; +import { usePageLayoutIdFromContextStore } from '@/side-panel/pages/page-layout/hooks/usePageLayoutIdFromContextStore'; +import { getFrontComponentWidgetTypeSelectItemId } from '@/side-panel/pages/page-layout/utils/getFrontComponentWidgetTypeSelectItemId'; +import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem'; +import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState'; +import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState'; +import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; +import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; +import { useQuery } from '@apollo/client/react'; +import { t } from '@lingui/core/macro'; +import { useStore } from 'jotai'; +import { useCallback } from 'react'; +import { SidePanelPages } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; +import { IconApps, IconList } from 'twenty-ui/display'; +import { v4 as uuidv4 } from 'uuid'; +import { + type FrontComponent, + PageLayoutTabLayoutMode, + WidgetConfigurationType, + WidgetType, +} from '~/generated-metadata/graphql'; + +export const SidePanelPageLayoutRecordPageWidgetTypeSelect = () => { + const { + pageLayoutId, + recordId, + objectNameSingular: targetObjectNameSingular, + } = usePageLayoutIdFromContextStore(); + + const { closeSidePanelMenu } = useSidePanelMenu(); + + const { navigatePageLayoutSidePanel } = useNavigatePageLayoutSidePanel(); + + const pageLayoutDraft = useAtomComponentStateValue( + pageLayoutDraftComponentState, + pageLayoutId, + ); + + const pageLayoutDraftState = useAtomComponentStateCallbackState( + pageLayoutDraftComponentState, + pageLayoutId, + ); + + const [pageLayoutEditingWidgetId, setPageLayoutEditingWidgetId] = + useAtomComponentState( + pageLayoutEditingWidgetIdComponentState, + pageLayoutId, + ); + + const widgetInsertionContext = useAtomComponentStateValue( + widgetInsertionContextComponentState, + pageLayoutId, + ); + + const tabListInstanceId = getTabListInstanceIdFromPageLayoutAndRecord({ + pageLayoutId, + layoutType: pageLayoutDraft.type, + targetRecordIdentifier: { id: recordId, targetObjectNameSingular: '' }, + }); + + const activeTabId = useAtomComponentStateValue( + activeTabIdComponentState, + tabListInstanceId, + ); + + const store = useStore(); + + const { insertCreatedWidgetAtContext } = + useInsertCreatedWidgetAtContext(pageLayoutId); + + const { createViewForFieldsWidget } = useCreateViewForFieldsWidget(); + + const { deleteViewForFieldsWidget } = useDeleteViewForFieldsWidget(); + + const { deleteViewForRecordTableWidget } = + useDeleteViewForRecordTableWidget(); + + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular: targetObjectNameSingular, + }); + + const { boxedRelationFieldMetadataItems } = useFieldListFieldMetadataItems({ + objectNameSingular: targetObjectNameSingular, + }); + + const editingWidgetTab = isDefined(pageLayoutEditingWidgetId) + ? pageLayoutDraft.tabs.find((tab) => + tab.widgets.some((widget) => widget.id === pageLayoutEditingWidgetId), + ) + : undefined; + + const tabId = editingWidgetTab?.id ?? activeTabId; + + const isReplaceMode = + isDefined(pageLayoutEditingWidgetId) && !isDefined(widgetInsertionContext); + + const existingWidget = isReplaceMode + ? pageLayoutDraft.tabs + .flatMap((tab) => tab.widgets) + .find((widget) => widget.id === pageLayoutEditingWidgetId) + : undefined; + + const getExistingWidgetPositionIndex = useCallback(() => { + if (!isDefined(existingWidget?.position)) { + return undefined; + } + + if (isVerticalListPosition(existingWidget.position)) { + return existingWidget.position.index; + } + + return undefined; + }, [existingWidget]); + + const removeExistingWidgetIfReplacing = useCallback(() => { + if ( + !isReplaceMode || + !isDefined(pageLayoutEditingWidgetId) || + !isDefined(tabId) + ) { + return; + } + + if (isDefined(existingWidget)) { + const viewId = getWidgetConfigurationViewId(existingWidget.configuration); + + if (isDefined(viewId)) { + if (existingWidget.type === WidgetType.RECORD_TABLE) { + deleteViewForRecordTableWidget(viewId); + } + + if (existingWidget.type === WidgetType.FIELDS) { + deleteViewForFieldsWidget(viewId); + } + } + } + + store.set(pageLayoutDraftState, (prev) => ({ + ...prev, + tabs: removeWidgetFromTab(prev.tabs, tabId, pageLayoutEditingWidgetId), + })); + }, [ + deleteViewForFieldsWidget, + deleteViewForRecordTableWidget, + existingWidget, + isReplaceMode, + pageLayoutDraftState, + pageLayoutEditingWidgetId, + store, + tabId, + ]); + + const { data: frontComponentsData } = useQuery<{ + frontComponents: FrontComponent[]; + }>(FIND_MANY_FRONT_COMPONENTS); + + const frontComponents = frontComponentsData?.frontComponents ?? []; + + const frontComponentsWithSelectItemId = frontComponents.map( + (frontComponent) => ({ + frontComponent, + selectItemId: getFrontComponentWidgetTypeSelectItemId(frontComponent.id), + }), + ); + + const handleCreateFieldsWidget = useCallback(async () => { + if (!isDefined(tabId)) { + return; + } + + const replacePositionIndex = getExistingWidgetPositionIndex(); + + const viewId = await createViewForFieldsWidget({ + objectMetadataId: objectMetadataItem.id, + viewName: `${objectMetadataItem.labelSingular} Fields`, + }); + + if (viewId === null) { + return; + } + + removeExistingWidgetIfReplacing(); + + const updatedPageLayout = store.get(pageLayoutDraftState); + const activeTab = updatedPageLayout.tabs.find((tab) => tab.id === tabId); + const positionIndex = + replacePositionIndex ?? activeTab?.widgets.length ?? 0; + const widgetId = uuidv4(); + + const newWidget = createDefaultFieldsWidget({ + id: widgetId, + pageLayoutTabId: tabId, + viewId, + objectMetadataId: objectMetadataItem.id, + positionIndex, + }); + + store.set(pageLayoutDraftState, (prev) => ({ + ...prev, + tabs: addWidgetToTab(prev.tabs, tabId, newWidget), + })); + + setPageLayoutEditingWidgetId(widgetId); + insertCreatedWidgetAtContext(widgetId); + + navigatePageLayoutSidePanel({ + sidePanelPage: SidePanelPages.RecordPageFieldsSettings, + focusTitleInput: true, + resetNavigationStack: true, + }); + }, [ + createViewForFieldsWidget, + getExistingWidgetPositionIndex, + insertCreatedWidgetAtContext, + navigatePageLayoutSidePanel, + objectMetadataItem.id, + objectMetadataItem.labelSingular, + pageLayoutDraftState, + removeExistingWidgetIfReplacing, + setPageLayoutEditingWidgetId, + store, + tabId, + ]); + + const handleCreateFieldWidget = useCallback(() => { + if (!isDefined(tabId)) { + return; + } + + const replacePositionIndex = getExistingWidgetPositionIndex(); + removeExistingWidgetIfReplacing(); + + const updatedPageLayout = store.get(pageLayoutDraftState); + const activeTab = updatedPageLayout.tabs.find((tab) => tab.id === tabId); + const existingWidgets = activeTab?.widgets ?? []; + const positionIndex = replacePositionIndex ?? existingWidgets.length; + const widgetId = uuidv4(); + + const usedFieldMetadataIds = new Set( + existingWidgets + .filter( + (widget) => + widget.configuration.configurationType === + WidgetConfigurationType.FIELD, + ) + .map((widget) => { + const configuration = widget.configuration as { + fieldMetadataId: string; + }; + return configuration.fieldMetadataId; + }), + ); + + const unusedRelationField = boxedRelationFieldMetadataItems.find( + (field) => !usedFieldMetadataIds.has(field.id), + ); + + const selectedField = + unusedRelationField ?? boxedRelationFieldMetadataItems[0]; + + const fieldMetadataId = selectedField?.id ?? ''; + const title = selectedField?.label ?? ''; + + const newWidget = createDefaultFieldWidget({ + id: widgetId, + pageLayoutTabId: tabId, + title, + fieldMetadataId, + objectMetadataId: objectMetadataItem.id, + positionIndex, + }); + + store.set(pageLayoutDraftState, (prev) => ({ + ...prev, + tabs: addWidgetToTab(prev.tabs, tabId, newWidget), + })); + + setPageLayoutEditingWidgetId(widgetId); + insertCreatedWidgetAtContext(widgetId); + + navigatePageLayoutSidePanel({ + sidePanelPage: SidePanelPages.RecordPageFieldSettings, + focusTitleInput: true, + resetNavigationStack: true, + }); + }, [ + boxedRelationFieldMetadataItems, + getExistingWidgetPositionIndex, + insertCreatedWidgetAtContext, + navigatePageLayoutSidePanel, + objectMetadataItem.id, + pageLayoutDraftState, + removeExistingWidgetIfReplacing, + setPageLayoutEditingWidgetId, + store, + tabId, + ]); + + const handleCreateFrontComponentWidget = useCallback( + (frontComponent: FrontComponent) => { + if (!isDefined(tabId)) { + return; + } + + const replacePositionIndex = getExistingWidgetPositionIndex(); + removeExistingWidgetIfReplacing(); + + const updatedPageLayout = store.get(pageLayoutDraftState); + const activeTab = updatedPageLayout.tabs.find((tab) => tab.id === tabId); + const positionIndex = + replacePositionIndex ?? activeTab?.widgets.length ?? 0; + const widgetId = uuidv4(); + + const newWidget: PageLayoutWidget = { + __typename: 'PageLayoutWidget', + id: widgetId, + pageLayoutTabId: tabId, + title: frontComponent.name, + type: WidgetType.FRONT_COMPONENT, + configuration: { + __typename: 'FrontComponentConfiguration', + configurationType: WidgetConfigurationType.FRONT_COMPONENT, + frontComponentId: frontComponent.id, + }, + gridPosition: { + __typename: 'GridPosition', + row: 0, + column: 0, + rowSpan: 1, + columnSpan: 12, + }, + position: { + __typename: 'PageLayoutWidgetVerticalListPosition', + layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, + index: positionIndex, + }, + objectMetadataId: null, + isOverridden: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + deletedAt: null, + }; + + store.set(pageLayoutDraftState, (prev) => ({ + ...prev, + tabs: addWidgetToTab(prev.tabs, tabId, newWidget), + })); + + setPageLayoutEditingWidgetId(widgetId); + insertCreatedWidgetAtContext(widgetId); + + closeSidePanelMenu(); + }, + [ + closeSidePanelMenu, + getExistingWidgetPositionIndex, + insertCreatedWidgetAtContext, + pageLayoutDraftState, + removeExistingWidgetIfReplacing, + setPageLayoutEditingWidgetId, + store, + tabId, + ], + ); + + const selectableItemIds = [ + 'fields', + 'field', + ...frontComponentsWithSelectItemId.map(({ selectItemId }) => selectItemId), + ]; + + return ( + + + + + + + + + + + {frontComponentsWithSelectItemId.length > 0 && ( + + {frontComponentsWithSelectItemId.map( + ({ frontComponent, selectItemId }) => ( + handleCreateFrontComponentWidget(frontComponent)} + > + + handleCreateFrontComponentWidget(frontComponent) + } + /> + + ), + )} + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/WidgetSettingsFooter.tsx b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/WidgetSettingsFooter.tsx index 2d07d73845..54121376ed 100644 --- a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/WidgetSettingsFooter.tsx +++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/WidgetSettingsFooter.tsx @@ -3,9 +3,9 @@ import { useDuplicatePageLayoutWidget } from '@/page-layout/hooks/useDuplicatePa import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState'; import { OptionsDropdownMenu } from '@/ui/layout/dropdown/components/OptionsDropdownMenu'; import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; -import { SidePanelFooter } from '@/ui/layout/side-panel/components/SidePanelFooter'; import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem'; import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState'; +import { SidePanelFooter } from '@/ui/layout/side-panel/components/SidePanelFooter'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { useLingui } from '@lingui/react/macro'; import { useId } from 'react'; diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/WidgetSettingsManageSection.tsx b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/WidgetSettingsManageSection.tsx new file mode 100644 index 0000000000..07af7441e7 --- /dev/null +++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/WidgetSettingsManageSection.tsx @@ -0,0 +1,116 @@ +import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem'; +import { useDeletePageLayoutWidget } from '@/page-layout/hooks/useDeletePageLayoutWidget'; +import { useResetPageLayoutWidgetToDefault } from '@/page-layout/hooks/useResetPageLayoutWidgetToDefault'; +import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState'; +import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup'; +import { WIDGET_SETTINGS_SELECTABLE_ITEM_IDS } from '@/side-panel/pages/page-layout/constants/settings/WidgetSettingsSelectableItemIds'; +import { useNavigatePageLayoutSidePanel } from '@/side-panel/pages/page-layout/hooks/useNavigatePageLayoutSidePanel'; +import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal'; +import { useModal } from '@/ui/layout/modal/hooks/useModal'; +import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem'; +import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; +import { useLingui } from '@lingui/react/macro'; +import { SidePanelPages } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; +import { + IconRefreshDot, + IconSwitchHorizontal, + IconTrash, +} from 'twenty-ui/display'; + +const RESET_WIDGET_TO_DEFAULT_MODAL_ID = 'reset-widget-to-default-modal'; + +type WidgetSettingsManageSectionProps = { + pageLayoutId: string; +}; + +export const WidgetSettingsManageSection = ({ + pageLayoutId, +}: WidgetSettingsManageSectionProps) => { + const { t } = useLingui(); + + const pageLayoutEditingWidgetId = useAtomComponentStateValue( + pageLayoutEditingWidgetIdComponentState, + pageLayoutId, + ); + + const { deletePageLayoutWidget } = useDeletePageLayoutWidget(pageLayoutId); + + const { resetPageLayoutWidgetToDefault } = + useResetPageLayoutWidgetToDefault(pageLayoutId); + + const { navigatePageLayoutSidePanel } = useNavigatePageLayoutSidePanel(); + + const { openModal } = useModal(); + + if (!isDefined(pageLayoutEditingWidgetId)) { + return null; + } + + const handleResetToDefault = () => { + openModal(RESET_WIDGET_TO_DEFAULT_MODAL_ID); + }; + + const handleConfirmReset = () => { + resetPageLayoutWidgetToDefault(pageLayoutEditingWidgetId); + }; + + const handleReplaceWidget = () => { + navigatePageLayoutSidePanel({ + sidePanelPage: SidePanelPages.PageLayoutRecordPageWidgetTypeSelect, + }); + }; + + const handleDeleteWidget = () => { + deletePageLayoutWidget(pageLayoutEditingWidgetId); + }; + + return ( + <> + + + + + + + + + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/WidgetSettingsPlacementSection.tsx b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/WidgetSettingsPlacementSection.tsx new file mode 100644 index 0000000000..2a26307683 --- /dev/null +++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/WidgetSettingsPlacementSection.tsx @@ -0,0 +1,183 @@ +import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem'; +import { CommandMenuItemDropdown } from '@/command-menu/components/CommandMenuItemDropdown'; +import { useCanMovePageLayoutWidgetDown } from '@/page-layout/hooks/useCanMovePageLayoutWidgetDown'; +import { useCanMovePageLayoutWidgetUp } from '@/page-layout/hooks/useCanMovePageLayoutWidgetUp'; +import { useMovePageLayoutWidgetDown } from '@/page-layout/hooks/useMovePageLayoutWidgetDown'; +import { useMovePageLayoutWidgetUp } from '@/page-layout/hooks/useMovePageLayoutWidgetUp'; +import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; +import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState'; +import { widgetInsertionContextComponentState } from '@/page-layout/states/widgetInsertionContextComponentState'; +import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup'; +import { MoveToTabDropdownContent } from '@/side-panel/pages/page-layout/components/dropdown-content/MoveToTabDropdownContent'; +import { WIDGET_SETTINGS_SELECTABLE_ITEM_IDS } from '@/side-panel/pages/page-layout/constants/settings/WidgetSettingsSelectableItemIds'; +import { useNavigatePageLayoutSidePanel } from '@/side-panel/pages/page-layout/hooks/useNavigatePageLayoutSidePanel'; +import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; +import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem'; +import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; +import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; +import { useLingui } from '@lingui/react/macro'; +import { useStore } from 'jotai'; +import { SidePanelPages } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; +import { + IconArrowsVertical, + IconChevronDown, + IconChevronUp, + IconRowInsertBottom, + IconRowInsertTop, +} from 'twenty-ui/display'; +import { PageLayoutTabLayoutMode } from '~/generated-metadata/graphql'; + +type WidgetSettingsPlacementSectionProps = { + pageLayoutId: string; +}; + +export const WidgetSettingsPlacementSection = ({ + pageLayoutId, +}: WidgetSettingsPlacementSectionProps) => { + const { t } = useLingui(); + + const pageLayoutEditingWidgetId = useAtomComponentStateValue( + pageLayoutEditingWidgetIdComponentState, + pageLayoutId, + ); + + const pageLayoutDraft = useAtomComponentStateValue( + pageLayoutDraftComponentState, + pageLayoutId, + ); + + const widgetInsertionContextState = useAtomComponentStateCallbackState( + widgetInsertionContextComponentState, + pageLayoutId, + ); + + const store = useStore(); + + const { movePageLayoutWidgetUp } = useMovePageLayoutWidgetUp(pageLayoutId); + const { movePageLayoutWidgetDown } = + useMovePageLayoutWidgetDown(pageLayoutId); + const { canMovePageLayoutWidgetUp } = + useCanMovePageLayoutWidgetUp(pageLayoutId); + const { canMovePageLayoutWidgetDown } = + useCanMovePageLayoutWidgetDown(pageLayoutId); + + const { navigatePageLayoutSidePanel } = useNavigatePageLayoutSidePanel(); + + if (!isDefined(pageLayoutEditingWidgetId)) { + return null; + } + + const currentTab = pageLayoutDraft.tabs.find((tab) => + tab.widgets.some((widget) => widget.id === pageLayoutEditingWidgetId), + ); + + if ( + !isDefined(currentTab) || + currentTab.layoutMode !== PageLayoutTabLayoutMode.VERTICAL_LIST + ) { + return null; + } + + const showMoveUp = canMovePageLayoutWidgetUp(pageLayoutEditingWidgetId); + const showMoveDown = canMovePageLayoutWidgetDown(pageLayoutEditingWidgetId); + + const handleMoveUp = () => { + movePageLayoutWidgetUp(pageLayoutEditingWidgetId); + }; + + const handleMoveDown = () => { + movePageLayoutWidgetDown(pageLayoutEditingWidgetId); + }; + + const handleAddWidgetAbove = () => { + store.set(widgetInsertionContextState, { + targetWidgetId: pageLayoutEditingWidgetId, + direction: 'above', + }); + + navigatePageLayoutSidePanel({ + sidePanelPage: SidePanelPages.PageLayoutRecordPageWidgetTypeSelect, + }); + }; + + const handleAddWidgetBelow = () => { + store.set(widgetInsertionContextState, { + targetWidgetId: pageLayoutEditingWidgetId, + direction: 'below', + }); + + navigatePageLayoutSidePanel({ + sidePanelPage: SidePanelPages.PageLayoutRecordPageWidgetTypeSelect, + }); + }; + + return ( + + {showMoveUp && ( + + + + )} + {showMoveDown && ( + + + + )} + + + + + } + dropdownPlacement="bottom-end" + /> + + + + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/SidePanelPageLayoutChartSettings.tsx b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dashboard/SidePanelDashboardChartSettings.tsx similarity index 97% rename from packages/twenty-front/src/modules/side-panel/pages/page-layout/components/SidePanelPageLayoutChartSettings.tsx rename to packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dashboard/SidePanelDashboardChartSettings.tsx index e857dcc6a8..64450a2ee4 100644 --- a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/SidePanelPageLayoutChartSettings.tsx +++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dashboard/SidePanelDashboardChartSettings.tsx @@ -15,7 +15,7 @@ const StyledContainer = styled.div` height: 100%; `; -export const SidePanelPageLayoutChartSettings = () => { +export const SidePanelDashboardChartSettings = () => { const { pageLayoutId } = usePageLayoutIdFromContextStore(); const pageLayoutDraft = useAtomComponentStateValue( diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/SidePanelPageLayoutIframeSettings.tsx b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dashboard/SidePanelDashboardIframeSettings.tsx similarity index 71% rename from packages/twenty-front/src/modules/side-panel/pages/page-layout/components/SidePanelPageLayoutIframeSettings.tsx rename to packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dashboard/SidePanelDashboardIframeSettings.tsx index 27f02a552d..f06ad74d98 100644 --- a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/SidePanelPageLayoutIframeSettings.tsx +++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dashboard/SidePanelDashboardIframeSettings.tsx @@ -1,5 +1,8 @@ import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput'; import { useUpdatePageLayoutWidget } from '@/page-layout/hooks/useUpdatePageLayoutWidget'; +import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup'; +import { SidePanelGroupFormContainer } from '@/side-panel/components/SidePanelGroupFormContainer'; +import { SidePanelList } from '@/side-panel/components/SidePanelList'; import { WidgetSettingsFooter } from '@/side-panel/pages/page-layout/components/WidgetSettingsFooter'; import { usePageLayoutIdFromContextStore } from '@/side-panel/pages/page-layout/hooks/usePageLayoutIdFromContextStore'; import { useWidgetInEditMode } from '@/side-panel/pages/page-layout/hooks/useWidgetInEditMode'; @@ -8,24 +11,22 @@ import { t } from '@lingui/core/macro'; import { isNonEmptyString, isString } from '@sniptt/guards'; import { useState } from 'react'; import { isDefined, isValidUrl } from 'twenty-shared/utils'; -import { themeCssVariables } from 'twenty-ui/theme-constants'; import { WidgetConfigurationType } from '~/generated-metadata/graphql'; -const StyledOuterContainer = styled.div` +const StyledContainer = styled.div` display: flex; flex-direction: column; height: 100%; `; -const StyledContainer = styled.div` +const StyledSidePanelContainer = styled.div` display: flex; flex: 1; flex-direction: column; - gap: ${themeCssVariables.spacing[3]}; - padding: ${themeCssVariables.spacing[2]}; + overflow: hidden; `; -export const SidePanelPageLayoutIframeSettings = () => { +export const SidePanelDashboardIframeSettings = () => { const { pageLayoutId } = usePageLayoutIdFromContextStore(); const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId); @@ -84,17 +85,22 @@ export const SidePanelPageLayoutIframeSettings = () => { }; return ( - - - - + + + + + + + + + + - + ); }; diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/SidePanelPageLayoutRecordTableSettings.tsx b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dashboard/SidePanelDashboardRecordTableSettings.tsx similarity index 98% rename from packages/twenty-front/src/modules/side-panel/pages/page-layout/components/SidePanelPageLayoutRecordTableSettings.tsx rename to packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dashboard/SidePanelDashboardRecordTableSettings.tsx index 07e684d27a..8ad8c80dc5 100644 --- a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/SidePanelPageLayoutRecordTableSettings.tsx +++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dashboard/SidePanelDashboardRecordTableSettings.tsx @@ -36,7 +36,7 @@ const StyledSettingsContainer = styled.div` overflow: hidden; `; -export const SidePanelPageLayoutRecordTableSettings = () => { +export const SidePanelDashboardRecordTableSettings = () => { const { pageLayoutId } = usePageLayoutIdFromContextStore(); const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId); const { navigateToSidePanelSubPage } = useSidePanelSubPageHistory(); diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/MoveToTabDropdownContent.tsx b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/MoveToTabDropdownContent.tsx new file mode 100644 index 0000000000..4eed430479 --- /dev/null +++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/MoveToTabDropdownContent.tsx @@ -0,0 +1,68 @@ +import { useMoveWidgetToTab } from '@/page-layout/hooks/useMoveWidgetToTab'; +import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; +import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState'; +import { usePageLayoutIdFromContextStore } from '@/side-panel/pages/page-layout/hooks/usePageLayoutIdFromContextStore'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; +import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; +import { t } from '@lingui/core/macro'; +import { isDefined } from 'twenty-shared/utils'; +import { MenuItem } from 'twenty-ui/navigation'; +import { PageLayoutTabLayoutMode } from '~/generated-metadata/graphql'; + +export const MoveToTabDropdownContent = () => { + const { pageLayoutId } = usePageLayoutIdFromContextStore(); + + const pageLayoutDraft = useAtomComponentStateValue( + pageLayoutDraftComponentState, + pageLayoutId, + ); + + const pageLayoutEditingWidgetId = useAtomComponentStateValue( + pageLayoutEditingWidgetIdComponentState, + pageLayoutId, + ); + + const { moveWidgetToTab } = useMoveWidgetToTab(pageLayoutId); + + const { closeDropdown } = useCloseDropdown(); + + const currentTab = isDefined(pageLayoutEditingWidgetId) + ? pageLayoutDraft.tabs.find((tab) => + tab.widgets.some((widget) => widget.id === pageLayoutEditingWidgetId), + ) + : undefined; + + const eligibleTabs = pageLayoutDraft.tabs.filter( + (tab) => + tab.layoutMode === PageLayoutTabLayoutMode.VERTICAL_LIST && + tab.id !== currentTab?.id, + ); + + if (!isDefined(pageLayoutEditingWidgetId)) { + return null; + } + + if (eligibleTabs.length === 0) { + return ( + + + + ); + } + + return ( + + {eligibleTabs.map((tab) => ( + { + moveWidgetToTab(pageLayoutEditingWidgetId, tab.id); + closeDropdown(); + }} + /> + ))} + + ); +}; diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/SidePanelPageLayoutFieldSettings.tsx b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/record-page/SidePanelRecordPageFieldSettings.tsx similarity index 78% rename from packages/twenty-front/src/modules/side-panel/pages/page-layout/components/SidePanelPageLayoutFieldSettings.tsx rename to packages/twenty-front/src/modules/side-panel/pages/page-layout/components/record-page/SidePanelRecordPageFieldSettings.tsx index 399166ea06..627b7c0d77 100644 --- a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/SidePanelPageLayoutFieldSettings.tsx +++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/record-page/SidePanelRecordPageFieldSettings.tsx @@ -4,9 +4,12 @@ import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup'; import { SidePanelList } from '@/side-panel/components/SidePanelList'; import { FieldWidgetFieldDropdownContent } from '@/side-panel/pages/page-layout/components/dropdown-content/FieldWidgetFieldDropdownContent'; import { FieldWidgetLayoutDropdownContent } from '@/side-panel/pages/page-layout/components/dropdown-content/FieldWidgetLayoutDropdownContent'; -import { WidgetSettingsFooter } from '@/side-panel/pages/page-layout/components/WidgetSettingsFooter'; +import { WidgetSettingsManageSection } from '@/side-panel/pages/page-layout/components/WidgetSettingsManageSection'; +import { WidgetSettingsPlacementSection } from '@/side-panel/pages/page-layout/components/WidgetSettingsPlacementSection'; +import { WIDGET_SETTINGS_SELECTABLE_ITEM_IDS } from '@/side-panel/pages/page-layout/constants/settings/WidgetSettingsSelectableItemIds'; import { usePageLayoutIdFromContextStore } from '@/side-panel/pages/page-layout/hooks/usePageLayoutIdFromContextStore'; import { useWidgetInEditMode } from '@/side-panel/pages/page-layout/hooks/useWidgetInEditMode'; +import { useWidgetSettingsPlacementSelectableItemIds } from '@/side-panel/pages/page-layout/hooks/useWidgetSettingsPlacementSelectableItemIds'; import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem'; import { styled } from '@linaria/react'; @@ -31,10 +34,13 @@ const StyledSidePanelContainer = styled.div` overflow: hidden; `; -export const SidePanelPageLayoutFieldSettings = () => { +export const SidePanelRecordPageFieldSettings = () => { const { t } = useLingui(); const { pageLayoutId } = usePageLayoutIdFromContextStore(); + const { placementSelectableItemIds } = + useWidgetSettingsPlacementSelectableItemIds(pageLayoutId); + const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId); const fieldConfiguration = widgetInEditMode?.configuration as @@ -62,7 +68,14 @@ export const SidePanelPageLayoutFieldSettings = () => { ? (displayModeLabels[currentDisplayMode] ?? '') : ''; - const selectableItemIds = ['field', 'layout']; + const selectableItemIds = [ + 'field', + 'layout', + WIDGET_SETTINGS_SELECTABLE_ITEM_IDS.RESET_TO_DEFAULT, + WIDGET_SETTINGS_SELECTABLE_ITEM_IDS.REPLACE_WIDGET, + WIDGET_SETTINGS_SELECTABLE_ITEM_IDS.DELETE_WIDGET, + ...placementSelectableItemIds, + ]; return ( @@ -102,9 +115,10 @@ export const SidePanelPageLayoutFieldSettings = () => { /> + + - ); }; diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/SidePanelPageLayoutFieldsSettings.tsx b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/record-page/SidePanelRecordPageFieldsSettings.tsx similarity index 64% rename from packages/twenty-front/src/modules/side-panel/pages/page-layout/components/SidePanelPageLayoutFieldsSettings.tsx rename to packages/twenty-front/src/modules/side-panel/pages/page-layout/components/record-page/SidePanelRecordPageFieldsSettings.tsx index 91aae7023c..4314d5a940 100644 --- a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/SidePanelPageLayoutFieldsSettings.tsx +++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/record-page/SidePanelRecordPageFieldsSettings.tsx @@ -1,33 +1,25 @@ import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem'; import { CommandMenuItemToggle } from '@/command-menu/components/CommandMenuItemToggle'; -import { useDeletePageLayoutWidget } from '@/page-layout/hooks/useDeletePageLayoutWidget'; -import { useResetPageLayoutWidgetToDefault } from '@/page-layout/hooks/useResetPageLayoutWidgetToDefault'; import { useFieldsWidgetGroups } from '@/page-layout/widgets/fields/hooks/useFieldsWidgetGroups'; import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup'; import { SidePanelList } from '@/side-panel/components/SidePanelList'; import { useSidePanelSubPageHistory } from '@/side-panel/hooks/useSidePanelSubPageHistory'; import { NewFieldDefaultVisibilityToggle } from '@/side-panel/pages/page-layout/components/NewFieldDefaultVisibilityToggle'; -import { WidgetSettingsFooter } from '@/side-panel/pages/page-layout/components/WidgetSettingsFooter'; +import { WidgetSettingsManageSection } from '@/side-panel/pages/page-layout/components/WidgetSettingsManageSection'; +import { WidgetSettingsPlacementSection } from '@/side-panel/pages/page-layout/components/WidgetSettingsPlacementSection'; +import { WIDGET_SETTINGS_SELECTABLE_ITEM_IDS } from '@/side-panel/pages/page-layout/constants/settings/WidgetSettingsSelectableItemIds'; import { usePageLayoutIdFromContextStore } from '@/side-panel/pages/page-layout/hooks/usePageLayoutIdFromContextStore'; import { useUpdateCurrentWidgetConfig } from '@/side-panel/pages/page-layout/hooks/useUpdateCurrentWidgetConfig'; import { useWidgetInEditMode } from '@/side-panel/pages/page-layout/hooks/useWidgetInEditMode'; +import { useWidgetSettingsPlacementSelectableItemIds } from '@/side-panel/pages/page-layout/hooks/useWidgetSettingsPlacementSelectableItemIds'; import { SidePanelSubPages } from '@/side-panel/types/SidePanelSubPages'; -import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal'; -import { useModal } from '@/ui/layout/modal/hooks/useModal'; import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem'; import { styled } from '@linaria/react'; import { useLingui } from '@lingui/react/macro'; import { isDefined } from 'twenty-shared/utils'; -import { - IconChevronDown, - IconLayoutSidebarRight, - IconRefreshDot, - IconTrash, -} from 'twenty-ui/display'; +import { IconChevronDown, IconLayoutSidebarRight } from 'twenty-ui/display'; import { type FieldsConfiguration } from '~/generated-metadata/graphql'; -const RESET_WIDGET_TO_DEFAULT_MODAL_ID = 'reset-widget-to-default-modal'; - const StyledContainer = styled.div` display: flex; flex-direction: column; @@ -41,22 +33,18 @@ const StyledSidePanelContainer = styled.div` overflow: hidden; `; -export const SidePanelPageLayoutFieldsSettings = () => { +export const SidePanelRecordPageFieldsSettings = () => { const { t } = useLingui(); const { navigateToSidePanelSubPage } = useSidePanelSubPageHistory(); const { pageLayoutId, objectNameSingular } = usePageLayoutIdFromContextStore(); + const { placementSelectableItemIds } = + useWidgetSettingsPlacementSelectableItemIds(pageLayoutId); + const { updateCurrentWidgetConfig } = useUpdateCurrentWidgetConfig(pageLayoutId); - const { deletePageLayoutWidget } = useDeletePageLayoutWidget(pageLayoutId); - - const { resetPageLayoutWidgetToDefault } = - useResetPageLayoutWidgetToDefault(pageLayoutId); - - const { openModal } = useModal(); - const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId); const fieldsConfiguration = widgetInEditMode?.configuration as @@ -93,30 +81,15 @@ export const SidePanelPageLayoutFieldsSettings = () => { }); }; - const handleResetToDefault = () => { - openModal(RESET_WIDGET_TO_DEFAULT_MODAL_ID); - }; - - const handleConfirmReset = () => { - resetPageLayoutWidgetToDefault(widgetInEditMode.id); - }; - - const handleDelete = () => { - deletePageLayoutWidget(widgetInEditMode.id); - }; - const selectableItemIds = [ 'layout', 'new-field-default-visibility', 'display-more-fields-button', 'action-button', - 'move-down', - 'move-up', - 'move-to-tab', - 'add-widget-above', - 'add-widget-below', - 'reset-to-default', - 'delete', + WIDGET_SETTINGS_SELECTABLE_ITEM_IDS.RESET_TO_DEFAULT, + WIDGET_SETTINGS_SELECTABLE_ITEM_IDS.REPLACE_WIDGET, + WIDGET_SETTINGS_SELECTABLE_ITEM_IDS.DELETE_WIDGET, + ...placementSelectableItemIds, ]; return ( @@ -155,38 +128,10 @@ export const SidePanelPageLayoutFieldsSettings = () => { widgetId={widgetInEditMode.id} /> - - - - - - - - + + - - ); }; diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/constants/settings/WidgetSettingsSelectableItemIds.ts b/packages/twenty-front/src/modules/side-panel/pages/page-layout/constants/settings/WidgetSettingsSelectableItemIds.ts new file mode 100644 index 0000000000..32cba0dc32 --- /dev/null +++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/constants/settings/WidgetSettingsSelectableItemIds.ts @@ -0,0 +1,10 @@ +export const WIDGET_SETTINGS_SELECTABLE_ITEM_IDS = { + MOVE_DOWN: 'widget-move-down', + MOVE_UP: 'widget-move-up', + MOVE_TO_TAB: 'widget-move-to-tab', + ADD_WIDGET_ABOVE: 'widget-add-above', + ADD_WIDGET_BELOW: 'widget-add-below', + RESET_TO_DEFAULT: 'widget-reset-to-default', + REPLACE_WIDGET: 'widget-replace', + DELETE_WIDGET: 'widget-delete', +} as const; diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/hooks/useIsDashboardPageLayout.ts b/packages/twenty-front/src/modules/side-panel/pages/page-layout/hooks/useIsDashboardPageLayout.ts new file mode 100644 index 0000000000..2fbb560ace --- /dev/null +++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/hooks/useIsDashboardPageLayout.ts @@ -0,0 +1,24 @@ +import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState'; +import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector'; +import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { CoreObjectNameSingular } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +export const useIsDashboardPageLayout = (): boolean => { + const contextStoreCurrentObjectMetadataItemId = useAtomComponentStateValue( + contextStoreCurrentObjectMetadataItemIdComponentState, + ); + + const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector); + + if (!isDefined(contextStoreCurrentObjectMetadataItemId)) { + return false; + } + + const objectMetadataItem = objectMetadataItems.find( + (item) => item.id === contextStoreCurrentObjectMetadataItemId, + ); + + return objectMetadataItem?.nameSingular === CoreObjectNameSingular.Dashboard; +}; diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/hooks/useWidgetSettingsPlacementSelectableItemIds.ts b/packages/twenty-front/src/modules/side-panel/pages/page-layout/hooks/useWidgetSettingsPlacementSelectableItemIds.ts new file mode 100644 index 0000000000..9753fdeb49 --- /dev/null +++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/hooks/useWidgetSettingsPlacementSelectableItemIds.ts @@ -0,0 +1,38 @@ +import { useCanMovePageLayoutWidgetDown } from '@/page-layout/hooks/useCanMovePageLayoutWidgetDown'; +import { useCanMovePageLayoutWidgetUp } from '@/page-layout/hooks/useCanMovePageLayoutWidgetUp'; +import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState'; +import { WIDGET_SETTINGS_SELECTABLE_ITEM_IDS } from '@/side-panel/pages/page-layout/constants/settings/WidgetSettingsSelectableItemIds'; +import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; +import { isDefined } from 'twenty-shared/utils'; + +export const useWidgetSettingsPlacementSelectableItemIds = ( + pageLayoutId: string, +) => { + const pageLayoutEditingWidgetId = useAtomComponentStateValue( + pageLayoutEditingWidgetIdComponentState, + pageLayoutId, + ); + + const { canMovePageLayoutWidgetUp } = + useCanMovePageLayoutWidgetUp(pageLayoutId); + const { canMovePageLayoutWidgetDown } = + useCanMovePageLayoutWidgetDown(pageLayoutId); + + const showMoveUp = + isDefined(pageLayoutEditingWidgetId) && + canMovePageLayoutWidgetUp(pageLayoutEditingWidgetId); + + const showMoveDown = + isDefined(pageLayoutEditingWidgetId) && + canMovePageLayoutWidgetDown(pageLayoutEditingWidgetId); + + const placementSelectableItemIds = [ + ...(showMoveUp ? [WIDGET_SETTINGS_SELECTABLE_ITEM_IDS.MOVE_UP] : []), + ...(showMoveDown ? [WIDGET_SETTINGS_SELECTABLE_ITEM_IDS.MOVE_DOWN] : []), + WIDGET_SETTINGS_SELECTABLE_ITEM_IDS.MOVE_TO_TAB, + WIDGET_SETTINGS_SELECTABLE_ITEM_IDS.ADD_WIDGET_ABOVE, + WIDGET_SETTINGS_SELECTABLE_ITEM_IDS.ADD_WIDGET_BELOW, + ]; + + return { placementSelectableItemIds }; +}; diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/types/PageLayoutSidePanelPage.ts b/packages/twenty-front/src/modules/side-panel/pages/page-layout/types/PageLayoutSidePanelPage.ts index c0b5e7faa4..b605420753 100644 --- a/packages/twenty-front/src/modules/side-panel/pages/page-layout/types/PageLayoutSidePanelPage.ts +++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/types/PageLayoutSidePanelPage.ts @@ -1,10 +1,11 @@ import { type SidePanelPages } from 'twenty-shared/types'; export type PageLayoutSidePanelPage = - | SidePanelPages.PageLayoutWidgetTypeSelect - | SidePanelPages.PageLayoutGraphTypeSelect - | SidePanelPages.PageLayoutIframeSettings + | SidePanelPages.PageLayoutDashboardWidgetTypeSelect | SidePanelPages.PageLayoutTabSettings - | SidePanelPages.PageLayoutFieldsSettings - | SidePanelPages.PageLayoutFieldSettings - | SidePanelPages.PageLayoutRecordTableSettings; + | SidePanelPages.DashboardChartSettings + | SidePanelPages.DashboardIframeSettings + | SidePanelPages.DashboardRecordTableSettings + | SidePanelPages.RecordPageFieldsSettings + | SidePanelPages.RecordPageFieldSettings + | SidePanelPages.PageLayoutRecordPageWidgetTypeSelect; diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/utils/__tests__/isPageLayoutSidePanelPage.test.ts b/packages/twenty-front/src/modules/side-panel/pages/page-layout/utils/__tests__/isPageLayoutSidePanelPage.test.ts new file mode 100644 index 0000000000..13362d4141 --- /dev/null +++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/utils/__tests__/isPageLayoutSidePanelPage.test.ts @@ -0,0 +1,37 @@ +import { isPageLayoutSidePanelPage } from '@/side-panel/pages/page-layout/utils/isPageLayoutSidePanelPage'; +import { SidePanelPages } from 'twenty-shared/types'; + +describe('isPageLayoutSidePanelPage', () => { + const pageLayoutPages: SidePanelPages[] = [ + SidePanelPages.PageLayoutDashboardWidgetTypeSelect, + SidePanelPages.PageLayoutTabSettings, + SidePanelPages.DashboardChartSettings, + SidePanelPages.DashboardIframeSettings, + SidePanelPages.DashboardRecordTableSettings, + SidePanelPages.RecordPageFieldsSettings, + SidePanelPages.RecordPageFieldSettings, + SidePanelPages.PageLayoutRecordPageWidgetTypeSelect, + ]; + + it.each(pageLayoutPages)( + 'should return true for page layout page: %s', + (page) => { + expect(isPageLayoutSidePanelPage(page)).toBe(true); + }, + ); + + const nonPageLayoutPages: SidePanelPages[] = [ + SidePanelPages.ViewRecord, + SidePanelPages.AskAI, + SidePanelPages.ComposeEmail, + SidePanelPages.SearchRecords, + SidePanelPages.ViewFrontComponent, + ]; + + it.each(nonPageLayoutPages)( + 'should return false for non-page-layout page: %s', + (page) => { + expect(isPageLayoutSidePanelPage(page)).toBe(false); + }, + ); +}); diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/utils/getPageLayoutIcon.ts b/packages/twenty-front/src/modules/side-panel/pages/page-layout/utils/getPageLayoutIcon.ts index 6ad916bc89..90fe4c9350 100644 --- a/packages/twenty-front/src/modules/side-panel/pages/page-layout/utils/getPageLayoutIcon.ts +++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/utils/getPageLayoutIcon.ts @@ -6,25 +6,28 @@ import { IconChartPie, IconFrame, IconList, + IconPlus, IconTable, } from 'twenty-ui/display'; export const getPageLayoutIcon = (page: PageLayoutSidePanelPage) => { switch (page) { - case SidePanelPages.PageLayoutWidgetTypeSelect: + case SidePanelPages.PageLayoutDashboardWidgetTypeSelect: return IconAppWindow; - case SidePanelPages.PageLayoutGraphTypeSelect: + case SidePanelPages.DashboardChartSettings: return IconChartPie; - case SidePanelPages.PageLayoutIframeSettings: + case SidePanelPages.DashboardIframeSettings: return IconFrame; case SidePanelPages.PageLayoutTabSettings: return IconAppWindow; - case SidePanelPages.PageLayoutFieldsSettings: + case SidePanelPages.RecordPageFieldsSettings: return IconList; - case SidePanelPages.PageLayoutFieldSettings: + case SidePanelPages.RecordPageFieldSettings: return IconList; - case SidePanelPages.PageLayoutRecordTableSettings: + case SidePanelPages.DashboardRecordTableSettings: return IconTable; + case SidePanelPages.PageLayoutRecordPageWidgetTypeSelect: + return IconPlus; default: assertUnreachable(page); } diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/utils/getPageLayoutPageTitle.ts b/packages/twenty-front/src/modules/side-panel/pages/page-layout/utils/getPageLayoutPageTitle.ts index d88d3abcaf..e674b18d5a 100644 --- a/packages/twenty-front/src/modules/side-panel/pages/page-layout/utils/getPageLayoutPageTitle.ts +++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/utils/getPageLayoutPageTitle.ts @@ -5,20 +5,22 @@ import { assertUnreachable } from 'twenty-shared/utils'; export const getPageLayoutPageTitle = (page: PageLayoutSidePanelPage) => { switch (page) { - case SidePanelPages.PageLayoutWidgetTypeSelect: + case SidePanelPages.PageLayoutDashboardWidgetTypeSelect: return t`Add Widget`; - case SidePanelPages.PageLayoutGraphTypeSelect: + case SidePanelPages.DashboardChartSettings: return t`Select Graph Type`; - case SidePanelPages.PageLayoutIframeSettings: + case SidePanelPages.DashboardIframeSettings: return t`iFrame Settings`; case SidePanelPages.PageLayoutTabSettings: return t`Tab Settings`; - case SidePanelPages.PageLayoutFieldsSettings: + case SidePanelPages.RecordPageFieldsSettings: return t`Fields Settings`; - case SidePanelPages.PageLayoutFieldSettings: + case SidePanelPages.RecordPageFieldSettings: return t`Field widget`; - case SidePanelPages.PageLayoutRecordTableSettings: + case SidePanelPages.DashboardRecordTableSettings: return t`Record Table Settings`; + case SidePanelPages.PageLayoutRecordPageWidgetTypeSelect: + return t`New widget`; default: assertUnreachable(page); } diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/utils/isPageLayoutSidePanelPage.ts b/packages/twenty-front/src/modules/side-panel/pages/page-layout/utils/isPageLayoutSidePanelPage.ts new file mode 100644 index 0000000000..23ecc731a8 --- /dev/null +++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/utils/isPageLayoutSidePanelPage.ts @@ -0,0 +1,20 @@ +import { SidePanelPages } from 'twenty-shared/types'; + +import { type PageLayoutSidePanelPage } from '@/side-panel/pages/page-layout/types/PageLayoutSidePanelPage'; + +const PAGE_LAYOUT_SIDE_PANEL_PAGES: PageLayoutSidePanelPage[] = [ + SidePanelPages.PageLayoutDashboardWidgetTypeSelect, + SidePanelPages.PageLayoutTabSettings, + SidePanelPages.DashboardChartSettings, + SidePanelPages.DashboardIframeSettings, + SidePanelPages.DashboardRecordTableSettings, + SidePanelPages.RecordPageFieldsSettings, + SidePanelPages.RecordPageFieldSettings, + SidePanelPages.PageLayoutRecordPageWidgetTypeSelect, +]; + +export const isPageLayoutSidePanelPage = ( + page: SidePanelPages, +): page is PageLayoutSidePanelPage => { + return (PAGE_LAYOUT_SIDE_PANEL_PAGES as SidePanelPages[]).includes(page); +}; diff --git a/packages/twenty-shared/src/types/SidePanelPages.ts b/packages/twenty-shared/src/types/SidePanelPages.ts index 5fe84d1a6c..81c76dca12 100644 --- a/packages/twenty-shared/src/types/SidePanelPages.ts +++ b/packages/twenty-shared/src/types/SidePanelPages.ts @@ -15,16 +15,17 @@ export enum SidePanelPages { SearchRecords = 'search-records', AskAI = 'ask-ai', ViewPreviousAIChats = 'view-previous-ai-chats', - PageLayoutWidgetTypeSelect = 'page-layout-widget-type-select', - PageLayoutGraphTypeSelect = 'page-layout-graph-type-select', - PageLayoutIframeSettings = 'page-layout-iframe-settings', + PageLayoutDashboardWidgetTypeSelect = 'page-layout-dashboard-widget-type-select', PageLayoutTabSettings = 'page-layout-tab-settings', - PageLayoutFieldsSettings = 'page-layout-fields-settings', - PageLayoutRecordTableSettings = 'page-layout-record-table-settings', - PageLayoutFieldSettings = 'page-layout-field-settings', + DashboardChartSettings = 'dashboard-chart-settings', + DashboardIframeSettings = 'dashboard-iframe-settings', + DashboardRecordTableSettings = 'dashboard-record-table-settings', + RecordPageFieldsSettings = 'record-page-fields-settings', + RecordPageFieldSettings = 'record-page-field-settings', ViewFrontComponent = 'view-front-component', NavigationMenuItemEdit = 'navigation-menu-item-edit', NavigationMenuAddItem = 'navigation-menu-add-item', CommandMenuEdit = 'command-menu-edit', + PageLayoutRecordPageWidgetTypeSelect = 'page-layout-record-page-widget-type-select', ComposeEmail = 'compose-email', }