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();
},
};
@@ -12,7 +12,10 @@ import { hasUserSelectedCommandState } from '@/command-menu/states/hasUserSelect
import { isCommandMenuClosingState } from '@/command-menu/states/isCommandMenuClosingState';
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { viewableRecordIdState } from '@/object-record/record-right-drawer/states/viewableRecordIdState';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { emitSidePanelCloseEvent } from '@/ui/layout/right-drawer/utils/emitSidePanelCloseEvent';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
@@ -21,6 +24,7 @@ import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTab
import { WORKFLOW_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID } from '@/workflow/workflow-steps/workflow-actions/code-action/constants/WorkflowServerlessFunctionTabListComponentId';
import { WorkflowServerlessFunctionTabId } from '@/workflow/workflow-steps/workflow-actions/code-action/types/WorkflowServerlessFunctionTabId';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
export const useCommandMenuCloseAnimationCompleteCleanup = () => {
const { resetSelectedItem } = useSelectableList('command-menu-list');
@@ -37,6 +41,44 @@ export const useCommandMenuCloseAnimationCompleteCleanup = () => {
resetContextStoreStates(COMMAND_MENU_COMPONENT_INSTANCE_ID);
resetContextStoreStates(COMMAND_MENU_PREVIOUS_COMPONENT_INSTANCE_ID);
const currentPage = snapshot
.getLoadable(commandMenuPageState)
.getValue();
const isPageLayoutEditingPage =
currentPage === CommandMenuPages.PageLayoutWidgetTypeSelect ||
currentPage === CommandMenuPages.PageLayoutGraphTypeSelect ||
currentPage === CommandMenuPages.PageLayoutIframeConfig;
if (isPageLayoutEditingPage) {
const targetedRecordsRule = snapshot
.getLoadable(
contextStoreTargetedRecordsRuleComponentState.atomFamily({
instanceId: COMMAND_MENU_COMPONENT_INSTANCE_ID,
}),
)
.getValue();
if (
targetedRecordsRule.mode === 'selection' &&
targetedRecordsRule.selectedRecordIds.length === 1
) {
const recordId = targetedRecordsRule.selectedRecordIds[0];
const record = snapshot
.getLoadable(recordStoreFamilyState(recordId))
.getValue();
if (isDefined(record) && isDefined(record.pageLayoutId)) {
set(
pageLayoutEditingWidgetIdComponentState.atomFamily({
instanceId: record.pageLayoutId,
}),
null,
);
}
}
}
set(viewableRecordIdState, null);
set(commandMenuPageState, CommandMenuPages.Root);
set(commandMenuPageInfoState, {
@@ -0,0 +1,152 @@
import { CommandGroup } from '@/command-menu/components/CommandGroup';
import { CommandMenuItemDropdown } from '@/command-menu/components/CommandMenuItemDropdown';
import { CommandMenuItemToggle } from '@/command-menu/components/CommandMenuItemToggle';
import { CommandMenuList } from '@/command-menu/components/CommandMenuList';
import { useUpdateCommandMenuPageInfo } from '@/command-menu/hooks/useUpdateCommandMenuPageInfo';
import { ChartTypeSelectionSection } from '@/command-menu/pages/page-layout/components/ChartTypeSelectionSection';
import { GRAPH_TYPE_INFORMATION } from '@/command-menu/pages/page-layout/constants/GraphTypeInformation';
import { GRAPH_TYPE_TO_CONFIG_TYPENAME } from '@/command-menu/pages/page-layout/constants/GraphTypeToConfigTypename';
import { useChartSettingsValues } from '@/command-menu/pages/page-layout/hooks/useChartSettingsValues';
import { usePageLayoutIdFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutFromContextStoreTargetedRecord';
import { useUpdateCurrentWidgetConfig } from '@/command-menu/pages/page-layout/hooks/useUpdateCurrentWidgetConfig';
import { type ChartConfiguration } from '@/command-menu/pages/page-layout/types/ChartConfiguration';
import {
CHART_CONFIGURATION_SETTING_IDS,
CHART_CONFIGURATION_SETTING_TO_CONFIG_KEY_MAP,
} from '@/command-menu/pages/page-layout/types/ChartConfigurationSettingIds';
import { getChartSettingsDropdownContent } from '@/command-menu/pages/page-layout/utils/getChartSettingsDropdownContent';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useOpenDropdown } from '@/ui/layout/dropdown/hooks/useOpenDropdown';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { t } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { type GraphType, type PageLayoutWidget } from '~/generated/graphql';
export const ChartSettings = ({ widget }: { widget: PageLayoutWidget }) => {
const { updateCommandMenuPageInfo } = useUpdateCommandMenuPageInfo();
const { pageLayoutId } = usePageLayoutIdFromContextStoreTargetedRecord();
const { updateCurrentWidgetConfig } =
useUpdateCurrentWidgetConfig(pageLayoutId);
const { openDropdown } = useOpenDropdown();
if (widget.configuration?.__typename === 'IframeConfiguration') {
throw new Error(t`IframeConfiguration is not supported`);
}
const configuration = widget.configuration as ChartConfiguration;
const { getChartSettingsValues } = useChartSettingsValues({
objectMetadataId: widget.objectMetadataId,
configuration,
});
const currentGraphType = configuration?.graphType;
const handleGraphTypeChange = (graphType: GraphType) => {
updateCurrentWidgetConfig({
configToUpdate: {
__typename: GRAPH_TYPE_TO_CONFIG_TYPENAME[graphType],
graphType,
},
});
updateCommandMenuPageInfo({
pageIcon: GRAPH_TYPE_INFORMATION[graphType].icon,
});
};
const chartSettings = GRAPH_TYPE_INFORMATION[currentGraphType].settings;
return (
<CommandMenuList
commandGroups={[]}
selectableItemIds={[
...chartSettings.flatMap((group) => group.items.map((item) => item.id)),
]}
>
<ChartTypeSelectionSection
currentGraphType={currentGraphType}
setCurrentGraphType={handleGraphTypeChange}
/>
{chartSettings.map((group) => (
<CommandGroup key={group.heading} heading={group.heading}>
{group.items.map((item) => {
const isDisabled =
!isNonEmptyString(widget.objectMetadataId) &&
(item?.dependsOn?.includes(
CHART_CONFIGURATION_SETTING_IDS.SOURCE,
) ??
false);
const handleToggleChange = () => {
const configKey =
item.id === CHART_CONFIGURATION_SETTING_IDS.DATA_LABELS
? CHART_CONFIGURATION_SETTING_TO_CONFIG_KEY_MAP[
CHART_CONFIGURATION_SETTING_IDS.DATA_LABELS
]
: item.id;
updateCurrentWidgetConfig({
configToUpdate: {
[configKey]: !getChartSettingsValues(item.id),
},
});
};
const handleDropdownOpen = () => {
openDropdown({
dropdownComponentInstanceIdFromProps: item.id,
});
};
return item.isBoolean ? (
<SelectableListItem
key={item.id}
itemId={item.id}
onEnter={isDisabled ? undefined : handleToggleChange}
>
<CommandMenuItemToggle
LeftIcon={item.Icon}
text={t(item.label)}
id={item.id}
toggled={getChartSettingsValues(item.id) as boolean}
onToggleChange={handleToggleChange}
/>
</SelectableListItem>
) : (
<SelectableListItem
key={item.id}
itemId={item.id}
onEnter={isDisabled ? undefined : handleDropdownOpen}
>
<CommandMenuItemDropdown
Icon={item.Icon}
label={t(item.label)}
id={item.id}
dropdownId={item.id}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
{getChartSettingsDropdownContent(item.id)}
</DropdownMenuItemsContainer>
</DropdownContent>
}
dropdownPlacement="bottom-end"
description={getChartSettingsValues(item.id) as string}
contextualTextPosition={'right'}
hasSubMenu
disabled={isDisabled}
/>
</SelectableListItem>
);
})}
</CommandGroup>
))}
</CommandMenuList>
);
};
@@ -0,0 +1,47 @@
import { GRAPH_TYPE_INFORMATION } from '@/command-menu/pages/page-layout/constants/GraphTypeInformation';
import styled from '@emotion/styled';
import { GraphType } from '~/generated-metadata/graphql';
import { t } from '@lingui/core/macro';
import { MenuPicker } from 'twenty-ui/navigation';
const graphTypeOptions = [
GraphType.BAR,
GraphType.PIE,
GraphType.LINE,
GraphType.NUMBER,
GraphType.GAUGE,
];
const StyledChartTypeSelectionContainer = styled.div`
display: flex;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(2)};
`;
type ChartTypeSelectionSectionProps = {
currentGraphType: GraphType;
setCurrentGraphType: (graphType: GraphType) => void;
};
export const ChartTypeSelectionSection = ({
currentGraphType,
setCurrentGraphType,
}: ChartTypeSelectionSectionProps) => {
return (
<StyledChartTypeSelectionContainer>
{graphTypeOptions.map((graphType) => (
<MenuPicker
selected={currentGraphType === graphType}
key={graphType}
icon={GRAPH_TYPE_INFORMATION[graphType].icon}
onClick={() => {
setCurrentGraphType(graphType);
}}
label={t(GRAPH_TYPE_INFORMATION[graphType].label)}
showLabel={false}
/>
))}
</StyledChartTypeSelectionContainer>
);
};
@@ -1,87 +1,53 @@
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { SidePanelHeader } from '@/command-menu/components/SidePanelHeader';
import { ChartSettings } from '@/command-menu/pages/page-layout/components/ChartSettings';
import { GRAPH_TYPE_INFORMATION } from '@/command-menu/pages/page-layout/constants/GraphTypeInformation';
import { usePageLayoutIdFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutFromContextStoreTargetedRecord';
import { useCreatePageLayoutGraphWidget } from '@/page-layout/hooks/useCreatePageLayoutGraphWidget';
import { GraphType, WidgetType } from '~/generated-metadata/graphql';
import styled from '@emotion/styled';
import {
IconChartBar,
IconChartLine,
IconChartPie,
IconGauge,
IconNumber,
} from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
padding: ${({ theme }) => theme.spacing(1)} ${({ theme }) => theme.spacing(2)};
`;
const StyledSectionTitle = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
padding-top: ${({ theme }) => theme.spacing(2)};
padding-bottom: ${({ theme }) => theme.spacing(1)};
padding-left: ${({ theme }) => theme.spacing(1)};
`;
const graphTypeOptions = [
{
type: GraphType.BAR,
icon: IconChartBar,
title: 'Bar Chart',
},
{
type: GraphType.PIE,
icon: IconChartPie,
title: 'Pie Chart',
},
{
type: GraphType.GAUGE,
icon: IconGauge,
title: 'Gauge',
},
{
type: GraphType.NUMBER,
icon: IconNumber,
title: 'Number',
},
{
type: GraphType.LINE,
icon: IconChartLine,
title: 'Line Chart',
},
];
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useTheme } from '@emotion/react';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
export const CommandMenuPageLayoutGraphTypeSelect = () => {
const { closeCommandMenu } = useCommandMenu();
const { pageLayoutId } = usePageLayoutIdFromContextStoreTargetedRecord();
const { createPageLayoutWidget } =
useCreatePageLayoutGraphWidget(pageLayoutId);
const draftPageLayout = useRecoilComponentValue(
pageLayoutDraftComponentState,
pageLayoutId,
);
const handleSelectGraphType = (graphType: GraphType) => {
createPageLayoutWidget(WidgetType.GRAPH, graphType);
closeCommandMenu();
};
const pageLayoutEditingWidgetId = useRecoilComponentValue(
pageLayoutEditingWidgetIdComponentState,
pageLayoutId,
);
const widgetInEditMode = draftPageLayout.tabs
.flatMap((tab) => tab.widgets)
.find((widget) => widget.id === pageLayoutEditingWidgetId);
const theme = useTheme();
if (
!isDefined(widgetInEditMode?.configuration) ||
!('graphType' in widgetInEditMode.configuration)
) {
return null;
}
const currentGraphType = widgetInEditMode.configuration.graphType;
return (
<StyledContainer>
<StyledSectionTitle>Graph type</StyledSectionTitle>
<>
<SidePanelHeader
Icon={GRAPH_TYPE_INFORMATION[currentGraphType].icon}
iconColor={theme.font.color.tertiary}
initialTitle={t`Chart`}
headerType={t(GRAPH_TYPE_INFORMATION[currentGraphType].label)}
onTitleChange={() => {}}
/>
{graphTypeOptions.map((option) => (
<MenuItem
withIconContainer={true}
key={option.type}
LeftIcon={option.icon}
text={option.title}
onClick={() => handleSelectGraphType(option.type)}
/>
))}
</StyledContainer>
<ChartSettings widget={widgetInEditMode} />
</>
);
};
@@ -8,6 +8,7 @@ import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pa
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { isString } from '@sniptt/guards';
import { useState } from 'react';
import { isValidUrl } from 'twenty-shared/utils';
@@ -119,12 +120,12 @@ export const CommandMenuPageLayoutIframeConfig = () => {
return (
<StyledContainer>
<StyledSectionTitle>
{isEditMode ? 'Edit iFrame Widget' : 'Configure iFrame Widget'}
{isEditMode ? t`Edit iFrame Widget` : t`Configure iFrame Widget`}
</StyledSectionTitle>
<FormTextFieldInput
label="Widget Title"
placeholder="e.g., Analytics Dashboard"
label={t`Widget Title`}
placeholder={t`e.g., Analytics Dashboard`}
defaultValue={title}
onChange={setTitle}
/>
@@ -1,109 +1,78 @@
import { CommandGroup } from '@/command-menu/components/CommandGroup';
import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem';
import { CommandMenuList } from '@/command-menu/components/CommandMenuList';
import { useNavigatePageLayoutCommandMenu } from '@/command-menu/pages/page-layout/hooks/useNavigatePageLayoutCommandMenu';
import { usePageLayoutIdFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutFromContextStoreTargetedRecord';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { pageLayoutDraggedAreaComponentState } from '@/page-layout/states/pageLayoutDraggedAreaComponentState';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import styled from '@emotion/styled';
import { IconChartPie, IconFrame, IconList } from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation';
import { WidgetType } from '~/generated-metadata/graphql';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
padding: ${({ theme }) => theme.spacing(1)} ${({ theme }) => theme.spacing(2)};
`;
const StyledSectionTitle = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
padding-top: ${({ theme }) => theme.spacing(2)};
padding-bottom: ${({ theme }) => theme.spacing(1)};
padding-left: ${({ theme }) => theme.spacing(1)};
`;
const StyledDisabledMenuItem = styled.div`
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
`;
const widgetTypeOptions = [
{
type: WidgetType.GRAPH,
icon: IconChartPie,
title: 'Add a graph',
disabled: false,
},
{
type: WidgetType.VIEW,
icon: IconList,
title: 'Add a view',
disabled: true,
},
{
type: WidgetType.IFRAME,
icon: IconFrame,
title: 'Add an iframe',
disabled: false,
},
];
import { useCreatePageLayoutGraphWidget } from '@/page-layout/hooks/useCreatePageLayoutGraphWidget';
import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { IconChartPie, IconFrame } from 'twenty-ui/display';
import { GraphType } from '~/generated-metadata/graphql';
export const CommandMenuPageLayoutWidgetTypeSelect = () => {
const { pageLayoutId } = usePageLayoutIdFromContextStoreTargetedRecord();
const setPageLayoutDraggedArea = useSetRecoilComponentState(
pageLayoutDraggedAreaComponentState,
pageLayoutId,
);
const { navigatePageLayoutCommandMenu } = useNavigatePageLayoutCommandMenu();
const handleSelectWidget = (widgetType: WidgetType) => {
switch (widgetType) {
case WidgetType.GRAPH: {
navigatePageLayoutCommandMenu({
commandMenuPage: CommandMenuPages.PageLayoutGraphTypeSelect,
});
const { createPageLayoutGraphWidget } =
useCreatePageLayoutGraphWidget(pageLayoutId);
break;
}
case WidgetType.IFRAME: {
navigatePageLayoutCommandMenu({
commandMenuPage: CommandMenuPages.PageLayoutIframeConfig,
});
const [pageLayoutEditingWidgetId, setPageLayoutEditingWidgetId] =
useRecoilComponentState(
pageLayoutEditingWidgetIdComponentState,
pageLayoutId,
);
break;
}
default:
setPageLayoutDraggedArea(null);
break;
const handleNavigateToGraphTypeSelect = () => {
if (!isDefined(pageLayoutEditingWidgetId)) {
const newWidget = createPageLayoutGraphWidget(GraphType.BAR);
setPageLayoutEditingWidgetId(newWidget.id);
}
navigatePageLayoutCommandMenu({
commandMenuPage: CommandMenuPages.PageLayoutGraphTypeSelect,
});
};
const handleNavigateToIframeConfig = () => {
navigatePageLayoutCommandMenu({
commandMenuPage: CommandMenuPages.PageLayoutIframeConfig,
});
};
return (
<StyledContainer>
<StyledSectionTitle>Widget type</StyledSectionTitle>
{widgetTypeOptions.map((option) => {
const MenuItemComponent = (
<MenuItem
withIconContainer={true}
key={option.type}
LeftIcon={option.icon}
text={option.title}
onClick={() => handleSelectWidget(option.type)}
<CommandMenuList commandGroups={[]} selectableItemIds={['chart', 'iframe']}>
<CommandGroup heading={t`Widget type`}>
<SelectableListItem
itemId="chart"
onEnter={handleNavigateToGraphTypeSelect}
>
<CommandMenuItem
Icon={IconChartPie}
label={t`Chart`}
id="chart"
onClick={handleNavigateToGraphTypeSelect}
/>
);
return option.disabled ? (
<StyledDisabledMenuItem key={option.type}>
{MenuItemComponent}
</StyledDisabledMenuItem>
) : (
MenuItemComponent
);
})}
</StyledContainer>
</SelectableListItem>
<SelectableListItem
itemId="iframe"
onEnter={() => {
handleNavigateToIframeConfig();
}}
>
<CommandMenuItem
Icon={IconFrame}
label="iFrame"
id="iframe"
onClick={handleNavigateToIframeConfig}
/>
</SelectableListItem>
</CommandGroup>
</CommandMenuList>
);
};
@@ -0,0 +1,162 @@
import { usePageLayoutIdFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutFromContextStoreTargetedRecord';
import { useUpdateCurrentWidgetConfig } from '@/command-menu/pages/page-layout/hooks/useUpdateCurrentWidgetConfig';
import { useWidgetInEditMode } from '@/command-menu/pages/page-layout/hooks/useWidgetInEditMode';
import { mapToGraphQLExtendedAggregateOperation } from '@/command-menu/pages/page-layout/utils/mapToGraphQLExtendedAggregateOperation';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
import { getAvailableAggregateOperationsForFieldMetadataType } from '@/object-record/record-table/record-table-footer/utils/getAvailableAggregateOperationsForFieldMetadataType';
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { DropdownComponentInstanceContext } from '@/ui/layout/dropdown/contexts/DropdownComponentInstanceContext';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { t } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { IconChevronLeft } from 'twenty-ui/display';
import { MenuItemSelect } from 'twenty-ui/navigation';
import { filterBySearchQuery } from '~/utils/filterBySearchQuery';
export const ChartAggregateOperationSelectionDropdownContent = ({
currentFieldMetadataId,
setIsSubMenuOpen,
}: {
currentFieldMetadataId: string;
setIsSubMenuOpen: (isSubMenuOpen: boolean) => void;
}) => {
const [searchQuery, setSearchQuery] = useState('');
const { objectMetadataItems } = useObjectMetadataItems();
const { pageLayoutId } = usePageLayoutIdFromContextStoreTargetedRecord();
const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId);
if (
widgetInEditMode?.configuration?.__typename !== 'BarChartConfiguration' &&
widgetInEditMode?.configuration?.__typename !== 'LineChartConfiguration'
) {
throw new Error('Invalid configuration type');
}
const currentAggregateOperation =
widgetInEditMode.configuration.aggregateOperation;
const sourceObjectMetadataItem = objectMetadataItems.find(
(item) => item.id === widgetInEditMode.objectMetadataId,
);
const selectedField = sourceObjectMetadataItem?.fields.find(
(field) => field.id === currentFieldMetadataId,
);
const dropdownId = useAvailableComponentInstanceIdOrThrow(
DropdownComponentInstanceContext,
);
const selectedItemId = useRecoilComponentValue(
selectedItemIdComponentState,
dropdownId,
);
const availableAggregateOperations = selectedField
? getAvailableAggregateOperationsForFieldMetadataType({
fieldMetadataType: selectedField.type,
})
: [];
const aggregateOperationsWithLabels = availableAggregateOperations.map(
(operation) => ({
operation,
label: getAggregateOperationLabel(operation),
}),
);
const filteredAggregateOperationsWithLabels = filterBySearchQuery({
items: aggregateOperationsWithLabels,
searchQuery,
getSearchableValues: (item) => [item.label],
});
const { updateCurrentWidgetConfig } =
useUpdateCurrentWidgetConfig(pageLayoutId);
const { closeDropdown } = useCloseDropdown();
if (!isDefined(sourceObjectMetadataItem) || !isDefined(selectedField)) {
return null;
}
const handleSelectAggregateOperation = (
aggregateOperation: ExtendedAggregateOperations,
) => {
updateCurrentWidgetConfig({
configToUpdate: {
aggregateFieldMetadataId: currentFieldMetadataId,
aggregateOperation:
mapToGraphQLExtendedAggregateOperation(aggregateOperation),
},
});
closeDropdown();
};
return (
<>
<DropdownMenuHeader
StartComponent={
<DropdownMenuHeaderLeftComponent
onClick={() => setIsSubMenuOpen(false)}
Icon={IconChevronLeft}
/>
}
>
<Trans>Y-Axis Aggregate Operation</Trans>
</DropdownMenuHeader>
<DropdownMenuSearchInput
autoFocus
type="text"
placeholder={t`Search operations`}
onChange={(event) => setSearchQuery(event.target.value)}
value={searchQuery}
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
<SelectableList
selectableListInstanceId={dropdownId}
focusId={dropdownId}
selectableItemIdArray={filteredAggregateOperationsWithLabels.map(
(item) => item.operation,
)}
>
{filteredAggregateOperationsWithLabels.map((item) => (
<SelectableListItem
key={item.operation}
itemId={item.operation}
onEnter={() => {
handleSelectAggregateOperation(item.operation);
}}
>
<MenuItemSelect
text={item.label}
selected={
currentAggregateOperation ===
mapToGraphQLExtendedAggregateOperation(item.operation)
}
focused={selectedItemId === item.operation}
onClick={() => {
handleSelectAggregateOperation(item.operation);
}}
/>
</SelectableListItem>
))}
</SelectableList>
</DropdownMenuItemsContainer>
</>
);
};
@@ -0,0 +1,94 @@
import { usePageLayoutIdFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutFromContextStoreTargetedRecord';
import { useUpdateCurrentWidgetConfig } from '@/command-menu/pages/page-layout/hooks/useUpdateCurrentWidgetConfig';
import { useWidgetInEditMode } from '@/command-menu/pages/page-layout/hooks/useWidgetInEditMode';
import { getChartAxisNameDisplayOptions } from '@/command-menu/pages/page-layout/utils/getChartAxisNameDisplayOptions';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownComponentInstanceContext } from '@/ui/layout/dropdown/contexts/DropdownComponentInstanceContext';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { Trans } from '@lingui/react/macro';
import { MenuItemSelect } from 'twenty-ui/navigation';
import { AxisNameDisplay } from '~/generated/graphql';
export const ChartAxisNameSelectionDropdownContent = () => {
const { pageLayoutId } = usePageLayoutIdFromContextStoreTargetedRecord();
const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId);
if (
widgetInEditMode?.configuration?.__typename !== 'BarChartConfiguration' &&
widgetInEditMode?.configuration?.__typename !== 'LineChartConfiguration'
) {
throw new Error('Invalid configuration type');
}
const currentAxisNameDisplay = widgetInEditMode.configuration.axisNameDisplay;
const dropdownId = useAvailableComponentInstanceIdOrThrow(
DropdownComponentInstanceContext,
);
const selectedItemId = useRecoilComponentValue(
selectedItemIdComponentState,
dropdownId,
);
const axisOptions: AxisNameDisplay[] = [
AxisNameDisplay.NONE,
AxisNameDisplay.X,
AxisNameDisplay.Y,
AxisNameDisplay.BOTH,
];
const { updateCurrentWidgetConfig } =
useUpdateCurrentWidgetConfig(pageLayoutId);
const { closeDropdown } = useCloseDropdown();
const handleSelectAxisNameOption = (axisNameOption: AxisNameDisplay) => {
updateCurrentWidgetConfig({
configToUpdate: {
axisNameDisplay: axisNameOption,
},
});
closeDropdown();
};
return (
<>
<DropdownMenuHeader>
<Trans>Axis Name</Trans>
</DropdownMenuHeader>
<DropdownMenuItemsContainer>
<SelectableList
selectableListInstanceId={dropdownId}
focusId={dropdownId}
selectableItemIdArray={axisOptions}
>
{axisOptions.map((option) => (
<SelectableListItem
key={option}
itemId={option}
onEnter={() => {
handleSelectAxisNameOption(option);
}}
>
<MenuItemSelect
text={getChartAxisNameDisplayOptions(option)}
selected={currentAxisNameDisplay?.toUpperCase() === option}
focused={selectedItemId === option}
onClick={() => {
handleSelectAxisNameOption(option);
}}
/>
</SelectableListItem>
))}
</SelectableList>
</DropdownMenuItemsContainer>
</>
);
};
@@ -0,0 +1,121 @@
import { usePageLayoutIdFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutFromContextStoreTargetedRecord';
import { useUpdateCurrentWidgetConfig } from '@/command-menu/pages/page-layout/hooks/useUpdateCurrentWidgetConfig';
import { useWidgetInEditMode } from '@/command-menu/pages/page-layout/hooks/useWidgetInEditMode';
import { type ChartConfiguration } from '@/command-menu/pages/page-layout/types/ChartConfiguration';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownComponentInstanceContext } from '@/ui/layout/dropdown/contexts/DropdownComponentInstanceContext';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { t } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { useState } from 'react';
import { capitalize, isDefined } from 'twenty-shared/utils';
import { ColorSample } from 'twenty-ui/display';
import { MenuItemSelect } from 'twenty-ui/navigation';
import { MAIN_COLOR_NAMES, type ThemeColor } from 'twenty-ui/theme';
import { filterBySearchQuery } from '~/utils/filterBySearchQuery';
export const ChartColorSelectionDropdownContent = () => {
const [searchQuery, setSearchQuery] = useState('');
const { pageLayoutId } = usePageLayoutIdFromContextStoreTargetedRecord();
const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId);
const dropdownId = useAvailableComponentInstanceIdOrThrow(
DropdownComponentInstanceContext,
);
const selectedItemId = useRecoilComponentValue(
selectedItemIdComponentState,
dropdownId,
);
const { updateCurrentWidgetConfig } =
useUpdateCurrentWidgetConfig(pageLayoutId);
const { closeDropdown } = useCloseDropdown();
if (!isDefined(widgetInEditMode)) {
return null;
}
if (widgetInEditMode.configuration?.__typename === 'IframeConfiguration') {
throw new Error('Invalid configuration type');
}
const configuration = widgetInEditMode.configuration as ChartConfiguration;
const currentColor = configuration.color;
const colorOptions = MAIN_COLOR_NAMES.map((colorName) => ({
id: colorName,
name: capitalize(colorName),
colorName: colorName,
}));
const filteredColorOptions = filterBySearchQuery({
items: colorOptions,
searchQuery,
getSearchableValues: (item) => [item.name],
});
const handleSelectColor = (colorName: ThemeColor) => {
updateCurrentWidgetConfig({
configToUpdate: {
color: colorName,
},
});
closeDropdown();
};
return (
<>
<DropdownMenuHeader>
<Trans>Color</Trans>
</DropdownMenuHeader>
<DropdownMenuSearchInput
autoFocus
type="text"
placeholder={t`Search colors`}
onChange={(event) => setSearchQuery(event.target.value)}
value={searchQuery}
/>
<DropdownMenuItemsContainer>
<SelectableList
selectableListInstanceId={dropdownId}
focusId={dropdownId}
selectableItemIdArray={filteredColorOptions.map(
(colorOption) => colorOption.id,
)}
>
{filteredColorOptions.map((colorOption) => (
<SelectableListItem
key={colorOption.id}
itemId={colorOption.id}
onEnter={() => {
handleSelectColor(colorOption.colorName);
}}
>
<MenuItemSelect
text={colorOption.name}
selected={currentColor === colorOption.id}
focused={selectedItemId === colorOption.id}
LeftIcon={() => (
<ColorSample colorName={colorOption.colorName} />
)}
onClick={() => {
handleSelectColor(colorOption.colorName);
}}
/>
</SelectableListItem>
))}
</SelectableList>
</DropdownMenuItemsContainer>
</>
);
};
@@ -0,0 +1,119 @@
import { usePageLayoutIdFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutFromContextStoreTargetedRecord';
import { useUpdateCurrentWidgetConfig } from '@/command-menu/pages/page-layout/hooks/useUpdateCurrentWidgetConfig';
import { useWidgetInEditMode } from '@/command-menu/pages/page-layout/hooks/useWidgetInEditMode';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { DropdownComponentInstanceContext } from '@/ui/layout/dropdown/contexts/DropdownComponentInstanceContext';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { t } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
import { MenuItemSelect } from 'twenty-ui/navigation';
import { filterBySearchQuery } from '~/utils/filterBySearchQuery';
export const ChartDataSourceDropdownContent = () => {
const [searchQuery, setSearchQuery] = useState('');
const { objectMetadataItems } = useObjectMetadataItems();
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const { pageLayoutId } = usePageLayoutIdFromContextStoreTargetedRecord();
const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId);
const currentSource = widgetInEditMode?.objectMetadataId;
const dropdownId = useAvailableComponentInstanceIdOrThrow(
DropdownComponentInstanceContext,
);
const selectedItemId = useRecoilComponentValue(
selectedItemIdComponentState,
dropdownId,
);
const objectsWithReadAccess = objectMetadataItems.filter(
(objectMetadataItem) => {
const objectPermissions =
objectPermissionsByObjectMetadataId[objectMetadataItem.id];
return (
isDefined(objectPermissions) && objectPermissions.canReadObjectRecords
);
},
);
const availableObjectMetadataItems = filterBySearchQuery({
items: objectsWithReadAccess,
searchQuery,
getSearchableValues: (item) => [item.labelPlural, item.namePlural],
});
const { updateCurrentWidgetConfig } =
useUpdateCurrentWidgetConfig(pageLayoutId);
const { closeDropdown } = useCloseDropdown();
const { getIcon } = useIcons();
const handleSelectSource = (objectMetadataId: string) => {
updateCurrentWidgetConfig({
objectMetadataId,
});
closeDropdown();
};
return (
<>
<DropdownMenuHeader>
<Trans>Source</Trans>
</DropdownMenuHeader>
<DropdownMenuSearchInput
autoFocus
type="text"
placeholder={t`Search objects`}
onChange={(event) => setSearchQuery(event.target.value)}
value={searchQuery}
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
<SelectableList
selectableListInstanceId={dropdownId}
focusId={dropdownId}
selectableItemIdArray={availableObjectMetadataItems.map(
(objectMetadataItem) => objectMetadataItem.id,
)}
>
{availableObjectMetadataItems.map((objectMetadataItem) => (
<SelectableListItem
key={objectMetadataItem.id}
itemId={objectMetadataItem.id}
onEnter={() => {
handleSelectSource(objectMetadataItem.id);
}}
>
<MenuItemSelect
text={objectMetadataItem.labelPlural}
selected={currentSource === objectMetadataItem.id}
focused={selectedItemId === objectMetadataItem.id}
LeftIcon={getIcon(objectMetadataItem.icon)}
onClick={() => {
handleSelectSource(objectMetadataItem.id);
}}
/>
</SelectableListItem>
))}
</SelectableList>
</DropdownMenuItemsContainer>
</>
);
};
@@ -0,0 +1,123 @@
import { usePageLayoutIdFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutFromContextStoreTargetedRecord';
import { useUpdateCurrentWidgetConfig } from '@/command-menu/pages/page-layout/hooks/useUpdateCurrentWidgetConfig';
import { useWidgetInEditMode } from '@/command-menu/pages/page-layout/hooks/useWidgetInEditMode';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { DropdownComponentInstanceContext } from '@/ui/layout/dropdown/contexts/DropdownComponentInstanceContext';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { t } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
import { MenuItemSelect } from 'twenty-ui/navigation';
import { filterBySearchQuery } from '~/utils/filterBySearchQuery';
export const ChartFieldSelectionDropdownContent = () => {
const [searchQuery, setSearchQuery] = useState('');
const { objectMetadataItems } = useObjectMetadataItems();
const { pageLayoutId } = usePageLayoutIdFromContextStoreTargetedRecord();
const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId);
if (
widgetInEditMode?.configuration?.__typename !== 'BarChartConfiguration' &&
widgetInEditMode?.configuration?.__typename !== 'LineChartConfiguration'
) {
throw new Error('Invalid configuration type');
}
const currentXAxisFieldMetadataId =
widgetInEditMode.configuration.groupByFieldMetadataIdX;
const sourceObjectMetadataItem = objectMetadataItems.find(
(item) => item.id === widgetInEditMode.objectMetadataId,
);
const dropdownId = useAvailableComponentInstanceIdOrThrow(
DropdownComponentInstanceContext,
);
const selectedItemId = useRecoilComponentValue(
selectedItemIdComponentState,
dropdownId,
);
const availableFieldMetadataItems = filterBySearchQuery({
items: sourceObjectMetadataItem?.fields || [],
searchQuery,
getSearchableValues: (item) => [item.label, item.name],
});
const { updateCurrentWidgetConfig } =
useUpdateCurrentWidgetConfig(pageLayoutId);
const { closeDropdown } = useCloseDropdown();
const { getIcon } = useIcons();
if (!isDefined(sourceObjectMetadataItem)) {
return null;
}
const handleSelectField = (fieldMetadataId: string) => {
updateCurrentWidgetConfig({
configToUpdate: {
groupByFieldMetadataIdX: fieldMetadataId,
},
});
closeDropdown();
};
return (
<>
<DropdownMenuHeader>
<Trans>X-Axis Field</Trans>
</DropdownMenuHeader>
<DropdownMenuSearchInput
autoFocus
type="text"
placeholder={t`Search fields`}
onChange={(event) => setSearchQuery(event.target.value)}
value={searchQuery}
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
<SelectableList
selectableListInstanceId={dropdownId}
focusId={dropdownId}
selectableItemIdArray={availableFieldMetadataItems.map(
(item) => item.id,
)}
>
{availableFieldMetadataItems.map((fieldMetadataItem) => (
<SelectableListItem
key={fieldMetadataItem.id}
itemId={fieldMetadataItem.id}
onEnter={() => {
handleSelectField(fieldMetadataItem.id);
}}
>
<MenuItemSelect
text={fieldMetadataItem.label}
selected={currentXAxisFieldMetadataId === fieldMetadataItem.id}
focused={selectedItemId === fieldMetadataItem.id}
LeftIcon={getIcon(fieldMetadataItem.icon)}
onClick={() => {
handleSelectField(fieldMetadataItem.id);
}}
/>
</SelectableListItem>
))}
</SelectableList>
</DropdownMenuItemsContainer>
</>
);
};
@@ -0,0 +1,126 @@
import { ChartAggregateOperationSelectionDropdownContent } from '@/command-menu/pages/page-layout/components/dropdown-content/ChartAggregateOperationSelectionDropdownContent';
import { usePageLayoutIdFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutFromContextStoreTargetedRecord';
import { useWidgetInEditMode } from '@/command-menu/pages/page-layout/hooks/useWidgetInEditMode';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { DropdownComponentInstanceContext } from '@/ui/layout/dropdown/contexts/DropdownComponentInstanceContext';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { t } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
import { MenuItemSelect } from 'twenty-ui/navigation';
import { filterBySearchQuery } from '~/utils/filterBySearchQuery';
export const ChartFieldSelectionForAggregateOperationDropdownContent = () => {
const [isSubMenuOpen, setIsSubMenuOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const { objectMetadataItems } = useObjectMetadataItems();
const { pageLayoutId } = usePageLayoutIdFromContextStoreTargetedRecord();
const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId);
if (
widgetInEditMode?.configuration?.__typename !== 'BarChartConfiguration' &&
widgetInEditMode?.configuration?.__typename !== 'LineChartConfiguration'
) {
throw new Error('Invalid configuration type');
}
const currentFieldMetadataId =
widgetInEditMode.configuration.groupByFieldMetadataIdY;
const [selectedFieldMetadataId, setSelectedFieldMetadataId] = useState(
currentFieldMetadataId,
);
const sourceObjectMetadataItem = objectMetadataItems.find(
(item) => item.id === widgetInEditMode.objectMetadataId,
);
const dropdownId = useAvailableComponentInstanceIdOrThrow(
DropdownComponentInstanceContext,
);
const selectedItemId = useRecoilComponentValue(
selectedItemIdComponentState,
dropdownId,
);
const availableFieldMetadataItems = filterBySearchQuery({
items: sourceObjectMetadataItem?.fields || [],
searchQuery,
getSearchableValues: (item) => [item.label, item.name],
});
const { getIcon } = useIcons();
if (!isDefined(sourceObjectMetadataItem)) {
return null;
}
if (isSubMenuOpen) {
return (
<ChartAggregateOperationSelectionDropdownContent
currentFieldMetadataId={selectedFieldMetadataId}
setIsSubMenuOpen={setIsSubMenuOpen}
/>
);
}
return (
<>
<DropdownMenuHeader>
<Trans>Y-Axis Field</Trans>
</DropdownMenuHeader>
<DropdownMenuSearchInput
autoFocus
type="text"
placeholder={t`Search fields`}
onChange={(event) => setSearchQuery(event.target.value)}
value={searchQuery}
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
<SelectableList
selectableListInstanceId={dropdownId}
focusId={dropdownId}
selectableItemIdArray={availableFieldMetadataItems.map(
(item) => item.id,
)}
>
{availableFieldMetadataItems.map((fieldMetadataItem) => (
<SelectableListItem
key={fieldMetadataItem.id}
itemId={fieldMetadataItem.id}
onEnter={() => {
setIsSubMenuOpen(true);
setSelectedFieldMetadataId(fieldMetadataItem.id);
}}
>
<MenuItemSelect
text={fieldMetadataItem.label}
selected={selectedFieldMetadataId === fieldMetadataItem.id}
focused={selectedItemId === fieldMetadataItem.id}
LeftIcon={getIcon(fieldMetadataItem.icon)}
hasSubMenu={true}
onClick={() => {
setIsSubMenuOpen(true);
setSelectedFieldMetadataId(fieldMetadataItem.id);
}}
/>
</SelectableListItem>
))}
</SelectableList>
</DropdownMenuItemsContainer>
</>
);
};
@@ -0,0 +1,125 @@
import { usePageLayoutIdFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutFromContextStoreTargetedRecord';
import { useUpdateCurrentWidgetConfig } from '@/command-menu/pages/page-layout/hooks/useUpdateCurrentWidgetConfig';
import { useWidgetInEditMode } from '@/command-menu/pages/page-layout/hooks/useWidgetInEditMode';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { DropdownComponentInstanceContext } from '@/ui/layout/dropdown/contexts/DropdownComponentInstanceContext';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { t } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
import { MenuItemSelect } from 'twenty-ui/navigation';
import { filterBySearchQuery } from '~/utils/filterBySearchQuery';
export const ChartGroupByFieldSelectionDropdownContent = () => {
const [searchQuery, setSearchQuery] = useState('');
const { objectMetadataItems } = useObjectMetadataItems();
const { pageLayoutId } = usePageLayoutIdFromContextStoreTargetedRecord();
const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId);
if (
widgetInEditMode?.configuration?.__typename !== 'BarChartConfiguration' &&
widgetInEditMode?.configuration?.__typename !== 'LineChartConfiguration'
) {
throw new Error('Invalid configuration type');
}
const currentGroupByFieldMetadataId =
widgetInEditMode.configuration.groupByFieldMetadataIdY;
const sourceObjectMetadataItem = objectMetadataItems.find(
(item) => item.id === widgetInEditMode.objectMetadataId,
);
const dropdownId = useAvailableComponentInstanceIdOrThrow(
DropdownComponentInstanceContext,
);
const selectedItemId = useRecoilComponentValue(
selectedItemIdComponentState,
dropdownId,
);
const availableFieldMetadataItems = filterBySearchQuery({
items: sourceObjectMetadataItem?.fields || [],
searchQuery,
getSearchableValues: (item) => [item.label, item.name],
});
const { updateCurrentWidgetConfig } =
useUpdateCurrentWidgetConfig(pageLayoutId);
const { closeDropdown } = useCloseDropdown();
const { getIcon } = useIcons();
if (!isDefined(sourceObjectMetadataItem)) {
return null;
}
const handleSelectField = (fieldMetadataId: string) => {
updateCurrentWidgetConfig({
configToUpdate: {
groupByFieldMetadataIdY: fieldMetadataId,
},
});
closeDropdown();
};
return (
<>
<DropdownMenuHeader>
<Trans>Y-Axis Group By Field</Trans>
</DropdownMenuHeader>
<DropdownMenuSearchInput
autoFocus
type="text"
placeholder={t`Search fields`}
onChange={(event) => setSearchQuery(event.target.value)}
value={searchQuery}
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
<SelectableList
selectableListInstanceId={dropdownId}
focusId={dropdownId}
selectableItemIdArray={availableFieldMetadataItems.map(
(item) => item.id,
)}
>
{availableFieldMetadataItems.map((fieldMetadataItem) => (
<SelectableListItem
key={fieldMetadataItem.id}
itemId={fieldMetadataItem.id}
onEnter={() => {
handleSelectField(fieldMetadataItem.id);
}}
>
<MenuItemSelect
text={fieldMetadataItem.label}
selected={
currentGroupByFieldMetadataId === fieldMetadataItem.id
}
focused={selectedItemId === fieldMetadataItem.id}
LeftIcon={getIcon(fieldMetadataItem.icon)}
onClick={() => {
handleSelectField(fieldMetadataItem.id);
}}
/>
</SelectableListItem>
))}
</SelectableList>
</DropdownMenuItemsContainer>
</>
);
};
@@ -0,0 +1,117 @@
import { AGGREGATE_SORT_BY_OPTIONS } from '@/command-menu/pages/page-layout/constants/AggregateSortByOptions';
import { useGraphGroupBySortOptionLabels } from '@/command-menu/pages/page-layout/hooks/useGraphGroupBySortOptionLabels';
import { usePageLayoutIdFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutFromContextStoreTargetedRecord';
import { useUpdateCurrentWidgetConfig } from '@/command-menu/pages/page-layout/hooks/useUpdateCurrentWidgetConfig';
import { useWidgetInEditMode } from '@/command-menu/pages/page-layout/hooks/useWidgetInEditMode';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownComponentInstanceContext } from '@/ui/layout/dropdown/contexts/DropdownComponentInstanceContext';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { MenuItemSelect } from 'twenty-ui/navigation';
import {
type BarChartConfiguration,
type GraphOrderBy,
type LineChartConfiguration,
type NumberChartConfiguration,
} from '~/generated/graphql';
type ChartSortByGroupByFieldDropdownContentProps = {
title: string;
};
export const ChartSortByGroupByFieldDropdownContent = ({
title,
}: ChartSortByGroupByFieldDropdownContentProps) => {
const { pageLayoutId } = usePageLayoutIdFromContextStoreTargetedRecord();
const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId);
const configuration = widgetInEditMode?.configuration as
| BarChartConfiguration
| LineChartConfiguration
| NumberChartConfiguration;
const currentOrderBy =
'orderByY' in configuration
? configuration.orderByY
: 'orderBy' in configuration
? configuration.orderBy
: undefined;
const dropdownId = useAvailableComponentInstanceIdOrThrow(
DropdownComponentInstanceContext,
);
const selectedItemId = useRecoilComponentValue(
selectedItemIdComponentState,
dropdownId,
);
const { updateCurrentWidgetConfig } =
useUpdateCurrentWidgetConfig(pageLayoutId);
const { closeDropdown } = useCloseDropdown();
const orderByKey = 'orderByY' in configuration ? 'orderByY' : 'orderBy';
const handleSelectSortOption = (orderBy: GraphOrderBy) => {
updateCurrentWidgetConfig({
configToUpdate: {
[orderByKey]: orderBy,
},
});
closeDropdown();
};
const { getGroupBySortOptionLabel } = useGraphGroupBySortOptionLabels({
objectMetadataId: widgetInEditMode?.objectMetadataId,
});
return (
<>
<DropdownMenuHeader>{title}</DropdownMenuHeader>
<DropdownMenuItemsContainer>
<SelectableList
selectableListInstanceId={dropdownId}
focusId={dropdownId}
selectableItemIdArray={AGGREGATE_SORT_BY_OPTIONS.map(
(option) => option.value,
)}
>
{AGGREGATE_SORT_BY_OPTIONS.map((sortOption) => (
<SelectableListItem
key={sortOption.value}
itemId={sortOption.value}
onEnter={() => {
handleSelectSortOption(sortOption.value);
}}
>
<MenuItemSelect
text={getGroupBySortOptionLabel({
graphOrderBy: sortOption.value,
groupByFieldMetadataId:
'groupByFieldMetadataIdY' in configuration
? configuration.groupByFieldMetadataIdY
: 'groupByFieldMetadataId' in configuration
? configuration.groupByFieldMetadataId
: undefined,
})}
selected={currentOrderBy === sortOption.value}
focused={selectedItemId === sortOption.value}
LeftIcon={sortOption.icon}
onClick={() => {
handleSelectSortOption(sortOption.value);
}}
/>
</SelectableListItem>
))}
</SelectableList>
</DropdownMenuItemsContainer>
</>
);
};
@@ -0,0 +1,104 @@
import { X_SORT_BY_OPTIONS } from '@/command-menu/pages/page-layout/constants/XSortByOptions';
import { useGraphXSortOptionLabels } from '@/command-menu/pages/page-layout/hooks/useGraphXSortOptionLabels';
import { usePageLayoutIdFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutFromContextStoreTargetedRecord';
import { useUpdateCurrentWidgetConfig } from '@/command-menu/pages/page-layout/hooks/useUpdateCurrentWidgetConfig';
import { useWidgetInEditMode } from '@/command-menu/pages/page-layout/hooks/useWidgetInEditMode';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownComponentInstanceContext } from '@/ui/layout/dropdown/contexts/DropdownComponentInstanceContext';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { Trans } from '@lingui/react/macro';
import { MenuItemSelect } from 'twenty-ui/navigation';
import { type GraphOrderBy } from '~/generated/graphql';
export const ChartXAxisSortBySelectionDropdownContent = () => {
const { pageLayoutId } = usePageLayoutIdFromContextStoreTargetedRecord();
const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId);
if (
widgetInEditMode?.configuration?.__typename !== 'BarChartConfiguration' &&
widgetInEditMode?.configuration?.__typename !== 'LineChartConfiguration'
) {
throw new Error('Invalid configuration type');
}
const configuration = widgetInEditMode?.configuration;
const currentOrderByX = configuration.orderByX;
const dropdownId = useAvailableComponentInstanceIdOrThrow(
DropdownComponentInstanceContext,
);
const selectedItemId = useRecoilComponentValue(
selectedItemIdComponentState,
dropdownId,
);
const { updateCurrentWidgetConfig } =
useUpdateCurrentWidgetConfig(pageLayoutId);
const { closeDropdown } = useCloseDropdown();
const handleSelectSortOption = (orderByX: GraphOrderBy) => {
updateCurrentWidgetConfig({
configToUpdate: {
orderByX,
},
});
closeDropdown();
};
const { getXSortOptionLabel } = useGraphXSortOptionLabels({
objectMetadataId: widgetInEditMode?.objectMetadataId,
});
return (
<>
<DropdownMenuHeader>
<Trans>X-Axis Sort By</Trans>
</DropdownMenuHeader>
<DropdownMenuItemsContainer>
<SelectableList
selectableListInstanceId={dropdownId}
focusId={dropdownId}
selectableItemIdArray={X_SORT_BY_OPTIONS.map(
(option) => option.value,
)}
>
{X_SORT_BY_OPTIONS.map((sortOption) => (
<SelectableListItem
key={sortOption.value}
itemId={sortOption.value}
onEnter={() => {
handleSelectSortOption(sortOption.value);
}}
>
<MenuItemSelect
text={getXSortOptionLabel({
graphOrderBy: sortOption.value,
groupByFieldMetadataIdX:
configuration.groupByFieldMetadataIdX,
aggregateFieldMetadataId:
configuration.aggregateFieldMetadataId,
aggregateOperation: configuration.aggregateOperation,
})}
selected={currentOrderByX === sortOption.value}
focused={selectedItemId === sortOption.value}
LeftIcon={sortOption.icon}
onClick={() => {
handleSelectSortOption(sortOption.value);
}}
/>
</SelectableListItem>
))}
</SelectableList>
</DropdownMenuItemsContainer>
</>
);
};
@@ -0,0 +1,13 @@
import { IconArrowDown, IconArrowUp } from 'twenty-ui/display';
import { GraphOrderBy } from '~/generated/graphql';
export const AGGREGATE_SORT_BY_OPTIONS = [
{
value: GraphOrderBy.FIELD_ASC,
icon: IconArrowUp,
},
{
value: GraphOrderBy.FIELD_DESC,
icon: IconArrowDown,
},
];
@@ -0,0 +1,34 @@
import { AXIS_NAME_SETTING } from '@/command-menu/pages/page-layout/constants/settings/AxisNameSetting';
import { CHART_DATA_SOURCE_SETTING } from '@/command-menu/pages/page-layout/constants/settings/ChartDataSourceSetting';
import { COLORS_SETTING } from '@/command-menu/pages/page-layout/constants/settings/ColorsSetting';
import { DATA_DISPLAY_X_SETTING } from '@/command-menu/pages/page-layout/constants/settings/DataDisplayXSetting';
import { DATA_DISPLAY_Y_SETTING } from '@/command-menu/pages/page-layout/constants/settings/DataDisplayYSetting';
import { DATA_LABELS_SETTING } from '@/command-menu/pages/page-layout/constants/settings/DataLabelsSetting';
import { FILTER_SETTING } from '@/command-menu/pages/page-layout/constants/settings/FilterSetting';
import { GROUP_BY_SETTING } from '@/command-menu/pages/page-layout/constants/settings/GroupBySetting';
import { SORT_BY_GROUP_BY_FIELD_SETTING } from '@/command-menu/pages/page-layout/constants/settings/SortByGroupByFieldSetting';
import { SORT_BY_X_SETTING } from '@/command-menu/pages/page-layout/constants/settings/SortByXSetting';
import { type ChartSettingsGroup } from '@/command-menu/pages/page-layout/types/ChartSettingsGroup';
export const BAR_CHART_SETTINGS: ChartSettingsGroup[] = [
{
heading: 'Data',
items: [CHART_DATA_SOURCE_SETTING, FILTER_SETTING],
},
{
heading: 'X axis',
items: [DATA_DISPLAY_X_SETTING, SORT_BY_X_SETTING],
},
{
heading: 'Y axis',
items: [
DATA_DISPLAY_Y_SETTING,
GROUP_BY_SETTING,
SORT_BY_GROUP_BY_FIELD_SETTING,
],
},
{
heading: 'Style',
items: [COLORS_SETTING, AXIS_NAME_SETTING, DATA_LABELS_SETTING],
},
];
@@ -0,0 +1,15 @@
import { CHART_DATA_SOURCE_SETTING } from '@/command-menu/pages/page-layout/constants/settings/ChartDataSourceSetting';
import { DATA_LABELS_SETTING } from '@/command-menu/pages/page-layout/constants/settings/DataLabelsSetting';
import { FILTER_SETTING } from '@/command-menu/pages/page-layout/constants/settings/FilterSetting';
import { type ChartSettingsGroup } from '@/command-menu/pages/page-layout/types/ChartSettingsGroup';
export const GAUGE_CHART_SETTINGS: ChartSettingsGroup[] = [
{
heading: 'Data',
items: [CHART_DATA_SOURCE_SETTING, FILTER_SETTING],
},
{
heading: 'Style',
items: [DATA_LABELS_SETTING],
},
];
@@ -0,0 +1,52 @@
import { BAR_CHART_SETTINGS } from '@/command-menu/pages/page-layout/constants/BarChartSettings';
import { GAUGE_CHART_SETTINGS } from '@/command-menu/pages/page-layout/constants/GaugeChartSettings';
import { LINE_CHART_SETTINGS } from '@/command-menu/pages/page-layout/constants/LineChartSettings';
import { NUMBER_CHART_SETTINGS } from '@/command-menu/pages/page-layout/constants/NumberChartSettings';
import { PIE_CHART_SETTINGS } from '@/command-menu/pages/page-layout/constants/PieChartSettings';
import { type ChartSettingsGroup } from '@/command-menu/pages/page-layout/types/ChartSettingsGroup';
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import {
IconChartBar,
IconChartLine,
IconChartPie,
type IconComponent,
IconGauge,
IconNumber,
} from 'twenty-ui/display';
import { GraphType } from '~/generated-metadata/graphql';
export const GRAPH_TYPE_INFORMATION: Record<
GraphType,
{
label: MessageDescriptor;
icon: IconComponent;
settings: ChartSettingsGroup[];
}
> = {
[GraphType.BAR]: {
label: msg`Bar Chart`,
icon: IconChartBar,
settings: BAR_CHART_SETTINGS,
},
[GraphType.PIE]: {
label: msg`Pie Chart`,
icon: IconChartPie,
settings: PIE_CHART_SETTINGS,
},
[GraphType.LINE]: {
label: msg`Line Chart`,
icon: IconChartLine,
settings: LINE_CHART_SETTINGS,
},
[GraphType.NUMBER]: {
label: msg`Number Chart`,
icon: IconNumber,
settings: NUMBER_CHART_SETTINGS,
},
[GraphType.GAUGE]: {
label: msg`Gauge Chart`,
icon: IconGauge,
settings: GAUGE_CHART_SETTINGS,
},
};
@@ -0,0 +1,9 @@
import { GraphType } from '~/generated-metadata/graphql';
export const GRAPH_TYPE_TO_CONFIG_TYPENAME = {
[GraphType.BAR]: 'BarChartConfiguration',
[GraphType.LINE]: 'LineChartConfiguration',
[GraphType.PIE]: 'PieChartConfiguration',
[GraphType.NUMBER]: 'NumberChartConfiguration',
[GraphType.GAUGE]: 'GaugeChartConfiguration',
} as const;
@@ -0,0 +1,34 @@
import { AXIS_NAME_SETTING } from '@/command-menu/pages/page-layout/constants/settings/AxisNameSetting';
import { CHART_DATA_SOURCE_SETTING } from '@/command-menu/pages/page-layout/constants/settings/ChartDataSourceSetting';
import { COLORS_SETTING } from '@/command-menu/pages/page-layout/constants/settings/ColorsSetting';
import { DATA_DISPLAY_X_SETTING } from '@/command-menu/pages/page-layout/constants/settings/DataDisplayXSetting';
import { DATA_DISPLAY_Y_SETTING } from '@/command-menu/pages/page-layout/constants/settings/DataDisplayYSetting';
import { DATA_LABELS_SETTING } from '@/command-menu/pages/page-layout/constants/settings/DataLabelsSetting';
import { FILTER_SETTING } from '@/command-menu/pages/page-layout/constants/settings/FilterSetting';
import { GROUP_BY_SETTING } from '@/command-menu/pages/page-layout/constants/settings/GroupBySetting';
import { SORT_BY_GROUP_BY_FIELD_SETTING } from '@/command-menu/pages/page-layout/constants/settings/SortByGroupByFieldSetting';
import { SORT_BY_X_SETTING } from '@/command-menu/pages/page-layout/constants/settings/SortByXSetting';
import { type ChartSettingsGroup } from '@/command-menu/pages/page-layout/types/ChartSettingsGroup';
export const LINE_CHART_SETTINGS: ChartSettingsGroup[] = [
{
heading: 'Data',
items: [CHART_DATA_SOURCE_SETTING, FILTER_SETTING],
},
{
heading: 'X axis',
items: [DATA_DISPLAY_X_SETTING, SORT_BY_X_SETTING],
},
{
heading: 'Y axis',
items: [
DATA_DISPLAY_Y_SETTING,
GROUP_BY_SETTING,
SORT_BY_GROUP_BY_FIELD_SETTING,
],
},
{
heading: 'Style',
items: [COLORS_SETTING, AXIS_NAME_SETTING, DATA_LABELS_SETTING],
},
];
@@ -0,0 +1,15 @@
import { CHART_DATA_SOURCE_SETTING } from '@/command-menu/pages/page-layout/constants/settings/ChartDataSourceSetting';
import { DATA_LABELS_SETTING } from '@/command-menu/pages/page-layout/constants/settings/DataLabelsSetting';
import { FILTER_SETTING } from '@/command-menu/pages/page-layout/constants/settings/FilterSetting';
import { type ChartSettingsGroup } from '@/command-menu/pages/page-layout/types/ChartSettingsGroup';
export const NUMBER_CHART_SETTINGS: ChartSettingsGroup[] = [
{
heading: 'Data',
items: [CHART_DATA_SOURCE_SETTING, FILTER_SETTING],
},
{
heading: 'Style',
items: [DATA_LABELS_SETTING],
},
];
@@ -0,0 +1,25 @@
import { CHART_DATA_SOURCE_SETTING } from '@/command-menu/pages/page-layout/constants/settings/ChartDataSourceSetting';
import { COLORS_SETTING } from '@/command-menu/pages/page-layout/constants/settings/ColorsSetting';
import { DATA_DISPLAY_PIE_CHART_SETTING } from '@/command-menu/pages/page-layout/constants/settings/DataDisplayPieChartSetting';
import { DATA_LABELS_SETTING } from '@/command-menu/pages/page-layout/constants/settings/DataLabelsSetting';
import { EACH_SLICE_REPRESENTS_SETTING } from '@/command-menu/pages/page-layout/constants/settings/EachSliceRepresentsSetting';
import { FILTER_SETTING } from '@/command-menu/pages/page-layout/constants/settings/FilterSetting';
import { SORT_BY_X_SETTING } from '@/command-menu/pages/page-layout/constants/settings/SortByXSetting';
import { type ChartSettingsGroup } from '@/command-menu/pages/page-layout/types/ChartSettingsGroup';
export const PIE_CHART_SETTINGS: ChartSettingsGroup[] = [
{
heading: 'Data',
items: [
CHART_DATA_SOURCE_SETTING,
FILTER_SETTING,
DATA_DISPLAY_PIE_CHART_SETTING,
EACH_SLICE_REPRESENTS_SETTING,
SORT_BY_X_SETTING,
],
},
{
heading: 'Style',
items: [COLORS_SETTING, DATA_LABELS_SETTING],
},
];
@@ -0,0 +1,26 @@
import {
IconArrowDown,
IconArrowUp,
IconTrendingDown,
IconTrendingUp,
} from 'twenty-ui/display';
import { GraphOrderBy } from '~/generated/graphql';
export const X_SORT_BY_OPTIONS = [
{
value: GraphOrderBy.FIELD_ASC,
icon: IconArrowUp,
},
{
value: GraphOrderBy.FIELD_DESC,
icon: IconArrowDown,
},
{
value: GraphOrderBy.VALUE_ASC,
icon: IconTrendingUp,
},
{
value: GraphOrderBy.VALUE_DESC,
icon: IconTrendingDown,
},
];
@@ -0,0 +1,11 @@
import { CHART_CONFIGURATION_SETTING_LABELS } from '@/command-menu/pages/page-layout/constants/settings/ChartConfigurationSettingLabels';
import { CHART_CONFIGURATION_SETTING_IDS } from '@/command-menu/pages/page-layout/types/ChartConfigurationSettingIds';
import { type ChartSettingsItem } from '@/command-menu/pages/page-layout/types/ChartSettingsGroup';
import { IconGizmo } from 'twenty-ui/display';
export const AXIS_NAME_SETTING: ChartSettingsItem = {
isBoolean: false,
Icon: IconGizmo,
label: CHART_CONFIGURATION_SETTING_LABELS.AXIS_NAME,
id: CHART_CONFIGURATION_SETTING_IDS.AXIS_NAME,
};
@@ -0,0 +1,16 @@
import { msg } from '@lingui/core/macro';
export const CHART_CONFIGURATION_SETTING_LABELS = {
SOURCE: msg`Source`,
DATA_LABELS: msg`Data labels`,
COLORS: msg`Colors`,
FILTER: msg`Filter`,
GROUP_BY: msg`Group by`,
SORT_BY_X: msg`Sort by`,
SORT_BY_GROUP_BY_FIELD: msg`Sort by`,
DATA_ON_DISPLAY_X: msg`Data on display`,
DATA_ON_DISPLAY_Y: msg`Data on display`,
DATA_ON_DISPLAY_PIE_CHART: msg`Data on display`,
EACH_SLICE_REPRESENTS: msg`Each slice represents`,
AXIS_NAME: msg`Axis name`,
};
@@ -0,0 +1,11 @@
import { CHART_CONFIGURATION_SETTING_LABELS } from '@/command-menu/pages/page-layout/constants/settings/ChartConfigurationSettingLabels';
import { CHART_CONFIGURATION_SETTING_IDS } from '@/command-menu/pages/page-layout/types/ChartConfigurationSettingIds';
import { type ChartSettingsItem } from '@/command-menu/pages/page-layout/types/ChartSettingsGroup';
import { IconDatabase } from 'twenty-ui/display';
export const CHART_DATA_SOURCE_SETTING: ChartSettingsItem = {
isBoolean: false,
Icon: IconDatabase,
label: CHART_CONFIGURATION_SETTING_LABELS.SOURCE,
id: CHART_CONFIGURATION_SETTING_IDS.SOURCE,
};
@@ -0,0 +1,11 @@
import { CHART_CONFIGURATION_SETTING_LABELS } from '@/command-menu/pages/page-layout/constants/settings/ChartConfigurationSettingLabels';
import { CHART_CONFIGURATION_SETTING_IDS } from '@/command-menu/pages/page-layout/types/ChartConfigurationSettingIds';
import { type ChartSettingsItem } from '@/command-menu/pages/page-layout/types/ChartSettingsGroup';
import { IconColorSwatch } from 'twenty-ui/display';
export const COLORS_SETTING: ChartSettingsItem = {
isBoolean: false,
Icon: IconColorSwatch,
label: CHART_CONFIGURATION_SETTING_LABELS.COLORS,
id: CHART_CONFIGURATION_SETTING_IDS.COLORS,
};
@@ -0,0 +1,12 @@
import { CHART_CONFIGURATION_SETTING_LABELS } from '@/command-menu/pages/page-layout/constants/settings/ChartConfigurationSettingLabels';
import { CHART_CONFIGURATION_SETTING_IDS } from '@/command-menu/pages/page-layout/types/ChartConfigurationSettingIds';
import { type ChartSettingsItem } from '@/command-menu/pages/page-layout/types/ChartSettingsGroup';
import { IconChartPie } from 'twenty-ui/display';
export const DATA_DISPLAY_PIE_CHART_SETTING: ChartSettingsItem = {
isBoolean: false,
Icon: IconChartPie,
label: CHART_CONFIGURATION_SETTING_LABELS.DATA_ON_DISPLAY_PIE_CHART,
id: CHART_CONFIGURATION_SETTING_IDS.DATA_ON_DISPLAY_PIE_CHART,
dependsOn: [CHART_CONFIGURATION_SETTING_IDS.SOURCE],
};
@@ -0,0 +1,12 @@
import { CHART_CONFIGURATION_SETTING_LABELS } from '@/command-menu/pages/page-layout/constants/settings/ChartConfigurationSettingLabels';
import { CHART_CONFIGURATION_SETTING_IDS } from '@/command-menu/pages/page-layout/types/ChartConfigurationSettingIds';
import { type ChartSettingsItem } from '@/command-menu/pages/page-layout/types/ChartSettingsGroup';
import { IconAxisX } from 'twenty-ui/display';
export const DATA_DISPLAY_X_SETTING: ChartSettingsItem = {
isBoolean: false,
Icon: IconAxisX,
label: CHART_CONFIGURATION_SETTING_LABELS.DATA_ON_DISPLAY_X,
id: CHART_CONFIGURATION_SETTING_IDS.DATA_ON_DISPLAY_X,
dependsOn: [CHART_CONFIGURATION_SETTING_IDS.SOURCE],
};
@@ -0,0 +1,12 @@
import { CHART_CONFIGURATION_SETTING_LABELS } from '@/command-menu/pages/page-layout/constants/settings/ChartConfigurationSettingLabels';
import { CHART_CONFIGURATION_SETTING_IDS } from '@/command-menu/pages/page-layout/types/ChartConfigurationSettingIds';
import { type ChartSettingsItem } from '@/command-menu/pages/page-layout/types/ChartSettingsGroup';
import { IconAxisY } from 'twenty-ui/display';
export const DATA_DISPLAY_Y_SETTING: ChartSettingsItem = {
isBoolean: false,
Icon: IconAxisY,
label: CHART_CONFIGURATION_SETTING_LABELS.DATA_ON_DISPLAY_Y,
id: CHART_CONFIGURATION_SETTING_IDS.DATA_ON_DISPLAY_Y,
dependsOn: [CHART_CONFIGURATION_SETTING_IDS.SOURCE],
};
@@ -0,0 +1,11 @@
import { CHART_CONFIGURATION_SETTING_LABELS } from '@/command-menu/pages/page-layout/constants/settings/ChartConfigurationSettingLabels';
import { CHART_CONFIGURATION_SETTING_IDS } from '@/command-menu/pages/page-layout/types/ChartConfigurationSettingIds';
import { type ChartSettingsItem } from '@/command-menu/pages/page-layout/types/ChartSettingsGroup';
import { IconTag } from 'twenty-ui/display';
export const DATA_LABELS_SETTING: ChartSettingsItem = {
isBoolean: true,
Icon: IconTag,
label: CHART_CONFIGURATION_SETTING_LABELS.DATA_LABELS,
id: CHART_CONFIGURATION_SETTING_IDS.DATA_LABELS,
};
@@ -0,0 +1,11 @@
import { CHART_CONFIGURATION_SETTING_LABELS } from '@/command-menu/pages/page-layout/constants/settings/ChartConfigurationSettingLabels';
import { CHART_CONFIGURATION_SETTING_IDS } from '@/command-menu/pages/page-layout/types/ChartConfigurationSettingIds';
import { type ChartSettingsItem } from '@/command-menu/pages/page-layout/types/ChartSettingsGroup';
import { IconChartPie } from 'twenty-ui/display';
export const EACH_SLICE_REPRESENTS_SETTING: ChartSettingsItem = {
isBoolean: false,
Icon: IconChartPie,
label: CHART_CONFIGURATION_SETTING_LABELS.EACH_SLICE_REPRESENTS,
id: CHART_CONFIGURATION_SETTING_IDS.EACH_SLICE_REPRESENTS,
};
@@ -0,0 +1,12 @@
import { CHART_CONFIGURATION_SETTING_LABELS } from '@/command-menu/pages/page-layout/constants/settings/ChartConfigurationSettingLabels';
import { CHART_CONFIGURATION_SETTING_IDS } from '@/command-menu/pages/page-layout/types/ChartConfigurationSettingIds';
import { type ChartSettingsItem } from '@/command-menu/pages/page-layout/types/ChartSettingsGroup';
import { IconFilter } from 'twenty-ui/display';
export const FILTER_SETTING: ChartSettingsItem = {
isBoolean: false,
Icon: IconFilter,
label: CHART_CONFIGURATION_SETTING_LABELS.FILTER,
id: CHART_CONFIGURATION_SETTING_IDS.FILTER,
dependsOn: [CHART_CONFIGURATION_SETTING_IDS.SOURCE],
};
@@ -0,0 +1,12 @@
import { CHART_CONFIGURATION_SETTING_LABELS } from '@/command-menu/pages/page-layout/constants/settings/ChartConfigurationSettingLabels';
import { CHART_CONFIGURATION_SETTING_IDS } from '@/command-menu/pages/page-layout/types/ChartConfigurationSettingIds';
import { type ChartSettingsItem } from '@/command-menu/pages/page-layout/types/ChartSettingsGroup';
import { IconFilters } from 'twenty-ui/display';
export const GROUP_BY_SETTING: ChartSettingsItem = {
isBoolean: false,
Icon: IconFilters,
label: CHART_CONFIGURATION_SETTING_LABELS.GROUP_BY,
id: CHART_CONFIGURATION_SETTING_IDS.GROUP_BY,
dependsOn: [CHART_CONFIGURATION_SETTING_IDS.SOURCE],
};
@@ -0,0 +1,11 @@
import { CHART_CONFIGURATION_SETTING_LABELS } from '@/command-menu/pages/page-layout/constants/settings/ChartConfigurationSettingLabels';
import { CHART_CONFIGURATION_SETTING_IDS } from '@/command-menu/pages/page-layout/types/ChartConfigurationSettingIds';
import { type ChartSettingsItem } from '@/command-menu/pages/page-layout/types/ChartSettingsGroup';
import { IconArrowsSort } from 'twenty-ui/display';
export const SORT_BY_GROUP_BY_FIELD_SETTING: ChartSettingsItem = {
isBoolean: false,
Icon: IconArrowsSort,
label: CHART_CONFIGURATION_SETTING_LABELS.SORT_BY_GROUP_BY_FIELD,
id: CHART_CONFIGURATION_SETTING_IDS.SORT_BY_GROUP_BY_FIELD,
};
@@ -0,0 +1,11 @@
import { CHART_CONFIGURATION_SETTING_LABELS } from '@/command-menu/pages/page-layout/constants/settings/ChartConfigurationSettingLabels';
import { CHART_CONFIGURATION_SETTING_IDS } from '@/command-menu/pages/page-layout/types/ChartConfigurationSettingIds';
import { type ChartSettingsItem } from '@/command-menu/pages/page-layout/types/ChartSettingsGroup';
import { IconArrowsSort } from 'twenty-ui/display';
export const SORT_BY_X_SETTING: ChartSettingsItem = {
isBoolean: false,
Icon: IconArrowsSort,
label: CHART_CONFIGURATION_SETTING_LABELS.SORT_BY_X,
id: CHART_CONFIGURATION_SETTING_IDS.SORT_BY_X,
};
@@ -0,0 +1,127 @@
import { useGraphGroupBySortOptionLabels } from '@/command-menu/pages/page-layout/hooks/useGraphGroupBySortOptionLabels';
import { useGraphXSortOptionLabels } from '@/command-menu/pages/page-layout/hooks/useGraphXSortOptionLabels';
import { type ChartConfiguration } from '@/command-menu/pages/page-layout/types/ChartConfiguration';
import { CHART_CONFIGURATION_SETTING_IDS } from '@/command-menu/pages/page-layout/types/ChartConfigurationSettingIds';
import { getChartAxisNameDisplayOptions } from '@/command-menu/pages/page-layout/utils/getChartAxisNameDisplayOptions';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
import { useRecoilValue } from 'recoil';
import { capitalize, isDefined } from 'twenty-shared/utils';
export const useChartSettingsValues = ({
objectMetadataId,
configuration,
}: {
objectMetadataId: string;
configuration?: ChartConfiguration;
}) => {
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const objectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) => objectMetadataItem.id === objectMetadataId,
);
const { getXSortOptionLabel } = useGraphXSortOptionLabels({
objectMetadataId,
});
const { getGroupBySortOptionLabel } = useGraphGroupBySortOptionLabels({
objectMetadataId,
});
if (!configuration) {
return {
getChartSettingsValues: () => undefined,
};
}
const groupByFieldX =
'groupByFieldMetadataIdX' in configuration
? objectMetadataItem?.fields.find(
(fieldMetadataItem) =>
fieldMetadataItem.id === configuration.groupByFieldMetadataIdX,
)
: undefined;
const aggregateField = objectMetadataItem?.fields.find(
(fieldMetadataItem) =>
fieldMetadataItem.id === configuration.aggregateFieldMetadataId,
);
const yAxisAggregateOperation = configuration.aggregateOperation;
const groupByFieldY =
'groupByFieldMetadataIdY' in configuration
? objectMetadataItem?.fields.find(
(fieldMetadataItem) =>
fieldMetadataItem.id === configuration.groupByFieldMetadataIdY,
)
: undefined;
const xAxisOrderBy =
'orderByX' in configuration ? configuration.orderByX : undefined;
const xAxisOrderByLabel =
isDefined(xAxisOrderBy) && 'groupByFieldMetadataIdX' in configuration
? getXSortOptionLabel({
graphOrderBy: xAxisOrderBy,
groupByFieldMetadataIdX: configuration.groupByFieldMetadataIdX,
aggregateFieldMetadataId: configuration.aggregateFieldMetadataId,
aggregateOperation: configuration.aggregateOperation,
})
: undefined;
const groupByOrderBy =
'orderByY' in configuration
? configuration.orderByY
: 'orderBy' in configuration
? configuration.orderBy
: undefined;
const groupByOrderByLabel =
isDefined(groupByOrderBy) &&
getGroupBySortOptionLabel({
graphOrderBy: groupByOrderBy,
groupByFieldMetadataId:
'groupByFieldMetadataIdY' in configuration
? configuration.groupByFieldMetadataIdY
: 'groupByFieldMetadataId' in configuration
? configuration.groupByFieldMetadataId
: undefined,
});
const getChartSettingsValues = (
itemId: CHART_CONFIGURATION_SETTING_IDS,
): boolean | string | undefined => {
switch (itemId) {
case CHART_CONFIGURATION_SETTING_IDS.SOURCE:
return objectMetadataItem?.labelPlural;
case CHART_CONFIGURATION_SETTING_IDS.DATA_ON_DISPLAY_X:
return groupByFieldX?.label;
case CHART_CONFIGURATION_SETTING_IDS.COLORS:
return isDefined(configuration.color) && 'color' in configuration
? capitalize(configuration.color)
: undefined;
case CHART_CONFIGURATION_SETTING_IDS.DATA_ON_DISPLAY_Y:
return `${aggregateField?.label ?? ''}${aggregateField?.label ? ` (${getAggregateOperationLabel(yAxisAggregateOperation)})` : ''}`;
case CHART_CONFIGURATION_SETTING_IDS.GROUP_BY:
return groupByFieldY?.label;
case CHART_CONFIGURATION_SETTING_IDS.AXIS_NAME:
return 'axisNameDisplay' in configuration
? getChartAxisNameDisplayOptions(configuration.axisNameDisplay)
: undefined;
case CHART_CONFIGURATION_SETTING_IDS.SORT_BY_X:
return xAxisOrderByLabel;
case CHART_CONFIGURATION_SETTING_IDS.SORT_BY_GROUP_BY_FIELD:
return groupByOrderByLabel;
case CHART_CONFIGURATION_SETTING_IDS.DATA_LABELS:
return configuration.displayDataLabel;
default:
return '';
}
};
return {
getChartSettingsValues,
};
};
@@ -0,0 +1,43 @@
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { t } from '@lingui/core/macro';
import { useRecoilValue } from 'recoil';
import { GraphOrderBy } from '~/generated/graphql';
export const useGraphGroupBySortOptionLabels = ({
objectMetadataId,
}: {
objectMetadataId: string;
}) => {
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const objectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) => objectMetadataItem.id === objectMetadataId,
);
const getGroupBySortOptionLabel = ({
graphOrderBy,
groupByFieldMetadataId,
}: {
graphOrderBy: GraphOrderBy;
groupByFieldMetadataId: string;
}): string => {
const field = objectMetadataItem?.fields.find(
(fieldMetadataItem) => fieldMetadataItem.id === groupByFieldMetadataId,
);
const fieldLabel = field?.label || t`Field`;
switch (graphOrderBy) {
case GraphOrderBy.FIELD_ASC:
return `${fieldLabel} ${t`Ascending`}`;
case GraphOrderBy.FIELD_DESC:
return `${fieldLabel} ${t`Descending`}`;
default:
return '';
}
};
return {
getGroupBySortOptionLabel,
};
};
@@ -0,0 +1,67 @@
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
import { t } from '@lingui/core/macro';
import { useRecoilValue } from 'recoil';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
import {
type ExtendedAggregateOperations,
GraphOrderBy,
} from '~/generated/graphql';
export const useGraphXSortOptionLabels = ({
objectMetadataId,
}: {
objectMetadataId: string;
}) => {
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const objectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) => objectMetadataItem.id === objectMetadataId,
);
const getXSortOptionLabel = ({
graphOrderBy,
groupByFieldMetadataIdX,
aggregateFieldMetadataId,
aggregateOperation,
}: {
graphOrderBy: GraphOrderBy;
groupByFieldMetadataIdX: string;
aggregateFieldMetadataId?: string;
aggregateOperation?: ExtendedAggregateOperations;
}): string => {
const groupByField = objectMetadataItem?.fields.find(
(fieldMetadataItem) => fieldMetadataItem.id === groupByFieldMetadataIdX,
);
const fieldLabel = groupByField?.label || t`Field`;
const aggregateField = objectMetadataItem?.fields.find(
(fieldMetadataItem) =>
isDefined(aggregateFieldMetadataId) &&
fieldMetadataItem.id === aggregateFieldMetadataId,
);
const valueLabel =
aggregateField?.label && isDefined(aggregateOperation)
? getAggregateOperationLabel(aggregateOperation)
: t`Value`;
switch (graphOrderBy) {
case GraphOrderBy.FIELD_ASC:
return `${fieldLabel} ${t`Ascending`}`;
case GraphOrderBy.FIELD_DESC:
return `${fieldLabel} ${t`Descending`}`;
case GraphOrderBy.VALUE_ASC:
return `${valueLabel} ${t`Ascending`}`;
case GraphOrderBy.VALUE_DESC:
return `${valueLabel} ${t`Descending`}`;
default:
assertUnreachable(graphOrderBy);
}
};
return {
getXSortOptionLabel,
};
};
@@ -0,0 +1,52 @@
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useRecoilCallback } from 'recoil';
import type { PageLayoutWidget } from '~/generated/graphql';
export const useUpdateCurrentWidgetConfig = (pageLayoutIdFromProps: string) => {
const pageLayoutDraftCallbackState = useRecoilComponentCallbackState(
pageLayoutDraftComponentState,
pageLayoutIdFromProps,
);
const currentlyEditingWidgetId = useRecoilComponentValue(
pageLayoutEditingWidgetIdComponentState,
pageLayoutIdFromProps,
);
const updateCurrentWidgetConfig = useRecoilCallback(
({ set }) =>
({
objectMetadataId,
configToUpdate,
}: {
objectMetadataId?: string | null;
configToUpdate?: Partial<PageLayoutWidget['configuration']>;
}) => {
set(pageLayoutDraftCallbackState, (prev) => ({
...prev,
tabs: prev.tabs.map((tab) => ({
...tab,
widgets: tab.widgets.map((widget) =>
widget.id === currentlyEditingWidgetId
? {
...widget,
objectMetadataId:
objectMetadataId ?? widget.objectMetadataId,
configuration: {
...(widget.configuration ?? {}),
...configToUpdate,
} as PageLayoutWidget['configuration'],
}
: widget,
),
})),
}));
},
[pageLayoutDraftCallbackState, currentlyEditingWidgetId],
);
return { updateCurrentWidgetConfig };
};
@@ -0,0 +1,21 @@
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
export const useWidgetInEditMode = (pageLayoutId: string) => {
const draftPageLayout = useRecoilComponentValue(
pageLayoutDraftComponentState,
pageLayoutId,
);
const pageLayoutEditingWidgetId = useRecoilComponentValue(
pageLayoutEditingWidgetIdComponentState,
pageLayoutId,
);
const widgetInEditMode = draftPageLayout.tabs
.flatMap((tab) => tab.widgets)
.find((widget) => widget.id === pageLayoutEditingWidgetId);
return { widgetInEditMode };
};
@@ -0,0 +1,14 @@
import {
type BarChartConfiguration,
type GaugeChartConfiguration,
type LineChartConfiguration,
type NumberChartConfiguration,
type PieChartConfiguration,
} from '~/generated/graphql';
export type ChartConfiguration =
| BarChartConfiguration
| LineChartConfiguration
| PieChartConfiguration
| NumberChartConfiguration
| GaugeChartConfiguration;
@@ -0,0 +1,19 @@
export enum CHART_CONFIGURATION_SETTING_IDS {
SOURCE = 'SOURCE',
DATA_LABELS = 'DATA_LABELS',
COLORS = 'COLORS',
FILTER = 'FILTER',
GROUP_BY = 'GROUP_BY',
SORT_BY_X = 'SORT_BY_X',
SORT_BY_GROUP_BY_FIELD = 'SORT_BY_GROUP_BY_FIELD',
SORT_BY = 'SORT_BY',
DATA_ON_DISPLAY_X = 'DATA_ON_DISPLAY_X',
DATA_ON_DISPLAY_Y = 'DATA_ON_DISPLAY_Y',
DATA_ON_DISPLAY_PIE_CHART = 'DATA_ON_DISPLAY_PIE_CHART',
EACH_SLICE_REPRESENTS = 'EACH_SLICE_REPRESENTS',
AXIS_NAME = 'AXIS_NAME',
}
export const CHART_CONFIGURATION_SETTING_TO_CONFIG_KEY_MAP = {
[CHART_CONFIGURATION_SETTING_IDS.DATA_LABELS]: 'displayDataLabel',
} as const;
@@ -0,0 +1,17 @@
import { type CHART_CONFIGURATION_SETTING_IDS } from '@/command-menu/pages/page-layout/types/ChartConfigurationSettingIds';
import { type MessageDescriptor } from '@lingui/core';
import { type IconComponent } from 'twenty-ui/display';
export type ChartSettingsGroup = {
heading: string;
items: ChartSettingsItem[];
};
export type ChartSettingsItem = {
Icon: IconComponent;
label: MessageDescriptor;
id: CHART_CONFIGURATION_SETTING_IDS;
description?: string;
isBoolean: boolean;
dependsOn?: CHART_CONFIGURATION_SETTING_IDS[];
};
@@ -0,0 +1,18 @@
import { t } from '@lingui/core/macro';
import { assertUnreachable } from 'twenty-shared/utils';
import { AxisNameDisplay } from '~/generated/graphql';
export const getChartAxisNameDisplayOptions = (option: AxisNameDisplay) => {
switch (option) {
case AxisNameDisplay.NONE:
return t`None`;
case AxisNameDisplay.X:
return t`X axis`;
case AxisNameDisplay.Y:
return t`Y axis`;
case AxisNameDisplay.BOTH:
return t`Both`;
default:
assertUnreachable(option);
}
};
@@ -0,0 +1,35 @@
import { ChartAxisNameSelectionDropdownContent } from '@/command-menu/pages/page-layout/components/dropdown-content/ChartAxisNameSelectionDropdownContent';
import { ChartColorSelectionDropdownContent } from '@/command-menu/pages/page-layout/components/dropdown-content/ChartColorSelectionDropdownContent';
import { ChartDataSourceDropdownContent } from '@/command-menu/pages/page-layout/components/dropdown-content/ChartDataSourceDropdownContent';
import { ChartFieldSelectionDropdownContent } from '@/command-menu/pages/page-layout/components/dropdown-content/ChartFieldSelectionDropdownContent';
import { ChartFieldSelectionForAggregateOperationDropdownContent } from '@/command-menu/pages/page-layout/components/dropdown-content/ChartFieldSelectionForAggregateOperationDropdownContent';
import { ChartGroupByFieldSelectionDropdownContent } from '@/command-menu/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionDropdownContent';
import { ChartSortByGroupByFieldDropdownContent } from '@/command-menu/pages/page-layout/components/dropdown-content/ChartSortByGroupByFieldDropdownContent';
import { ChartXAxisSortBySelectionDropdownContent } from '@/command-menu/pages/page-layout/components/dropdown-content/ChartXAxisSortBySelectionDropdownContent';
import { CHART_CONFIGURATION_SETTING_IDS } from '@/command-menu/pages/page-layout/types/ChartConfigurationSettingIds';
import { t } from '@lingui/core/macro';
export const getChartSettingsDropdownContent = (itemId: string) => {
switch (itemId) {
case CHART_CONFIGURATION_SETTING_IDS.SOURCE:
return <ChartDataSourceDropdownContent />;
case CHART_CONFIGURATION_SETTING_IDS.DATA_ON_DISPLAY_X:
return <ChartFieldSelectionDropdownContent />;
case CHART_CONFIGURATION_SETTING_IDS.SORT_BY_X:
return <ChartXAxisSortBySelectionDropdownContent />;
case CHART_CONFIGURATION_SETTING_IDS.DATA_ON_DISPLAY_Y:
return <ChartFieldSelectionForAggregateOperationDropdownContent />;
case CHART_CONFIGURATION_SETTING_IDS.SORT_BY_GROUP_BY_FIELD:
return (
<ChartSortByGroupByFieldDropdownContent title={t`Y-Axis Sort By`} />
);
case CHART_CONFIGURATION_SETTING_IDS.GROUP_BY:
return <ChartGroupByFieldSelectionDropdownContent />;
case CHART_CONFIGURATION_SETTING_IDS.COLORS:
return <ChartColorSelectionDropdownContent />;
case CHART_CONFIGURATION_SETTING_IDS.AXIS_NAME:
return <ChartAxisNameSelectionDropdownContent />;
default:
return <div>Configuration options will be implemented here</div>;
}
};
@@ -0,0 +1,38 @@
import { AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations';
import { DateAggregateOperations } from '@/object-record/record-table/constants/DateAggregateOperations';
import { type ExtendedAggregateOperations as FrontendExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { ExtendedAggregateOperations as GraphQLExtendedAggregateOperations } from '~/generated-metadata/graphql';
const AGGREGATE_OPERATION_MAPPING: Record<
FrontendExtendedAggregateOperations,
GraphQLExtendedAggregateOperations
> = {
[AggregateOperations.MIN]: GraphQLExtendedAggregateOperations.MIN,
[AggregateOperations.MAX]: GraphQLExtendedAggregateOperations.MAX,
[AggregateOperations.AVG]: GraphQLExtendedAggregateOperations.AVG,
[AggregateOperations.SUM]: GraphQLExtendedAggregateOperations.SUM,
[AggregateOperations.COUNT]: GraphQLExtendedAggregateOperations.COUNT,
[AggregateOperations.COUNT_EMPTY]:
GraphQLExtendedAggregateOperations.COUNT_EMPTY,
[AggregateOperations.COUNT_NOT_EMPTY]:
GraphQLExtendedAggregateOperations.COUNT_NOT_EMPTY,
[AggregateOperations.COUNT_UNIQUE_VALUES]:
GraphQLExtendedAggregateOperations.COUNT_UNIQUE_VALUES,
[AggregateOperations.PERCENTAGE_EMPTY]:
GraphQLExtendedAggregateOperations.PERCENTAGE_EMPTY,
[AggregateOperations.PERCENTAGE_NOT_EMPTY]:
GraphQLExtendedAggregateOperations.PERCENTAGE_NOT_EMPTY,
[AggregateOperations.COUNT_TRUE]:
GraphQLExtendedAggregateOperations.COUNT_TRUE,
[AggregateOperations.COUNT_FALSE]:
GraphQLExtendedAggregateOperations.COUNT_FALSE,
[DateAggregateOperations.EARLIEST]:
GraphQLExtendedAggregateOperations.EARLIEST,
[DateAggregateOperations.LATEST]: GraphQLExtendedAggregateOperations.LATEST,
};
export const mapToGraphQLExtendedAggregateOperation = (
operation: FrontendExtendedAggregateOperations,
): GraphQLExtendedAggregateOperations => {
return AGGREGATE_OPERATION_MAPPING[operation];
};