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:
@@ -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>
|
||||
);
|
||||
};
|
||||
+91
@@ -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();
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user