From 13b32a22b6d9d24b486675bdfab9f4aa21284f09 Mon Sep 17 00:00:00 2001 From: Weiko Date: Fri, 17 Apr 2026 20:19:46 +0200 Subject: [PATCH] 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). Screenshot 2026-04-17 at 19 55 51 --- .../__tests__/useCreatePageLayoutTab.test.tsx | 73 +++++++++++++++++++ .../hooks/useCreatePageLayoutTab.ts | 5 ++ ...onvertPageLayoutDraftToUpdateInput.test.ts | 22 ++++++ .../convertPageLayoutDraftToUpdateInput.ts | 1 + .../SidePanelPageLayoutInfoContent.tsx | 65 ++++++++++++++--- .../hooks/usePageLayoutHeaderInfo.ts | 26 ++++++- 6 files changed, 179 insertions(+), 13 deletions(-) diff --git a/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useCreatePageLayoutTab.test.tsx b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useCreatePageLayoutTab.test.tsx index 641c0a6208..c494941281 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useCreatePageLayoutTab.test.tsx +++ b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useCreatePageLayoutTab.test.tsx @@ -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'); diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useCreatePageLayoutTab.ts b/packages/twenty-front/src/modules/page-layout/hooks/useCreatePageLayoutTab.ts index deb110bed8..5434d7db5d 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/useCreatePageLayoutTab.ts +++ b/packages/twenty-front/src/modules/page-layout/hooks/useCreatePageLayoutTab.ts @@ -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(), diff --git a/packages/twenty-front/src/modules/page-layout/utils/__tests__/convertPageLayoutDraftToUpdateInput.test.ts b/packages/twenty-front/src/modules/page-layout/utils/__tests__/convertPageLayoutDraftToUpdateInput.test.ts index 8e4028e524..bd503ec7ea 100644 --- a/packages/twenty-front/src/modules/page-layout/utils/__tests__/convertPageLayoutDraftToUpdateInput.test.ts +++ b/packages/twenty-front/src/modules/page-layout/utils/__tests__/convertPageLayoutDraftToUpdateInput.test.ts @@ -34,6 +34,7 @@ const makeTab = ( id: string, widgets: PageLayoutWidget[], layoutMode?: PageLayoutTabLayoutMode, + overrides?: Partial, ): 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' })]), diff --git a/packages/twenty-front/src/modules/page-layout/utils/convertPageLayoutDraftToUpdateInput.ts b/packages/twenty-front/src/modules/page-layout/utils/convertPageLayoutDraftToUpdateInput.ts index d3db0877ff..eeb990c31e 100644 --- a/packages/twenty-front/src/modules/page-layout/utils/convertPageLayoutDraftToUpdateInput.ts +++ b/packages/twenty-front/src/modules/page-layout/utils/convertPageLayoutDraftToUpdateInput.ts @@ -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, diff --git a/packages/twenty-front/src/modules/side-panel/components/SidePanelPageLayoutInfoContent.tsx b/packages/twenty-front/src/modules/side-panel/components/SidePanelPageLayoutInfoContent.tsx index a1122a403f..996947ebbc 100644 --- a/packages/twenty-front/src/modules/side-panel/components/SidePanelPageLayoutInfoContent.tsx +++ b/packages/twenty-front/src/modules/side-panel/components/SidePanelPageLayoutInfoContent.tsx @@ -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) ? ( + + ) : undefined; + + const handleIconChange = ({ iconKey }: { iconKey: string }) => { + if (!isDefined(tab)) { + return; + } + + updatePageLayoutTab(tab.id, { icon: iconKey }); + }; + + const iconElement = + isIconEditable && isDefined(tab) ? ( + + {renderedIcon} + + } + /> + ) : ( + renderedIcon + ); + return ( - ) : undefined - } + icon={iconElement} iconColor={headerIconColor} title={ { 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: