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
@@ -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>
</>
);
};