Add Manage and Placement sections in widget side panel page for record page layouts (#19310)

https://github.com/user-attachments/assets/f6120c2e-95e7-4b9b-abb5-69a10c3f2f3b
This commit is contained in:
Baptiste Devessier
2026-04-07 16:21:35 +02:00
committed by GitHub
parent e048d03872
commit 1f3965e5f8
60 changed files with 3364 additions and 235 deletions
@@ -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<typeof makeTab>[]): DraftPageLayout => ({
id: 'test-layout',
name: 'Test Layout',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: null,
tabs,
});
describe('useCanMovePageLayoutWidgetDown', () => {
const getWrapper =
(store = createStore()) =>
({ children }: { children: ReactNode }) => (
<PageLayoutTestWrapper
store={store}
instanceId={PAGE_LAYOUT_TEST_INSTANCE_ID}
>
{children}
</PageLayoutTestWrapper>
);
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);
});
});
@@ -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<typeof makeTab>[]): DraftPageLayout => ({
id: 'test-layout',
name: 'Test Layout',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: null,
tabs,
});
describe('useCanMovePageLayoutWidgetUp', () => {
const getWrapper =
(store = createStore()) =>
({ children }: { children: ReactNode }) => (
<PageLayoutTestWrapper
store={store}
instanceId={PAGE_LAYOUT_TEST_INSTANCE_ID}
>
{children}
</PageLayoutTestWrapper>
);
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);
});
});
@@ -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,
});
});
@@ -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(),
@@ -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,
});
});
@@ -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<typeof makeTab>[]): DraftPageLayout => ({
id: 'test-layout',
name: 'Test Layout',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: null,
tabs,
});
describe('useInsertCreatedWidgetAtContext', () => {
const getWrapper =
(store = createStore()) =>
({ children }: { children: ReactNode }) => (
<PageLayoutTestWrapper
store={store}
instanceId={PAGE_LAYOUT_TEST_INSTANCE_ID}
>
{children}
</PageLayoutTestWrapper>
);
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']);
});
});
@@ -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<typeof makeTab>[]): DraftPageLayout => ({
id: 'test-layout',
name: 'Test Layout',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: null,
tabs,
});
describe('useMovePageLayoutWidgetDown', () => {
const getWrapper =
(store = createStore()) =>
({ children }: { children: ReactNode }) => (
<PageLayoutTestWrapper
store={store}
instanceId={PAGE_LAYOUT_TEST_INSTANCE_ID}
>
{children}
</PageLayoutTestWrapper>
);
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');
});
});
@@ -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<typeof makeTab>[]): DraftPageLayout => ({
id: 'test-layout',
name: 'Test Layout',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: null,
tabs,
});
describe('useMovePageLayoutWidgetUp', () => {
const getWrapper =
(store = createStore()) =>
({ children }: { children: ReactNode }) => (
<PageLayoutTestWrapper
store={store}
instanceId={PAGE_LAYOUT_TEST_INSTANCE_ID}
>
{children}
</PageLayoutTestWrapper>
);
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');
});
});
@@ -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<typeof makeTab>[]): DraftPageLayout => ({
id: 'test-layout',
name: 'Test Layout',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: null,
tabs,
});
describe('useMoveWidgetToTab', () => {
const getWrapper =
(store = createStore()) =>
({ children }: { children: ReactNode }) => (
<PageLayoutTestWrapper
store={store}
instanceId={PAGE_LAYOUT_TEST_INSTANCE_ID}
>
{children}
</PageLayoutTestWrapper>
);
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);
});
});
@@ -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 };
};
@@ -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 };
};
@@ -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,
]);
@@ -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,
});
},
@@ -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,
@@ -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,
});
}
@@ -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 };
};
@@ -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 };
};
@@ -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 };
};
@@ -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 };
};
@@ -7,7 +7,7 @@ export const useNavigateToMoreWidgets = () => {
const navigateToMoreWidgets = useCallback(() => {
navigatePageLayoutSidePanel({
sidePanelPage: SidePanelPages.PageLayoutWidgetTypeSelect,
sidePanelPage: SidePanelPages.PageLayoutRecordPageWidgetTypeSelect,
});
}, [navigatePageLayoutSidePanel]);
@@ -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,
};
};
@@ -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,
@@ -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,
),
};
});
@@ -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<WidgetInsertionContext>({
key: 'widgetInsertionContextComponentState',
defaultValue: null,
componentInstanceContext: PageLayoutComponentInstanceContext,
});
@@ -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();
});
});
@@ -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);
});
});
@@ -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']);
});
});
@@ -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;
};
@@ -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;
@@ -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;
});
@@ -43,7 +43,7 @@ export const DashboardWidgetPlaceholder = () => {
setIsPageLayoutInEditMode(true);
}
navigatePageLayoutSidePanel({
sidePanelPage: SidePanelPages.PageLayoutWidgetTypeSelect,
sidePanelPage: SidePanelPages.PageLayoutDashboardWidgetTypeSelect,
resetNavigationStack: true,
});
};
@@ -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,
});
@@ -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 };
};
@@ -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 };
};
@@ -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 <StyledFormContainer>{children}</StyledFormContainer>;
};
@@ -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) {
@@ -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,
@@ -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, React.ReactNode>(
[SidePanelPages.AskAI, <SidePanelAskAIPage />],
[SidePanelPages.ViewPreviousAIChats, <SidePanelAIChatThreadsPage />],
[
SidePanelPages.PageLayoutWidgetTypeSelect,
<SidePanelPageLayoutWidgetTypeSelect />,
SidePanelPages.PageLayoutDashboardWidgetTypeSelect,
<SidePanelPageLayoutDashboardWidgetTypeSelect />,
],
[
SidePanelPages.PageLayoutGraphTypeSelect,
<SidePanelPageLayoutChartSettings />,
SidePanelPages.DashboardChartSettings,
<SidePanelDashboardChartSettings />,
],
[
SidePanelPages.PageLayoutIframeSettings,
<SidePanelPageLayoutIframeSettings />,
SidePanelPages.DashboardIframeSettings,
<SidePanelDashboardIframeSettings />,
],
[SidePanelPages.PageLayoutTabSettings, <SidePanelPageLayoutTabSettings />],
[
SidePanelPages.PageLayoutFieldsSettings,
<SidePanelPageLayoutFieldsSettings />,
SidePanelPages.DashboardRecordTableSettings,
<SidePanelDashboardRecordTableSettings />,
],
[
SidePanelPages.PageLayoutFieldSettings,
<SidePanelPageLayoutFieldSettings />,
SidePanelPages.RecordPageFieldsSettings,
<SidePanelRecordPageFieldsSettings />,
],
[
SidePanelPages.PageLayoutRecordTableSettings,
<SidePanelPageLayoutRecordTableSettings />,
SidePanelPages.RecordPageFieldSettings,
<SidePanelRecordPageFieldSettings />,
],
[SidePanelPages.ViewFrontComponent, <SidePanelFrontComponentPage />],
[
SidePanelPages.PageLayoutRecordPageWidgetTypeSelect,
<SidePanelPageLayoutRecordPageWidgetTypeSelect />,
],
[
SidePanelPages.NavigationMenuItemEdit,
<SidePanelNavigationMenuItemEditPage />,
@@ -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,
};
};
@@ -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,
);
}
}
}
@@ -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,
});
};
@@ -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 (
<SidePanelList commandGroups={[]} selectableItemIds={selectableItemIds}>
<SidePanelGroup heading={t`Widget type`}>
<SelectableListItem itemId="fields" onEnter={handleCreateFieldsWidget}>
<CommandMenuItem
Icon={IconList}
label={t`Fields`}
id="fields"
onClick={handleCreateFieldsWidget}
/>
</SelectableListItem>
<SelectableListItem itemId="field" onEnter={handleCreateFieldWidget}>
<CommandMenuItem
Icon={IconList}
label={t`Field`}
id="field"
onClick={handleCreateFieldWidget}
/>
</SelectableListItem>
</SidePanelGroup>
{frontComponentsWithSelectItemId.length > 0 && (
<SidePanelGroup heading={t`Front Components`}>
{frontComponentsWithSelectItemId.map(
({ frontComponent, selectItemId }) => (
<SelectableListItem
key={frontComponent.id}
itemId={selectItemId}
onEnter={() => handleCreateFrontComponentWidget(frontComponent)}
>
<CommandMenuItem
Icon={IconApps}
label={frontComponent.name}
id={selectItemId}
onClick={() =>
handleCreateFrontComponentWidget(frontComponent)
}
/>
</SelectableListItem>
),
)}
</SidePanelGroup>
)}
</SidePanelList>
);
};
@@ -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';
@@ -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 (
<>
<SidePanelGroup heading={t`Manage`}>
<SelectableListItem
itemId={WIDGET_SETTINGS_SELECTABLE_ITEM_IDS.RESET_TO_DEFAULT}
onEnter={handleResetToDefault}
>
<CommandMenuItem
id={WIDGET_SETTINGS_SELECTABLE_ITEM_IDS.RESET_TO_DEFAULT}
Icon={IconRefreshDot}
label={t`Reset to default`}
onClick={handleResetToDefault}
/>
</SelectableListItem>
<SelectableListItem
itemId={WIDGET_SETTINGS_SELECTABLE_ITEM_IDS.REPLACE_WIDGET}
onEnter={handleReplaceWidget}
>
<CommandMenuItem
id={WIDGET_SETTINGS_SELECTABLE_ITEM_IDS.REPLACE_WIDGET}
Icon={IconSwitchHorizontal}
label={t`Replace widget`}
hasSubMenu
onClick={handleReplaceWidget}
/>
</SelectableListItem>
<SelectableListItem
itemId={WIDGET_SETTINGS_SELECTABLE_ITEM_IDS.DELETE_WIDGET}
onEnter={handleDeleteWidget}
>
<CommandMenuItem
id={WIDGET_SETTINGS_SELECTABLE_ITEM_IDS.DELETE_WIDGET}
Icon={IconTrash}
label={t`Delete widget`}
onClick={handleDeleteWidget}
/>
</SelectableListItem>
</SidePanelGroup>
<ConfirmationModal
modalInstanceId={RESET_WIDGET_TO_DEFAULT_MODAL_ID}
title={t`Reset to default`}
subtitle={t`This will cancel all modifications done on the widget. This action cannot be undone.`}
onConfirmClick={handleConfirmReset}
confirmButtonText={t`Reset`}
confirmButtonAccent="danger"
/>
</>
);
};
@@ -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 (
<SidePanelGroup heading={t`Placement`}>
{showMoveUp && (
<SelectableListItem
itemId={WIDGET_SETTINGS_SELECTABLE_ITEM_IDS.MOVE_UP}
onEnter={handleMoveUp}
>
<CommandMenuItem
id={WIDGET_SETTINGS_SELECTABLE_ITEM_IDS.MOVE_UP}
Icon={IconChevronUp}
label={t`Move Up`}
onClick={handleMoveUp}
/>
</SelectableListItem>
)}
{showMoveDown && (
<SelectableListItem
itemId={WIDGET_SETTINGS_SELECTABLE_ITEM_IDS.MOVE_DOWN}
onEnter={handleMoveDown}
>
<CommandMenuItem
id={WIDGET_SETTINGS_SELECTABLE_ITEM_IDS.MOVE_DOWN}
Icon={IconChevronDown}
label={t`Move Down`}
onClick={handleMoveDown}
/>
</SelectableListItem>
)}
<SelectableListItem
itemId={WIDGET_SETTINGS_SELECTABLE_ITEM_IDS.MOVE_TO_TAB}
>
<CommandMenuItemDropdown
id={WIDGET_SETTINGS_SELECTABLE_ITEM_IDS.MOVE_TO_TAB}
label={t`Move to another tab`}
Icon={IconArrowsVertical}
dropdownId={WIDGET_SETTINGS_SELECTABLE_ITEM_IDS.MOVE_TO_TAB}
dropdownComponents={
<DropdownContent>
<MoveToTabDropdownContent />
</DropdownContent>
}
dropdownPlacement="bottom-end"
/>
</SelectableListItem>
<SelectableListItem
itemId={WIDGET_SETTINGS_SELECTABLE_ITEM_IDS.ADD_WIDGET_ABOVE}
onEnter={handleAddWidgetAbove}
>
<CommandMenuItem
id={WIDGET_SETTINGS_SELECTABLE_ITEM_IDS.ADD_WIDGET_ABOVE}
Icon={IconRowInsertTop}
label={t`Add widget above`}
onClick={handleAddWidgetAbove}
/>
</SelectableListItem>
<SelectableListItem
itemId={WIDGET_SETTINGS_SELECTABLE_ITEM_IDS.ADD_WIDGET_BELOW}
onEnter={handleAddWidgetBelow}
>
<CommandMenuItem
id={WIDGET_SETTINGS_SELECTABLE_ITEM_IDS.ADD_WIDGET_BELOW}
Icon={IconRowInsertBottom}
label={t`Add widget below`}
onClick={handleAddWidgetBelow}
/>
</SelectableListItem>
</SidePanelGroup>
);
};
@@ -15,7 +15,7 @@ const StyledContainer = styled.div`
height: 100%;
`;
export const SidePanelPageLayoutChartSettings = () => {
export const SidePanelDashboardChartSettings = () => {
const { pageLayoutId } = usePageLayoutIdFromContextStore();
const pageLayoutDraft = useAtomComponentStateValue(
@@ -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 (
<StyledOuterContainer>
<StyledContainer>
<FormTextFieldInput
label={t`URL to Embed`}
placeholder={t`https://example.com/embed`}
defaultValue={url}
onChange={handleUrlChange}
error={urlError}
/>
</StyledContainer>
<StyledContainer>
<StyledSidePanelContainer>
<SidePanelList commandGroups={[]} selectableItemIds={[]}>
<SidePanelGroup heading={t`URL to Embed`}>
<SidePanelGroupFormContainer>
<FormTextFieldInput
placeholder={t`https://example.com/embed`}
defaultValue={url}
onChange={handleUrlChange}
error={urlError}
/>
</SidePanelGroupFormContainer>
</SidePanelGroup>
</SidePanelList>
</StyledSidePanelContainer>
<WidgetSettingsFooter pageLayoutId={pageLayoutId} />
</StyledOuterContainer>
</StyledContainer>
);
};
@@ -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();
@@ -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 (
<DropdownMenuItemsContainer>
<MenuItem text={t`No available tabs`} />
</DropdownMenuItemsContainer>
);
}
return (
<DropdownMenuItemsContainer>
{eligibleTabs.map((tab) => (
<MenuItem
key={tab.id}
text={tab.title ?? ''}
onClick={() => {
moveWidgetToTab(pageLayoutEditingWidgetId, tab.id);
closeDropdown();
}}
/>
))}
</DropdownMenuItemsContainer>
);
};
@@ -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 (
<StyledContainer>
@@ -102,9 +115,10 @@ export const SidePanelPageLayoutFieldSettings = () => {
/>
</SelectableListItem>
</SidePanelGroup>
<WidgetSettingsManageSection pageLayoutId={pageLayoutId} />
<WidgetSettingsPlacementSection pageLayoutId={pageLayoutId} />
</SidePanelList>
</StyledSidePanelContainer>
<WidgetSettingsFooter pageLayoutId={pageLayoutId} />
</StyledContainer>
);
};
@@ -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}
/>
</SidePanelGroup>
<SidePanelGroup heading={t`Manage`}>
<SelectableListItem
itemId="reset-to-default"
onEnter={handleResetToDefault}
>
<CommandMenuItem
id="reset-to-default"
Icon={IconRefreshDot}
label={t`Reset to default`}
onClick={handleResetToDefault}
/>
</SelectableListItem>
<SelectableListItem itemId="delete" onEnter={handleDelete}>
<CommandMenuItem
id="delete"
Icon={IconTrash}
label={t`Delete widget`}
onClick={handleDelete}
/>
</SelectableListItem>
</SidePanelGroup>
<WidgetSettingsManageSection pageLayoutId={pageLayoutId} />
<WidgetSettingsPlacementSection pageLayoutId={pageLayoutId} />
</SidePanelList>
</StyledSidePanelContainer>
<WidgetSettingsFooter pageLayoutId={pageLayoutId} />
<ConfirmationModal
modalInstanceId={RESET_WIDGET_TO_DEFAULT_MODAL_ID}
title={t`Reset to default`}
subtitle={t`This will cancel all modifications done on the widget. This action cannot be undone.`}
onConfirmClick={handleConfirmReset}
confirmButtonText={t`Reset`}
confirmButtonAccent="danger"
/>
</StyledContainer>
);
};
@@ -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;
@@ -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;
};
@@ -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 };
};
@@ -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;
@@ -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);
},
);
});
@@ -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);
}
@@ -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);
}
@@ -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);
};
@@ -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',
}