Add page layout tab icon picker (#19818)
Adds the ability to change the icon of a record page layout tab from the side panel in tab edit mode, and sets a default icon for newly-created record page tabs (no default for dashboards). <img width="916" height="312" alt="Screenshot 2026-04-17 at 19 55 51" src="https://github.com/user-attachments/assets/d9f57e89-d40d-483e-b508-5d7318df1ef5" />
This commit is contained in:
+73
@@ -141,6 +141,79 @@ describe('useCreatePageLayoutTab', () => {
|
||||
expect(result.current.pageLayoutDraft.tabs[1].title).toBe('Tab 2');
|
||||
});
|
||||
|
||||
it('should default icon to IconAppWindow for new RECORD_PAGE tabs', () => {
|
||||
const uuidModule = require('uuid');
|
||||
uuidModule.v4.mockReturnValue('mock-uuid');
|
||||
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const setPageLayoutDraft = useSetAtomComponentState(
|
||||
pageLayoutDraftComponentState,
|
||||
PAGE_LAYOUT_TEST_INSTANCE_ID,
|
||||
);
|
||||
const pageLayoutDraft = useAtomComponentStateValue(
|
||||
pageLayoutDraftComponentState,
|
||||
PAGE_LAYOUT_TEST_INSTANCE_ID,
|
||||
);
|
||||
const createTab = useCreatePageLayoutTab({
|
||||
pageLayoutId: PAGE_LAYOUT_TEST_INSTANCE_ID,
|
||||
tabListInstanceId: getTabListInstanceIdFromPageLayoutId(
|
||||
PAGE_LAYOUT_TEST_INSTANCE_ID,
|
||||
),
|
||||
});
|
||||
return { setPageLayoutDraft, pageLayoutDraft, createTab };
|
||||
},
|
||||
{
|
||||
wrapper: PageLayoutTestWrapper,
|
||||
},
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setPageLayoutDraft({
|
||||
id: 'test-layout',
|
||||
name: 'Test Layout',
|
||||
type: PageLayoutType.RECORD_PAGE,
|
||||
objectMetadataId: null,
|
||||
tabs: [],
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.createTab.createPageLayoutTab();
|
||||
});
|
||||
|
||||
expect(result.current.pageLayoutDraft.tabs[0].icon).toBe('IconAppWindow');
|
||||
});
|
||||
|
||||
it('should leave icon as null for new DASHBOARD tabs', () => {
|
||||
const uuidModule = require('uuid');
|
||||
uuidModule.v4.mockReturnValue('mock-uuid');
|
||||
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
createTab: useCreatePageLayoutTab({
|
||||
pageLayoutId: PAGE_LAYOUT_TEST_INSTANCE_ID,
|
||||
tabListInstanceId: getTabListInstanceIdFromPageLayoutId(
|
||||
PAGE_LAYOUT_TEST_INSTANCE_ID,
|
||||
),
|
||||
}),
|
||||
pageLayoutDraft: useAtomComponentStateValue(
|
||||
pageLayoutDraftComponentState,
|
||||
PAGE_LAYOUT_TEST_INSTANCE_ID,
|
||||
),
|
||||
}),
|
||||
{
|
||||
wrapper: PageLayoutTestWrapper,
|
||||
},
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.createTab.createPageLayoutTab();
|
||||
});
|
||||
|
||||
expect(result.current.pageLayoutDraft.tabs[0].icon).toBeNull();
|
||||
});
|
||||
|
||||
it('should default layoutMode to VERTICAL_LIST for record page layouts', () => {
|
||||
const uuidModule = require('uuid');
|
||||
uuidModule.v4.mockReturnValue('mock-uuid');
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSe
|
||||
import { useStore } from 'jotai';
|
||||
import { useCallback } from 'react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { PageLayoutType } from '~/generated-metadata/graphql';
|
||||
|
||||
export const useCreatePageLayoutTab = ({
|
||||
pageLayoutId: pageLayoutIdFromProps,
|
||||
@@ -58,6 +59,10 @@ export const useCreatePageLayoutTab = ({
|
||||
title: title || `Tab ${tabsLength + 1}`,
|
||||
position: maxPosition + 1,
|
||||
pageLayoutId: pageLayoutId,
|
||||
icon:
|
||||
pageLayoutDraft.type === PageLayoutType.RECORD_PAGE
|
||||
? 'IconAppWindow'
|
||||
: null,
|
||||
layoutMode: getDefaultTabLayoutMode(pageLayoutDraft.type),
|
||||
widgets: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
|
||||
+22
@@ -34,6 +34,7 @@ const makeTab = (
|
||||
id: string,
|
||||
widgets: PageLayoutWidget[],
|
||||
layoutMode?: PageLayoutTabLayoutMode,
|
||||
overrides?: Partial<DraftPageLayout['tabs'][number]>,
|
||||
): DraftPageLayout['tabs'][number] =>
|
||||
({
|
||||
id,
|
||||
@@ -44,6 +45,7 @@ const makeTab = (
|
||||
isActive: true,
|
||||
layoutMode,
|
||||
widgets,
|
||||
...overrides,
|
||||
}) as DraftPageLayout['tabs'][number];
|
||||
|
||||
describe('convertPageLayoutDraftToUpdateInput', () => {
|
||||
@@ -109,6 +111,26 @@ describe('convertPageLayoutDraftToUpdateInput', () => {
|
||||
expect(result.objectMetadataId).toBeNull();
|
||||
});
|
||||
|
||||
it('should propagate tab icon to the update input', () => {
|
||||
const draft = makeDraft([
|
||||
makeTab('tab-1', [], PageLayoutTabLayoutMode.GRID, {
|
||||
icon: 'IconHome',
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = convertPageLayoutDraftToUpdateInput(draft);
|
||||
|
||||
expect(result.tabs[0].icon).toBe('IconHome');
|
||||
});
|
||||
|
||||
it('should send null icon when tab icon is undefined', () => {
|
||||
const draft = makeDraft([makeTab('tab-1', [])]);
|
||||
|
||||
const result = convertPageLayoutDraftToUpdateInput(draft);
|
||||
|
||||
expect(result.tabs[0].icon).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle multiple tabs', () => {
|
||||
const draft = makeDraft([
|
||||
makeTab('tab-1', [makeWidget({ id: 'w1' })]),
|
||||
|
||||
+1
@@ -59,6 +59,7 @@ export const convertPageLayoutDraftToUpdateInput = (
|
||||
id: tab.id,
|
||||
title: tab.title,
|
||||
position: tab.position,
|
||||
icon: tab.icon ?? null,
|
||||
layoutMode: tab.layoutMode,
|
||||
widgets: widgets.map((widget, widgetIndex) => ({
|
||||
id: widget.id,
|
||||
|
||||
+53
-12
@@ -1,24 +1,38 @@
|
||||
import { usePageLayoutHeaderInfo } from '@/side-panel/components/hooks/usePageLayoutHeaderInfo';
|
||||
import { useUpdateSidePanelPageInfo } from '@/side-panel/hooks/useUpdateSidePanelPageInfo';
|
||||
import { sidePanelPageInfoState } from '@/side-panel/states/sidePanelPageInfoState';
|
||||
import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState';
|
||||
import { sidePanelShouldFocusTitleInputComponentState } from '@/side-panel/states/sidePanelShouldFocusTitleInputComponentState';
|
||||
import { useUpdatePageLayoutTab } from '@/page-layout/hooks/useUpdatePageLayoutTab';
|
||||
import { useUpdatePageLayoutWidget } from '@/page-layout/hooks/useUpdatePageLayoutWidget';
|
||||
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
|
||||
import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState';
|
||||
import { pageLayoutTabSettingsOpenTabIdComponentState } from '@/page-layout/states/pageLayoutTabSettingsOpenTabIdComponentState';
|
||||
import { usePageLayoutHeaderInfo } from '@/side-panel/components/hooks/usePageLayoutHeaderInfo';
|
||||
import { useUpdateSidePanelPageInfo } from '@/side-panel/hooks/useUpdateSidePanelPageInfo';
|
||||
import { sidePanelPageInfoState } from '@/side-panel/states/sidePanelPageInfoState';
|
||||
import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState';
|
||||
import { sidePanelShouldFocusTitleInputComponentState } from '@/side-panel/states/sidePanelShouldFocusTitleInputComponentState';
|
||||
import { IconPicker } from '@/ui/input/components/IconPicker';
|
||||
import { TitleInput } from '@/ui/input/components/TitleInput';
|
||||
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { css } from '@linaria/core';
|
||||
import { styled } from '@linaria/react';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { useContext, useState } from 'react';
|
||||
import { SidePanelPages } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { useIcons } from 'twenty-ui/display';
|
||||
import { SidePanelPageInfoLayout } from './SidePanelPageInfoLayout';
|
||||
import { ThemeContext } from 'twenty-ui/theme-constants';
|
||||
import { SidePanelPageInfoLayout } from './SidePanelPageInfoLayout';
|
||||
|
||||
const StyledClickableIconWrapper = styled.div`
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
line-height: 0;
|
||||
`;
|
||||
|
||||
const iconPickerContainerStyles = css`
|
||||
display: flex;
|
||||
line-height: 0;
|
||||
`;
|
||||
|
||||
export const SidePanelPageLayoutInfoContent = ({
|
||||
pageLayoutId,
|
||||
@@ -81,6 +95,8 @@ export const SidePanelPageLayoutInfoContent = ({
|
||||
isReadonly,
|
||||
tab,
|
||||
widgetInEditMode,
|
||||
isIconEditable,
|
||||
selectedIconKey,
|
||||
} = headerInfo;
|
||||
|
||||
const Icon = headerIcon ?? getIcon('IconDefault');
|
||||
@@ -115,13 +131,38 @@ export const SidePanelPageLayoutInfoContent = ({
|
||||
setEditedTitle(null);
|
||||
};
|
||||
|
||||
const renderedIcon = isDefined(headerIcon) ? (
|
||||
<Icon size={theme.icon.size.md} stroke={theme.icon.stroke.sm} />
|
||||
) : undefined;
|
||||
|
||||
const handleIconChange = ({ iconKey }: { iconKey: string }) => {
|
||||
if (!isDefined(tab)) {
|
||||
return;
|
||||
}
|
||||
|
||||
updatePageLayoutTab(tab.id, { icon: iconKey });
|
||||
};
|
||||
|
||||
const iconElement =
|
||||
isIconEditable && isDefined(tab) ? (
|
||||
<IconPicker
|
||||
dropdownId={`page-layout-tab-icon-picker-${tab.id}`}
|
||||
selectedIconKey={selectedIconKey ?? undefined}
|
||||
onChange={handleIconChange}
|
||||
className={iconPickerContainerStyles}
|
||||
clickableComponent={
|
||||
<StyledClickableIconWrapper>
|
||||
{renderedIcon}
|
||||
</StyledClickableIconWrapper>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
renderedIcon
|
||||
);
|
||||
|
||||
return (
|
||||
<SidePanelPageInfoLayout
|
||||
icon={
|
||||
isDefined(headerIcon) ? (
|
||||
<Icon size={theme.icon.size.md} stroke={theme.icon.stroke.sm} />
|
||||
) : undefined
|
||||
}
|
||||
icon={iconElement}
|
||||
iconColor={headerIconColor}
|
||||
title={
|
||||
<TitleInput
|
||||
|
||||
+25
-1
@@ -14,6 +14,7 @@ import {
|
||||
IconPlus,
|
||||
IconTable,
|
||||
type IconComponent,
|
||||
useIcons,
|
||||
} from 'twenty-ui/display';
|
||||
import { ThemeContext } from 'twenty-ui/theme-constants';
|
||||
import { PageLayoutTabLayoutMode } from '~/generated-metadata/graphql';
|
||||
@@ -26,6 +27,8 @@ type PageLayoutHeaderInfo = {
|
||||
isReadonly: boolean;
|
||||
tab: PageLayoutTab | undefined;
|
||||
widgetInEditMode: PageLayoutWidget | undefined;
|
||||
isIconEditable: boolean;
|
||||
selectedIconKey: string | null;
|
||||
};
|
||||
|
||||
type UsePageLayoutHeaderInfoParams = {
|
||||
@@ -46,6 +49,7 @@ export const usePageLayoutHeaderInfo = ({
|
||||
editedTitle,
|
||||
}: UsePageLayoutHeaderInfoParams): PageLayoutHeaderInfo | null => {
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const { getIcon } = useIcons();
|
||||
const iconColor = theme.font.color.tertiary;
|
||||
|
||||
switch (sidePanelPage) {
|
||||
@@ -68,14 +72,20 @@ export const usePageLayoutHeaderInfo = ({
|
||||
|
||||
const isCanvasTab = tab.layoutMode === PageLayoutTabLayoutMode.CANVAS;
|
||||
|
||||
const resolvedTabIcon = isDefined(tab.icon)
|
||||
? getIcon(tab.icon)
|
||||
: IconAppWindow;
|
||||
|
||||
return {
|
||||
headerIcon: IconAppWindow,
|
||||
headerIcon: resolvedTabIcon ?? IconAppWindow,
|
||||
headerIconColor: iconColor,
|
||||
headerType: isCanvasTab ? t`Full tab widget` : t`Tab`,
|
||||
title,
|
||||
isReadonly: false,
|
||||
tab,
|
||||
widgetInEditMode: undefined,
|
||||
isIconEditable: true,
|
||||
selectedIconKey: tab.icon ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -106,6 +116,8 @@ export const usePageLayoutHeaderInfo = ({
|
||||
isReadonly: false,
|
||||
tab: undefined,
|
||||
widgetInEditMode,
|
||||
isIconEditable: false,
|
||||
selectedIconKey: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -145,6 +157,8 @@ export const usePageLayoutHeaderInfo = ({
|
||||
isReadonly: false,
|
||||
tab: undefined,
|
||||
widgetInEditMode,
|
||||
isIconEditable: false,
|
||||
selectedIconKey: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -175,6 +189,8 @@ export const usePageLayoutHeaderInfo = ({
|
||||
isReadonly: false,
|
||||
tab: undefined,
|
||||
widgetInEditMode,
|
||||
isIconEditable: false,
|
||||
selectedIconKey: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -205,6 +221,8 @@ export const usePageLayoutHeaderInfo = ({
|
||||
isReadonly: false,
|
||||
tab: undefined,
|
||||
widgetInEditMode,
|
||||
isIconEditable: false,
|
||||
selectedIconKey: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -235,6 +253,8 @@ export const usePageLayoutHeaderInfo = ({
|
||||
isReadonly: false,
|
||||
tab: undefined,
|
||||
widgetInEditMode,
|
||||
isIconEditable: false,
|
||||
selectedIconKey: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -247,6 +267,8 @@ export const usePageLayoutHeaderInfo = ({
|
||||
isReadonly: true,
|
||||
tab: undefined,
|
||||
widgetInEditMode: undefined,
|
||||
isIconEditable: false,
|
||||
selectedIconKey: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -259,6 +281,8 @@ export const usePageLayoutHeaderInfo = ({
|
||||
isReadonly: true,
|
||||
tab: undefined,
|
||||
widgetInEditMode: undefined,
|
||||
isIconEditable: false,
|
||||
selectedIconKey: null,
|
||||
};
|
||||
}
|
||||
default:
|
||||
|
||||
Reference in New Issue
Block a user