Chart editor - Part 1 (#14820)

This PR is the first part of the creation of the Chart editor.


https://github.com/user-attachments/assets/8b0af8ea-be41-4506-84cb-e40f5521cbf0

Done:
- Bar chart settings (except filters)
- Line chart settings (except filters)

In progress:
- Pie chart settings
- Number chart settings
- Gauge chart settings

Left to do:
- Loosen the backend validation to allow the user to save a partial
configuration, validate the graph configuration in the frontend and
display a error friendly message in the graph if the config is not
completed yet
- Implement the filter edition
- Finish the other graph types settings
This commit is contained in:
Raphaël Bosi
2025-10-03 10:25:45 +02:00
committed by GitHub
parent fc8b4f813c
commit d10ab5f67c
112 changed files with 3046 additions and 503 deletions
@@ -16,17 +16,25 @@ export type CommandMenuItemProps = {
Icon?: IconComponent;
hotKeys?: string[];
RightComponent?: ReactNode;
contextualTextPosition?: 'left' | 'right';
hasSubMenu?: boolean;
isSubMenuOpened?: boolean;
disabled?: boolean;
};
export const CommandMenuItem = ({
label,
description,
contextualTextPosition = 'left',
to,
id,
onClick,
Icon,
hotKeys,
RightComponent,
hasSubMenu = false,
isSubMenuOpened = false,
disabled = false,
}: CommandMenuItemProps) => {
const { onItemClick } = useCommandMenuOnItemClick();
@@ -45,15 +53,22 @@ export const CommandMenuItem = ({
LeftIcon={Icon}
text={label}
contextualText={description}
contextualTextPosition={contextualTextPosition}
hotKeys={hotKeys}
onClick={() =>
onItemClick({
onClick,
to,
})
onClick={
onClick || to
? () =>
onItemClick({
onClick,
to,
})
: undefined
}
focused={isSelectedItemId}
RightComponent={RightComponent}
hasSubMenu={hasSubMenu}
isSubMenuOpened={isSubMenuOpened}
disabled={disabled}
/>
);
};
@@ -0,0 +1,60 @@
import {
CommandMenuItem,
type CommandMenuItemProps,
} from '@/command-menu/components/CommandMenuItem';
import {
Dropdown,
type DropdownProps,
} from '@/ui/layout/dropdown/components/Dropdown';
import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
export type CommandMenuItemDropdownProps = CommandMenuItemProps &
Pick<
DropdownProps,
'dropdownPlacement' | 'dropdownOffset' | 'dropdownId' | 'dropdownComponents'
>;
export const CommandMenuItemDropdown = ({
id,
label,
Icon,
hotKeys,
RightComponent,
description,
contextualTextPosition,
dropdownComponents,
dropdownPlacement,
dropdownOffset,
dropdownId,
disabled = false,
}: CommandMenuItemDropdownProps) => {
const isDropdownOpen = useRecoilComponentValue(
isDropdownOpenComponentState,
dropdownId,
);
return (
<Dropdown
clickableComponent={
<CommandMenuItem
id={id}
label={label}
description={description}
contextualTextPosition={contextualTextPosition}
Icon={Icon}
hotKeys={hotKeys}
RightComponent={RightComponent}
hasSubMenu
isSubMenuOpened={isDropdownOpen}
disabled={disabled}
/>
}
dropdownComponents={dropdownComponents}
dropdownId={dropdownId}
dropdownPlacement={dropdownPlacement}
dropdownOffset={dropdownOffset}
disableClickForClickableComponent={disabled}
/>
);
};
@@ -0,0 +1,23 @@
import { isSelectedItemIdComponentFamilySelector } from '@/ui/layout/selectable-list/states/selectors/isSelectedItemIdComponentFamilySelector';
import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue';
import { MenuItemToggle, type MenuItemToggleProps } from 'twenty-ui/navigation';
export type CommandMenuItemToggleProps = MenuItemToggleProps & {
id: string;
};
export const CommandMenuItemToggle = (props: CommandMenuItemToggleProps) => {
const isSelectedItemId = useRecoilComponentFamilyValue(
isSelectedItemIdComponentFamilySelector,
props.id,
);
return (
<MenuItemToggle
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
focused={isSelectedItemId}
withIconContainer
/>
);
};
@@ -30,7 +30,7 @@ const StyledInnerList = styled.div`
);
padding-left: ${({ theme }) => theme.spacing(2)};
padding-right: ${({ theme }) => theme.spacing(2)};
padding-top: ${({ theme }) => theme.spacing(1)};
padding-top: ${({ theme }) => theme.spacing(2)};
width: calc(100% - ${({ theme }) => theme.spacing(4)});
@media (min-width: ${MOBILE_VIEWPORT}px) {
@@ -0,0 +1,123 @@
import { useUpdateCommandMenuPageInfo } from '@/command-menu/hooks/useUpdateCommandMenuPageInfo';
import { TitleInput } from '@/ui/input/components/TitleInput';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useState } from 'react';
import { 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;
} & (
| {
disabled: true;
onTitleChange?: never;
}
| {
disabled?: boolean;
onTitleChange: (newTitle: string) => void;
}
);
export const SidePanelHeader = ({
Icon,
iconColor,
initialTitle,
headerType,
disabled,
onTitleChange,
}: SidePanelHeaderProps) => {
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,
});
};
return (
<StyledHeader data-testid="side-panel-header">
<StyledHeaderIconContainer>
<Icon
color={iconColor}
stroke={theme.icon.stroke.sm}
size={theme.icon.size.lg}
/>
</StyledHeaderIconContainer>
<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}
/>
</StyledHeaderTitle>
<StyledHeaderType>{headerType}</StyledHeaderType>
</StyledHeaderInfo>
</StyledHeader>
);
};
@@ -0,0 +1,91 @@
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 { SidePanelHeader } from '../SidePanelHeader';
const meta: Meta<typeof SidePanelHeader> = {
title: 'Modules/CommandMenu/SidePanelHeader',
component: SidePanelHeader,
args: {
onTitleChange: fn(),
},
argTypes: {},
decorators: [ComponentDecorator],
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();
},
};