Implement new version of the side panel sub header (#16683)

## Before
<img width="822" height="302" alt="CleanShot 2025-12-18 at 17 08 09@2x"
src="https://github.com/user-attachments/assets/d4d0f783-64f2-4164-9a4d-42c341fa828d"
/>

## After
<img width="836" height="370" alt="CleanShot 2025-12-18 at 17 07 49@2x"
src="https://github.com/user-attachments/assets/aca7a72f-bb83-4e40-89d2-f62bb7c8f053"
/>
This commit is contained in:
Raphaël Bosi
2025-12-19 14:31:57 +01:00
committed by GitHub
parent 3dc759bd91
commit 088cd3025a
7 changed files with 69 additions and 330 deletions
@@ -13,11 +13,13 @@ import { MenuItem } from 'twenty-ui/navigation';
const StyledNavigationIcon = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.secondary};
cursor: pointer;
display: flex;
justify-content: center;
margin-right: ${({ theme }) => theme.spacing(1)};
`;
const StyledIconChevronLeft = styled(IconChevronLeft)`
color: ${({ theme }) => theme.font.color.secondary};
`;
export const CommandMenuBackButton = () => {
@@ -50,7 +52,7 @@ export const CommandMenuBackButton = () => {
clickableComponent={
<StyledNavigationIcon onContextMenu={handleBackButtonContextMenu}>
<IconButton
Icon={IconChevronLeft}
Icon={StyledIconChevronLeft}
size="small"
variant="tertiary"
onClick={goBackFromCommandMenu}
@@ -1,148 +0,0 @@
import { SidePanelHeaderTitleSyncEffect } from '@/command-menu/components/SidePanelHeaderSyncEffect';
import { useUpdateCommandMenuPageInfo } from '@/command-menu/hooks/useUpdateCommandMenuPageInfo';
import { commandMenuShouldFocusTitleInputComponentState } from '@/command-menu/states/commandMenuShouldFocusTitleInputComponentState';
import { TitleInput } from '@/ui/input/components/TitleInput';
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useState } from 'react';
import { AppTooltip, type IconComponent } from 'twenty-ui/display';
const StyledHeader = styled.div`
background-color: ${({ theme }) => theme.background.secondary};
border-bottom: 1px solid ${({ theme }) => theme.border.color.medium};
display: flex;
flex-direction: row;
padding: ${({ theme }) => theme.spacing(4)};
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledHeaderInfo = styled.div`
display: flex;
flex-direction: column;
width: 100%;
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledHeaderTitle = styled.div`
color: ${({ theme }) => theme.font.color.primary};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
font-size: ${({ theme }) => theme.font.size.xl};
width: fit-content;
max-width: 420px;
& > input:disabled {
color: ${({ theme }) => theme.font.color.primary};
}
`;
const StyledHeaderType = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
padding-left: ${({ theme }) => theme.spacing(1)};
`;
const StyledHeaderIconContainer = styled.div`
align-self: flex-start;
display: flex;
justify-content: center;
align-items: center;
background-color: ${({ theme }) => theme.background.transparent.light};
border-radius: ${({ theme }) => theme.border.radius.sm};
padding: ${({ theme }) => theme.spacing(2)};
`;
type SidePanelHeaderProps = {
Icon: IconComponent;
iconColor: string;
initialTitle: string;
headerType: string;
iconTooltip?: string;
} & (
| {
disabled: true;
onTitleChange?: never;
}
| {
disabled?: boolean;
onTitleChange: (newTitle: string) => void;
}
);
export const SidePanelHeader = ({
Icon,
iconColor,
initialTitle,
headerType,
disabled,
onTitleChange,
iconTooltip,
}: SidePanelHeaderProps) => {
const [shouldFocusTitleInput, setShouldFocusTitleInput] =
useRecoilComponentState(commandMenuShouldFocusTitleInputComponentState);
const theme = useTheme();
const [title, setTitle] = useState(initialTitle);
const { updateCommandMenuPageInfo } = useUpdateCommandMenuPageInfo();
const handleChange = (newTitle: string) => {
setTitle(newTitle);
};
const saveTitle = () => {
onTitleChange?.(title);
updateCommandMenuPageInfo({
pageTitle: title,
pageIcon: Icon,
});
};
const tooltipId = `side-panel-icon-tooltip-${headerType.replace(/\s+/g, '-')}`;
return (
<>
<SidePanelHeaderTitleSyncEffect
initialTitle={initialTitle}
setTitle={setTitle}
/>
<StyledHeader data-testid="side-panel-header">
<StyledHeaderIconContainer id={tooltipId}>
<Icon
color={iconColor}
stroke={theme.icon.stroke.sm}
size={theme.icon.size.lg}
/>
</StyledHeaderIconContainer>
{iconTooltip && (
<AppTooltip
anchorSelect={`#${tooltipId}`}
content={iconTooltip}
place="top"
/>
)}
<StyledHeaderInfo>
<StyledHeaderTitle>
<TitleInput
instanceId="side-panel-title-input"
disabled={disabled}
sizeVariant="md"
value={title}
onChange={handleChange}
placeholder={headerType}
onEnter={saveTitle}
onEscape={() => {
setTitle(initialTitle);
}}
onClickOutside={saveTitle}
onTab={saveTitle}
onShiftTab={saveTitle}
shouldOpen={shouldFocusTitleInput}
onOpen={() => setShouldFocusTitleInput(false)}
/>
</StyledHeaderTitle>
<StyledHeaderType>{headerType}</StyledHeaderType>
</StyledHeaderInfo>
</StyledHeader>
</>
);
};
@@ -1,17 +0,0 @@
import { useEffect } from 'react';
type SidePanelHeaderTitleSyncEffectProps = {
initialTitle: string;
setTitle: (title: string) => void;
};
export const SidePanelHeaderTitleSyncEffect = ({
initialTitle,
setTitle,
}: SidePanelHeaderTitleSyncEffectProps) => {
useEffect(() => {
setTitle(initialTitle);
}, [initialTitle, setTitle]);
return null;
};
@@ -1,101 +0,0 @@
import { type Meta, type StoryObj } from '@storybook/react';
import { expect, fn, userEvent, waitFor, within } from '@storybook/test';
import { IconPlus } from 'twenty-ui/display';
import { ComponentDecorator } from 'twenty-ui/testing';
import { THEME_LIGHT } from 'twenty-ui/theme';
import { CommandMenuPageComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuPageComponentInstanceContext';
import { SidePanelHeader } from '../SidePanelHeader';
const meta: Meta<typeof SidePanelHeader> = {
title: 'Modules/CommandMenu/SidePanelHeader',
component: SidePanelHeader,
args: {
onTitleChange: fn(),
},
argTypes: {},
decorators: [
ComponentDecorator,
(Story) => (
<CommandMenuPageComponentInstanceContext.Provider
value={{ instanceId: 'side-panel-header-story-instance' }}
>
<Story />
</CommandMenuPageComponentInstanceContext.Provider>
),
],
parameters: {
disableHotkeyInitialization: true,
},
};
export default meta;
type Story = StoryObj<typeof SidePanelHeader>;
export const Default: Story = {
args: {
headerType: 'Action',
iconColor: THEME_LIGHT.font.color.tertiary,
initialTitle: 'Create Record',
Icon: IconPlus,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText('Create Record')).toBeVisible();
expect(await canvas.findByText('Action')).toBeVisible();
},
};
export const EditableTitle: Story = {
args: {
headerType: 'Action',
iconColor: THEME_LIGHT.font.color.tertiary,
initialTitle: 'Create Record',
Icon: IconPlus,
onTitleChange: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const titleText = await canvas.findByText('Create Record');
await userEvent.click(titleText);
const titleInput = await canvas.findByDisplayValue('Create Record');
const NEW_TITLE = 'New Title';
await userEvent.clear(titleInput);
await userEvent.type(titleInput, NEW_TITLE);
await userEvent.keyboard('{Enter}');
await waitFor(() => {
expect(args.onTitleChange).toHaveBeenCalledWith(NEW_TITLE);
});
},
};
export const Disabled: Story = {
args: {
headerType: 'Action',
iconColor: THEME_LIGHT.font.color.tertiary,
initialTitle: 'Create Record',
Icon: IconPlus,
disabled: true,
onTitleChange: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const titleText = await canvas.findByText('Create Record');
expect(window.getComputedStyle(titleText).cursor).toBe('default');
await userEvent.click(titleText);
const titleInput = canvas.queryByDisplayValue('Create Record');
expect(titleInput).not.toBeInTheDocument();
expect(args.onTitleChange).not.toHaveBeenCalled();
},
};
@@ -1,39 +0,0 @@
import styled from '@emotion/styled';
import { IconChevronLeft } from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
const StyledContainer = styled.div`
align-items: center;
display: flex;
flex-direction: row;
`;
const StyledTextContainer = styled.div`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.md};
padding-left: ${({ theme }) => theme.spacing(1)};
padding-bottom: ${({ theme }) => theme.spacing(0.2)};
`;
type CommandMenuSubPageNavigationHeaderProps = {
title: string;
onBackClick: () => void;
};
export const CommandMenuSubPageNavigationHeader = ({
onBackClick,
title,
}: CommandMenuSubPageNavigationHeaderProps) => {
return (
<StyledContainer>
<IconButton
onClick={onBackClick}
Icon={IconChevronLeft}
variant="tertiary"
/>
<StyledTextContainer>{title}</StyledTextContainer>
</StyledContainer>
);
};
@@ -0,0 +1,40 @@
import styled from '@emotion/styled';
import { IconChevronLeft } from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
const StyledContainer = styled.div`
align-items: center;
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
padding: 0 ${({ theme }) => theme.spacing(2)};
height: 40px;
`;
const StyledText = styled.span`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.medium};
`;
type SidePanelSubPageNavigationHeaderProps = {
title: string;
onBackClick: () => void;
};
export const SidePanelSubPageNavigationHeader = ({
onBackClick,
title,
}: SidePanelSubPageNavigationHeaderProps) => {
return (
<StyledContainer>
<IconButton
onClick={onBackClick}
Icon={IconChevronLeft}
variant="tertiary"
size="small"
/>
<StyledText>{title}</StyledText>
</StyledContainer>
);
};
@@ -1,6 +1,6 @@
import { CommandMenuSubPageNavigationHeader } from '@/command-menu/pages/common/components/CommandMenuSubPageNavigationHeader';
import { ChartFiltersSettingsInitializeStateEffect } from '@/command-menu/pages/page-layout/components/ChartFiltersSettingsInitializeStateEffect';
import { useCommandMenuHistory } from '@/command-menu/hooks/useCommandMenuHistory';
import { SidePanelSubPageNavigationHeader } from '@/command-menu/pages/common/components/SidePanelSubPageNavigationHeader';
import { ChartFiltersSettingsInitializeStateEffect } from '@/command-menu/pages/page-layout/components/ChartFiltersSettingsInitializeStateEffect';
import { usePageLayoutIdFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutFromContextStoreTargetedRecord';
import { useUpdateCurrentWidgetConfig } from '@/command-menu/pages/page-layout/hooks/useUpdateCurrentWidgetConfig';
import { type ChartWidget } from '@/command-menu/pages/page-layout/types/ChartWidget';
@@ -95,30 +95,32 @@ export const ChartFiltersSettings = ({
);
return (
<StyledChartFiltersPageContainer>
<CommandMenuSubPageNavigationHeader
<>
<SidePanelSubPageNavigationHeader
title={t`Filter`}
onBackClick={goBackFromCommandMenu}
/>
<div>
<InputLabel>{t`Conditions`}</InputLabel>
<RecordFilterGroupsComponentInstanceContext.Provider
value={{ instanceId }}
>
<RecordFiltersComponentInstanceContext.Provider
<StyledChartFiltersPageContainer>
<div>
<InputLabel>{t`Conditions`}</InputLabel>
<RecordFilterGroupsComponentInstanceContext.Provider
value={{ instanceId }}
>
<AdvancedFilterCommandMenuContainer
onUpdate={handleFiltersUpdate}
objectMetadataItem={objectMetadataItem}
isWorkflowFindRecords={false}
/>
<ChartFiltersSettingsInitializeStateEffect
initialChartFilters={chartWidgetConfiguration.filter}
/>
</RecordFiltersComponentInstanceContext.Provider>
</RecordFilterGroupsComponentInstanceContext.Provider>
</div>
</StyledChartFiltersPageContainer>
<RecordFiltersComponentInstanceContext.Provider
value={{ instanceId }}
>
<AdvancedFilterCommandMenuContainer
onUpdate={handleFiltersUpdate}
objectMetadataItem={objectMetadataItem}
isWorkflowFindRecords={false}
/>
<ChartFiltersSettingsInitializeStateEffect
initialChartFilters={chartWidgetConfiguration.filter}
/>
</RecordFiltersComponentInstanceContext.Provider>
</RecordFilterGroupsComponentInstanceContext.Provider>
</div>
</StyledChartFiltersPageContainer>
</>
);
};