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:
Weiko
2026-04-17 20:19:46 +02:00
committed by GitHub
parent 4a5702328a
commit 13b32a22b6
6 changed files with 179 additions and 13 deletions
@@ -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(),
@@ -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' })]),
@@ -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,
@@ -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
@@ -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: