[Dashboards] - Min Max range on secondary axis bar charts (#15118)
video QA https://github.com/user-attachments/assets/70c37188-2398-43de-bbf6-5882bb79940a --------- Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
+92
@@ -0,0 +1,92 @@
|
||||
import { TextInput } from '@/ui/input/components/TextInput';
|
||||
import styled from '@emotion/styled';
|
||||
import { useState } from 'react';
|
||||
import { Key } from 'ts-key-enum';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
canBeCastAsNumberOrNull,
|
||||
castAsNumberOrNull,
|
||||
} from '~/utils/cast-as-number-or-null';
|
||||
|
||||
type CommandMenuItemNumberInputProps = {
|
||||
value: string;
|
||||
onChange: (value: number | null) => void;
|
||||
onValidate?: (value: number | null) => boolean;
|
||||
placeholder?: string;
|
||||
};
|
||||
const StyledRightAlignedTextInput = styled(TextInput)`
|
||||
input {
|
||||
:focus {
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
}
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
text-align: right;
|
||||
}
|
||||
`;
|
||||
export const CommandMenuItemNumberInput = ({
|
||||
value,
|
||||
onChange,
|
||||
onValidate,
|
||||
placeholder,
|
||||
}: CommandMenuItemNumberInputProps) => {
|
||||
const [draftValue, setDraftValue] = useState(value);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
|
||||
const handleChange = (text: string) => {
|
||||
setDraftValue(text);
|
||||
if (hasError) {
|
||||
setHasError(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCommit = () => {
|
||||
if (!canBeCastAsNumberOrNull(draftValue)) {
|
||||
setHasError(true);
|
||||
setDraftValue(value);
|
||||
return;
|
||||
}
|
||||
|
||||
const numericValue = castAsNumberOrNull(draftValue);
|
||||
|
||||
if (isDefined(onValidate)) {
|
||||
const isInvalid = onValidate(numericValue);
|
||||
if (isInvalid) {
|
||||
setHasError(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
onChange(numericValue);
|
||||
setHasError(false);
|
||||
};
|
||||
|
||||
const handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {
|
||||
event.target.select();
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
handleCommit();
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === Key.Enter || event.key === Key.Escape) {
|
||||
event.stopPropagation();
|
||||
handleCommit();
|
||||
} else {
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledRightAlignedTextInput
|
||||
value={draftValue}
|
||||
sizeVariant="sm"
|
||||
onChange={handleChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
noErrorHelper
|
||||
/>
|
||||
);
|
||||
};
|
||||
+45
-123
@@ -1,60 +1,61 @@
|
||||
import { CommandGroup } from '@/command-menu/components/CommandGroup';
|
||||
import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem';
|
||||
import { CommandMenuItemDropdown } from '@/command-menu/components/CommandMenuItemDropdown';
|
||||
import { CommandMenuItemToggle } from '@/command-menu/components/CommandMenuItemToggle';
|
||||
import { CommandMenuList } from '@/command-menu/components/CommandMenuList';
|
||||
import { COMMAND_MENU_LIST_SELECTABLE_LIST_ID } from '@/command-menu/constants/CommandMenuListSelectableListId';
|
||||
import { useUpdateCommandMenuPageInfo } from '@/command-menu/hooks/useUpdateCommandMenuPageInfo';
|
||||
import { ChartSettingItem } from '@/command-menu/pages/page-layout/components/chart-settings/ChartSettingItem';
|
||||
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 { useNavigatePageLayoutCommandMenu } from '@/command-menu/pages/page-layout/hooks/useNavigatePageLayoutCommandMenu';
|
||||
import { usePageLayoutIdFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutFromContextStoreTargetedRecord';
|
||||
import { useUpdateChartSettingInput } from '@/command-menu/pages/page-layout/hooks/useUpdateChartSettingInput';
|
||||
import { useUpdateChartSettingToggle } from '@/command-menu/pages/page-layout/hooks/useUpdateChartSettingToggle';
|
||||
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 { CHART_CONFIGURATION_SETTING_IDS } from '@/command-menu/pages/page-layout/types/ChartConfigurationSettingIds';
|
||||
import { isChartSettingDisabled } from '@/command-menu/pages/page-layout/utils/isChartSettingDisabled';
|
||||
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
|
||||
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
|
||||
import { useOpenDropdown } from '@/ui/layout/dropdown/hooks/useOpenDropdown';
|
||||
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
|
||||
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
|
||||
import {
|
||||
BarChartGroupMode,
|
||||
type GraphType,
|
||||
type PageLayoutWidget,
|
||||
} from '~/generated/graphql';
|
||||
import { type GraphType, type PageLayoutWidget } from '~/generated/graphql';
|
||||
|
||||
export const ChartSettings = ({ widget }: { widget: PageLayoutWidget }) => {
|
||||
const { updateCommandMenuPageInfo } = useUpdateCommandMenuPageInfo();
|
||||
|
||||
const { navigatePageLayoutCommandMenu } = useNavigatePageLayoutCommandMenu();
|
||||
|
||||
const { pageLayoutId } = usePageLayoutIdFromContextStoreTargetedRecord();
|
||||
|
||||
const { updateCurrentWidgetConfig } =
|
||||
useUpdateCurrentWidgetConfig(pageLayoutId);
|
||||
|
||||
const { openDropdown } = useOpenDropdown();
|
||||
const { setSelectedItemId } = useSelectableList(
|
||||
COMMAND_MENU_LIST_SELECTABLE_LIST_ID,
|
||||
);
|
||||
|
||||
if (widget.configuration?.__typename === 'IframeConfiguration') {
|
||||
throw new Error(t`IframeConfiguration is not supported`);
|
||||
}
|
||||
|
||||
const configuration = widget.configuration as ChartConfiguration;
|
||||
const currentGraphType = configuration?.graphType;
|
||||
|
||||
const { getChartSettingsValues } = useChartSettingsValues({
|
||||
objectMetadataId: widget.objectMetadataId,
|
||||
configuration,
|
||||
});
|
||||
|
||||
const currentGraphType = configuration?.graphType;
|
||||
const { updateChartSettingToggle } = useUpdateChartSettingToggle({
|
||||
pageLayoutId,
|
||||
objectMetadataId: widget.objectMetadataId,
|
||||
configuration,
|
||||
});
|
||||
|
||||
const { updateChartSettingInput } = useUpdateChartSettingInput(pageLayoutId);
|
||||
|
||||
const isGroupByEnabled = getChartSettingsValues(
|
||||
CHART_CONFIGURATION_SETTING_IDS.GROUP_BY,
|
||||
);
|
||||
|
||||
const handleGraphTypeChange = (graphType: GraphType) => {
|
||||
updateCurrentWidgetConfig({
|
||||
@@ -71,14 +72,6 @@ export const ChartSettings = ({ widget }: { widget: PageLayoutWidget }) => {
|
||||
|
||||
const chartSettings = GRAPH_TYPE_INFORMATION[currentGraphType].settings;
|
||||
|
||||
const { setSelectedItemId } = useSelectableList(
|
||||
COMMAND_MENU_LIST_SELECTABLE_LIST_ID,
|
||||
);
|
||||
|
||||
const isGroupByEnabled = getChartSettingsValues(
|
||||
CHART_CONFIGURATION_SETTING_IDS.GROUP_BY,
|
||||
);
|
||||
|
||||
return (
|
||||
<CommandMenuList
|
||||
commandGroups={[]}
|
||||
@@ -93,116 +86,45 @@ export const ChartSettings = ({ widget }: { widget: PageLayoutWidget }) => {
|
||||
{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)) ||
|
||||
(!isGroupByEnabled &&
|
||||
item?.dependsOn?.includes(
|
||||
CHART_CONFIGURATION_SETTING_IDS.GROUP_BY,
|
||||
));
|
||||
|
||||
const handleToggleChange = () => {
|
||||
const configKey =
|
||||
item.id in CHART_CONFIGURATION_SETTING_TO_CONFIG_KEY_MAP
|
||||
? CHART_CONFIGURATION_SETTING_TO_CONFIG_KEY_MAP[
|
||||
item.id as keyof typeof CHART_CONFIGURATION_SETTING_TO_CONFIG_KEY_MAP
|
||||
]
|
||||
: item.id;
|
||||
const isDisabled = isChartSettingDisabled(
|
||||
item,
|
||||
widget.objectMetadataId,
|
||||
isGroupByEnabled as boolean,
|
||||
);
|
||||
|
||||
const handleItemToggleChange = () => {
|
||||
setSelectedItemId(item.id);
|
||||
|
||||
if (item.id === CHART_CONFIGURATION_SETTING_IDS.STACKED_BARS) {
|
||||
const isCurrentlyStacked = getChartSettingsValues(item.id);
|
||||
const newGroupMode = isCurrentlyStacked
|
||||
? BarChartGroupMode.GROUPED
|
||||
: BarChartGroupMode.STACKED;
|
||||
|
||||
updateCurrentWidgetConfig({
|
||||
configToUpdate: {
|
||||
groupMode: newGroupMode,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const newValue = !getChartSettingsValues(item.id);
|
||||
|
||||
updateCurrentWidgetConfig({
|
||||
configToUpdate: {
|
||||
[configKey]: newValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
updateChartSettingToggle(item.id);
|
||||
};
|
||||
|
||||
const handleDropdownOpen = () => {
|
||||
const handleItemInputChange = (value: number | null) => {
|
||||
updateChartSettingInput(item.id, value);
|
||||
};
|
||||
|
||||
const handleItemDropdownOpen = () => {
|
||||
openDropdown({
|
||||
dropdownComponentInstanceIdFromProps: item.id,
|
||||
});
|
||||
};
|
||||
|
||||
const handleFilterSettingsClick = () => {
|
||||
const handleFilterClick = () => {
|
||||
navigatePageLayoutCommandMenu({
|
||||
commandMenuPage: CommandMenuPages.PageLayoutGraphFilter,
|
||||
});
|
||||
};
|
||||
|
||||
if (item.id === CHART_CONFIGURATION_SETTING_IDS.FILTER) {
|
||||
return (
|
||||
<SelectableListItem
|
||||
key={item.id}
|
||||
itemId={item.id}
|
||||
onEnter={handleFilterSettingsClick}
|
||||
>
|
||||
<CommandMenuItem
|
||||
id={item.id}
|
||||
label="Filter"
|
||||
Icon={item.Icon}
|
||||
hasSubMenu
|
||||
onClick={handleFilterSettingsClick}
|
||||
/>
|
||||
</SelectableListItem>
|
||||
);
|
||||
}
|
||||
|
||||
return item.isBoolean ? (
|
||||
<SelectableListItem
|
||||
return (
|
||||
<ChartSettingItem
|
||||
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>
|
||||
{item.DropdownContent && <item.DropdownContent />}
|
||||
</DropdownContent>
|
||||
}
|
||||
dropdownPlacement="bottom-end"
|
||||
description={getChartSettingsValues(item.id) as string}
|
||||
contextualTextPosition={'right'}
|
||||
hasSubMenu
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</SelectableListItem>
|
||||
item={item}
|
||||
isDisabled={isDisabled}
|
||||
configuration={configuration}
|
||||
getChartSettingsValues={getChartSettingsValues}
|
||||
onToggleChange={handleItemToggleChange}
|
||||
onInputChange={handleItemInputChange}
|
||||
onDropdownOpen={handleItemDropdownOpen}
|
||||
onFilterClick={handleFilterClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem';
|
||||
import { CommandMenuItemDropdown } from '@/command-menu/components/CommandMenuItemDropdown';
|
||||
import { CommandMenuItemNumberInput } from '@/command-menu/components/CommandMenuItemNumberInput';
|
||||
import { CommandMenuItemToggle } from '@/command-menu/components/CommandMenuItemToggle';
|
||||
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 { type ChartSettingsItem } from '@/command-menu/pages/page-layout/types/ChartSettingsGroup';
|
||||
import { isMinMaxRangeValid } from '@/command-menu/pages/page-layout/utils/isMinMaxRangeValid';
|
||||
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
|
||||
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { isString } from '@sniptt/guards';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type ChartSettingItemProps = {
|
||||
item: ChartSettingsItem;
|
||||
isDisabled: boolean;
|
||||
configuration: ChartConfiguration;
|
||||
getChartSettingsValues: (
|
||||
itemId: CHART_CONFIGURATION_SETTING_IDS,
|
||||
) => boolean | string | undefined;
|
||||
onToggleChange: () => void;
|
||||
onInputChange: (value: number | null) => void;
|
||||
onDropdownOpen: () => void;
|
||||
onFilterClick: () => void;
|
||||
};
|
||||
|
||||
export const ChartSettingItem = ({
|
||||
item,
|
||||
isDisabled,
|
||||
configuration,
|
||||
getChartSettingsValues,
|
||||
onToggleChange,
|
||||
onInputChange,
|
||||
onDropdownOpen,
|
||||
onFilterClick,
|
||||
}: ChartSettingItemProps) => {
|
||||
if (item.id === CHART_CONFIGURATION_SETTING_IDS.FILTER) {
|
||||
return (
|
||||
<SelectableListItem
|
||||
key={item.id}
|
||||
itemId={item.id}
|
||||
onEnter={onFilterClick}
|
||||
>
|
||||
<CommandMenuItem
|
||||
id={item.id}
|
||||
label={t(item.label)}
|
||||
Icon={item.Icon}
|
||||
hasSubMenu
|
||||
onClick={onFilterClick}
|
||||
/>
|
||||
</SelectableListItem>
|
||||
);
|
||||
}
|
||||
|
||||
if (isDefined(item.isInput)) {
|
||||
const settingValue = getChartSettingsValues(item.id);
|
||||
const stringValue = isString(settingValue) ? settingValue : '';
|
||||
|
||||
return (
|
||||
<SelectableListItem key={item.id} itemId={item.id}>
|
||||
<CommandMenuItem
|
||||
id={item.id}
|
||||
label={t(item.label)}
|
||||
Icon={item.Icon}
|
||||
RightComponent={
|
||||
<CommandMenuItemNumberInput
|
||||
value={stringValue}
|
||||
onChange={onInputChange}
|
||||
onValidate={(value) =>
|
||||
isDefined(value) &&
|
||||
isMinMaxRangeValid(
|
||||
item.id as
|
||||
| CHART_CONFIGURATION_SETTING_IDS.MIN_RANGE
|
||||
| CHART_CONFIGURATION_SETTING_IDS.MAX_RANGE,
|
||||
value,
|
||||
configuration,
|
||||
)
|
||||
}
|
||||
placeholder={
|
||||
item.inputPlaceholder ? t(item.inputPlaceholder) : undefined
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SelectableListItem>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.isBoolean) {
|
||||
return (
|
||||
<SelectableListItem
|
||||
key={item.id}
|
||||
itemId={item.id}
|
||||
onEnter={isDisabled ? undefined : onToggleChange}
|
||||
>
|
||||
<CommandMenuItemToggle
|
||||
LeftIcon={item.Icon}
|
||||
text={t(item.label)}
|
||||
id={item.id}
|
||||
toggled={getChartSettingsValues(item.id) as boolean}
|
||||
onToggleChange={onToggleChange}
|
||||
/>
|
||||
</SelectableListItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SelectableListItem
|
||||
key={item.id}
|
||||
itemId={item.id}
|
||||
onEnter={isDisabled ? undefined : onDropdownOpen}
|
||||
>
|
||||
<CommandMenuItemDropdown
|
||||
Icon={item.Icon}
|
||||
label={t(item.label)}
|
||||
id={item.id}
|
||||
dropdownId={item.id}
|
||||
dropdownComponents={
|
||||
<DropdownContent>
|
||||
{item.DropdownContent && <item.DropdownContent />}
|
||||
</DropdownContent>
|
||||
}
|
||||
dropdownPlacement="bottom-end"
|
||||
description={getChartSettingsValues(item.id) as string}
|
||||
contextualTextPosition={'right'}
|
||||
hasSubMenu
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</SelectableListItem>
|
||||
);
|
||||
};
|
||||
+4
@@ -7,6 +7,8 @@ import { DATA_LABELS_SETTING } from '@/command-menu/pages/page-layout/constants/
|
||||
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 { OMIT_NULL_VALUES_SETTING } from '@/command-menu/pages/page-layout/constants/settings/OmitNullValuesSetting';
|
||||
import { RANGE_MAX_SETTING } from '@/command-menu/pages/page-layout/constants/settings/RangeMaxSetting';
|
||||
import { RANGE_MIN_SETTING } from '@/command-menu/pages/page-layout/constants/settings/RangeMinSetting';
|
||||
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';
|
||||
@@ -30,6 +32,8 @@ export const LINE_CHART_SETTINGS: ChartSettingsGroup[] = [
|
||||
DATA_DISPLAY_Y_SETTING,
|
||||
GROUP_BY_SETTING,
|
||||
SORT_BY_GROUP_BY_FIELD_SETTING,
|
||||
RANGE_MIN_SETTING,
|
||||
RANGE_MAX_SETTING,
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
+2
@@ -16,4 +16,6 @@ export const CHART_CONFIGURATION_SETTING_LABELS = {
|
||||
AXIS_NAME: msg`Axis name`,
|
||||
STACKED_BARS: msg`Stacked bars`,
|
||||
OMIT_NULL_VALUES: msg`Omit zero values`,
|
||||
MIN_RANGE: msg`Min range`,
|
||||
MAX_RANGE: msg`Max range`,
|
||||
};
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
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 { msg } from '@lingui/core/macro';
|
||||
import { IconMathMax } from 'twenty-ui/display';
|
||||
|
||||
export const RANGE_MAX_SETTING: ChartSettingsItem = {
|
||||
isBoolean: false,
|
||||
Icon: IconMathMax,
|
||||
label: CHART_CONFIGURATION_SETTING_LABELS.MAX_RANGE,
|
||||
id: CHART_CONFIGURATION_SETTING_IDS.MAX_RANGE,
|
||||
isInput: true,
|
||||
inputPlaceholder: msg`Max`,
|
||||
};
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
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 { msg } from '@lingui/core/macro';
|
||||
import { IconMathMin } from 'twenty-ui/display';
|
||||
|
||||
export const RANGE_MIN_SETTING: ChartSettingsItem = {
|
||||
isBoolean: false,
|
||||
Icon: IconMathMin,
|
||||
label: CHART_CONFIGURATION_SETTING_LABELS.MIN_RANGE,
|
||||
id: CHART_CONFIGURATION_SETTING_IDS.MIN_RANGE,
|
||||
isInput: true,
|
||||
inputPlaceholder: msg`Min`,
|
||||
};
|
||||
+8
@@ -160,6 +160,14 @@ export const useChartSettingsValues = ({
|
||||
return 'omitNullValues' in configuration
|
||||
? (configuration.omitNullValues ?? false)
|
||||
: false;
|
||||
case CHART_CONFIGURATION_SETTING_IDS.MIN_RANGE:
|
||||
return 'rangeMin' in configuration
|
||||
? (configuration.rangeMin?.toString() ?? '')
|
||||
: '';
|
||||
case CHART_CONFIGURATION_SETTING_IDS.MAX_RANGE:
|
||||
return 'rangeMax' in configuration
|
||||
? (configuration.rangeMax?.toString() ?? '')
|
||||
: '';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { type CHART_CONFIGURATION_SETTING_IDS } from '@/command-menu/pages/page-layout/types/ChartConfigurationSettingIds';
|
||||
import { getConfigKeyFromSettingId } from '@/command-menu/pages/page-layout/utils/getConfigKeyFromSettingId';
|
||||
import { useUpdateCurrentWidgetConfig } from './useUpdateCurrentWidgetConfig';
|
||||
|
||||
export const useUpdateChartSettingInput = (pageLayoutId: string) => {
|
||||
const { updateCurrentWidgetConfig } =
|
||||
useUpdateCurrentWidgetConfig(pageLayoutId);
|
||||
|
||||
const updateChartSettingInput = (
|
||||
settingId: CHART_CONFIGURATION_SETTING_IDS,
|
||||
value: number | null,
|
||||
) => {
|
||||
const configKey = getConfigKeyFromSettingId(settingId);
|
||||
|
||||
updateCurrentWidgetConfig({
|
||||
configToUpdate: {
|
||||
[configKey]: value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return { updateChartSettingInput };
|
||||
};
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
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 { getConfigKeyFromSettingId } from '@/command-menu/pages/page-layout/utils/getConfigKeyFromSettingId';
|
||||
import { BarChartGroupMode } from '~/generated/graphql';
|
||||
import { useChartSettingsValues } from './useChartSettingsValues';
|
||||
import { useUpdateCurrentWidgetConfig } from './useUpdateCurrentWidgetConfig';
|
||||
|
||||
export const useUpdateChartSettingToggle = ({
|
||||
pageLayoutId,
|
||||
objectMetadataId,
|
||||
configuration,
|
||||
}: {
|
||||
pageLayoutId: string;
|
||||
objectMetadataId: string;
|
||||
configuration: ChartConfiguration;
|
||||
}) => {
|
||||
const { updateCurrentWidgetConfig } =
|
||||
useUpdateCurrentWidgetConfig(pageLayoutId);
|
||||
|
||||
const { getChartSettingsValues } = useChartSettingsValues({
|
||||
objectMetadataId,
|
||||
configuration,
|
||||
});
|
||||
|
||||
const updateChartSettingToggle = (
|
||||
settingId: CHART_CONFIGURATION_SETTING_IDS,
|
||||
) => {
|
||||
const configKey = getConfigKeyFromSettingId(settingId);
|
||||
|
||||
if (settingId === CHART_CONFIGURATION_SETTING_IDS.STACKED_BARS) {
|
||||
const isCurrentlyStacked = getChartSettingsValues(settingId);
|
||||
const newGroupMode = isCurrentlyStacked
|
||||
? BarChartGroupMode.GROUPED
|
||||
: BarChartGroupMode.STACKED;
|
||||
|
||||
updateCurrentWidgetConfig({
|
||||
configToUpdate: {
|
||||
groupMode: newGroupMode,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const newValue = !getChartSettingsValues(settingId);
|
||||
updateCurrentWidgetConfig({
|
||||
configToUpdate: {
|
||||
[configKey]: newValue,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return { updateChartSettingToggle };
|
||||
};
|
||||
+4
@@ -15,10 +15,14 @@ export enum CHART_CONFIGURATION_SETTING_IDS {
|
||||
AXIS_NAME = 'AXIS_NAME',
|
||||
STACKED_BARS = 'STACKED_BARS',
|
||||
OMIT_NULL_VALUES = 'OMIT_NULL_VALUES',
|
||||
MIN_RANGE = 'MIN_RANGE',
|
||||
MAX_RANGE = 'MAX_RANGE',
|
||||
}
|
||||
|
||||
export const CHART_CONFIGURATION_SETTING_TO_CONFIG_KEY_MAP = {
|
||||
[CHART_CONFIGURATION_SETTING_IDS.DATA_LABELS]: 'displayDataLabel',
|
||||
[CHART_CONFIGURATION_SETTING_IDS.STACKED_BARS]: 'groupMode',
|
||||
[CHART_CONFIGURATION_SETTING_IDS.OMIT_NULL_VALUES]: 'omitNullValues',
|
||||
[CHART_CONFIGURATION_SETTING_IDS.MIN_RANGE]: 'rangeMin',
|
||||
[CHART_CONFIGURATION_SETTING_IDS.MAX_RANGE]: 'rangeMax',
|
||||
} as const;
|
||||
|
||||
+2
@@ -16,4 +16,6 @@ export type ChartSettingsItem = {
|
||||
isBoolean: boolean;
|
||||
dependsOn?: CHART_CONFIGURATION_SETTING_IDS[];
|
||||
DropdownContent?: ComponentType;
|
||||
isInput?: boolean;
|
||||
inputPlaceholder?: MessageDescriptor;
|
||||
};
|
||||
|
||||
+8
-2
@@ -7,6 +7,8 @@ import { DATA_LABELS_SETTING } from '@/command-menu/pages/page-layout/constants/
|
||||
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 { OMIT_NULL_VALUES_SETTING } from '@/command-menu/pages/page-layout/constants/settings/OmitNullValuesSetting';
|
||||
import { RANGE_MAX_SETTING } from '@/command-menu/pages/page-layout/constants/settings/RangeMaxSetting';
|
||||
import { RANGE_MIN_SETTING } from '@/command-menu/pages/page-layout/constants/settings/RangeMinSetting';
|
||||
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 { STACKED_BARS_SETTING } from '@/command-menu/pages/page-layout/constants/settings/StackedBarsSetting';
|
||||
@@ -36,12 +38,14 @@ describe('getBarChartSettings', () => {
|
||||
const yAxisGroup = result.find((group) => group.heading === 'Y axis');
|
||||
|
||||
expect(yAxisGroup).toBeDefined();
|
||||
expect(yAxisGroup?.items).toHaveLength(3);
|
||||
expect(yAxisGroup?.items).toHaveLength(5);
|
||||
expect(yAxisGroup?.items[0].id).toBe(DATA_DISPLAY_Y_SETTING.id);
|
||||
expect(yAxisGroup?.items[0].label).toBe(DATA_DISPLAY_Y_SETTING.label);
|
||||
expect(yAxisGroup?.items[0].Icon).toBe(IconAxisY);
|
||||
expect(yAxisGroup?.items[1]).toEqual(GROUP_BY_SETTING);
|
||||
expect(yAxisGroup?.items[2]).toEqual(SORT_BY_GROUP_BY_FIELD_SETTING);
|
||||
expect(yAxisGroup?.items[3]).toEqual(RANGE_MIN_SETTING);
|
||||
expect(yAxisGroup?.items[4]).toEqual(RANGE_MAX_SETTING);
|
||||
});
|
||||
|
||||
it('should have all expected groups in correct order', () => {
|
||||
@@ -62,12 +66,14 @@ describe('getBarChartSettings', () => {
|
||||
const xAxisGroup = result.find((group) => group.heading === 'X axis');
|
||||
|
||||
expect(xAxisGroup).toBeDefined();
|
||||
expect(xAxisGroup?.items).toHaveLength(3);
|
||||
expect(xAxisGroup?.items).toHaveLength(5);
|
||||
expect(xAxisGroup?.items[0].id).toBe(DATA_DISPLAY_Y_SETTING.id);
|
||||
expect(xAxisGroup?.items[0].label).toBe(DATA_DISPLAY_Y_SETTING.label);
|
||||
expect(xAxisGroup?.items[0].Icon).toBe(IconAxisX);
|
||||
expect(xAxisGroup?.items[1]).toEqual(GROUP_BY_SETTING);
|
||||
expect(xAxisGroup?.items[2]).toEqual(SORT_BY_GROUP_BY_FIELD_SETTING);
|
||||
expect(xAxisGroup?.items[3]).toEqual(RANGE_MIN_SETTING);
|
||||
expect(xAxisGroup?.items[4]).toEqual(RANGE_MAX_SETTING);
|
||||
});
|
||||
|
||||
it('should place PRIMARY axis items under "Y axis" heading', () => {
|
||||
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
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 { msg } from '@lingui/core/macro';
|
||||
import { IconChartBar } from 'twenty-ui/display';
|
||||
import { isChartSettingDisabled } from '../isChartSettingDisabled';
|
||||
|
||||
describe('isChartSettingDisabled', () => {
|
||||
const mockItemWithoutDependencies: ChartSettingsItem = {
|
||||
id: CHART_CONFIGURATION_SETTING_IDS.DATA_LABELS,
|
||||
label: msg`Data Labels`,
|
||||
Icon: IconChartBar,
|
||||
isBoolean: true,
|
||||
isInput: false,
|
||||
};
|
||||
|
||||
const mockItemDependingOnSource: ChartSettingsItem = {
|
||||
id: CHART_CONFIGURATION_SETTING_IDS.DATA_ON_DISPLAY_X,
|
||||
label: msg`X Axis Data`,
|
||||
Icon: IconChartBar,
|
||||
isBoolean: false,
|
||||
isInput: false,
|
||||
dependsOn: [CHART_CONFIGURATION_SETTING_IDS.SOURCE],
|
||||
};
|
||||
|
||||
const mockItemDependingOnGroupBy: ChartSettingsItem = {
|
||||
id: CHART_CONFIGURATION_SETTING_IDS.SORT_BY_GROUP_BY_FIELD,
|
||||
label: msg`Sort By Group`,
|
||||
Icon: IconChartBar,
|
||||
isBoolean: false,
|
||||
isInput: false,
|
||||
dependsOn: [CHART_CONFIGURATION_SETTING_IDS.GROUP_BY],
|
||||
};
|
||||
|
||||
const mockItemWithMultipleDependencies: ChartSettingsItem = {
|
||||
id: CHART_CONFIGURATION_SETTING_IDS.DATA_ON_DISPLAY_X,
|
||||
label: msg`X Axis Data`,
|
||||
Icon: IconChartBar,
|
||||
isBoolean: false,
|
||||
isInput: false,
|
||||
dependsOn: [
|
||||
CHART_CONFIGURATION_SETTING_IDS.SOURCE,
|
||||
CHART_CONFIGURATION_SETTING_IDS.GROUP_BY,
|
||||
],
|
||||
};
|
||||
|
||||
describe('item without dependencies', () => {
|
||||
it('should return false when item has no dependencies', () => {
|
||||
const result = isChartSettingDisabled(
|
||||
mockItemWithoutDependencies,
|
||||
'valid-object-id',
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when item has no dependencies even with no object metadata', () => {
|
||||
const result = isChartSettingDisabled(
|
||||
mockItemWithoutDependencies,
|
||||
'',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('item depending on SOURCE', () => {
|
||||
it('should return true when no object metadata and item depends on SOURCE', () => {
|
||||
const result = isChartSettingDisabled(
|
||||
mockItemDependingOnSource,
|
||||
'',
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when object metadata exists and item depends on SOURCE', () => {
|
||||
const result = isChartSettingDisabled(
|
||||
mockItemDependingOnSource,
|
||||
'valid-object-id',
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('item depending on GROUP_BY', () => {
|
||||
it('should return true when group by is not enabled and item depends on GROUP_BY', () => {
|
||||
const result = isChartSettingDisabled(
|
||||
mockItemDependingOnGroupBy,
|
||||
'valid-object-id',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when group by is enabled and item depends on GROUP_BY', () => {
|
||||
const result = isChartSettingDisabled(
|
||||
mockItemDependingOnGroupBy,
|
||||
'valid-object-id',
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('item with multiple dependencies', () => {
|
||||
it('should return true if any dependency is not met (no object metadata)', () => {
|
||||
const result = isChartSettingDisabled(
|
||||
mockItemWithMultipleDependencies,
|
||||
'',
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if any dependency is not met (group by disabled)', () => {
|
||||
const result = isChartSettingDisabled(
|
||||
mockItemWithMultipleDependencies,
|
||||
'valid-object-id',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if all dependencies are met', () => {
|
||||
const result = isChartSettingDisabled(
|
||||
mockItemWithMultipleDependencies,
|
||||
'valid-object-id',
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle undefined dependsOn array', () => {
|
||||
const itemWithUndefinedDependsOn: ChartSettingsItem = {
|
||||
id: CHART_CONFIGURATION_SETTING_IDS.DATA_LABELS,
|
||||
label: msg`Data Labels`,
|
||||
Icon: IconChartBar,
|
||||
isBoolean: true,
|
||||
isInput: false,
|
||||
dependsOn: undefined,
|
||||
};
|
||||
|
||||
const result = isChartSettingDisabled(
|
||||
itemWithUndefinedDependsOn,
|
||||
'',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle empty dependsOn array', () => {
|
||||
const itemWithEmptyDependsOn: ChartSettingsItem = {
|
||||
id: CHART_CONFIGURATION_SETTING_IDS.DATA_LABELS,
|
||||
label: msg`Data Labels`,
|
||||
Icon: IconChartBar,
|
||||
isBoolean: true,
|
||||
isInput: false,
|
||||
dependsOn: [],
|
||||
};
|
||||
|
||||
const result = isChartSettingDisabled(itemWithEmptyDependsOn, '', false);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
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 { isMinMaxRangeValid } from '@/command-menu/pages/page-layout/utils/isMinMaxRangeValid';
|
||||
import { GraphType } from '~/generated/graphql';
|
||||
|
||||
describe('isMinMaxRangeValid', () => {
|
||||
const mockConfiguration = {
|
||||
__typename: 'BarChartConfiguration',
|
||||
graphType: GraphType.VERTICAL_BAR,
|
||||
rangeMin: 10,
|
||||
rangeMax: 100,
|
||||
} as ChartConfiguration;
|
||||
|
||||
describe('MIN_RANGE validation', () => {
|
||||
it('should be valid when new rangeMin is less than existing rangeMax', () => {
|
||||
const result = isMinMaxRangeValid(
|
||||
CHART_CONFIGURATION_SETTING_IDS.MIN_RANGE,
|
||||
50,
|
||||
mockConfiguration,
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should be valid when new rangeMin equals existing rangeMax', () => {
|
||||
const result = isMinMaxRangeValid(
|
||||
CHART_CONFIGURATION_SETTING_IDS.MIN_RANGE,
|
||||
100,
|
||||
mockConfiguration,
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should be invalid when new rangeMin is greater than existing rangeMax', () => {
|
||||
const result = isMinMaxRangeValid(
|
||||
CHART_CONFIGURATION_SETTING_IDS.MIN_RANGE,
|
||||
150,
|
||||
mockConfiguration,
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MAX_RANGE validation', () => {
|
||||
it('should be valid when new rangeMax is greater than existing rangeMin', () => {
|
||||
const result = isMinMaxRangeValid(
|
||||
CHART_CONFIGURATION_SETTING_IDS.MAX_RANGE,
|
||||
50,
|
||||
mockConfiguration,
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should be valid when new rangeMax equals existing rangeMin', () => {
|
||||
const result = isMinMaxRangeValid(
|
||||
CHART_CONFIGURATION_SETTING_IDS.MAX_RANGE,
|
||||
10,
|
||||
mockConfiguration,
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should be invalid when new rangeMax is less than existing rangeMin', () => {
|
||||
const result = isMinMaxRangeValid(
|
||||
CHART_CONFIGURATION_SETTING_IDS.MAX_RANGE,
|
||||
5,
|
||||
mockConfiguration,
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
describe('edge cases', () => {
|
||||
it('should be valid when new rangeMin is negative and greater than existing rangeMax', () => {
|
||||
const configWithNegatives = {
|
||||
__typename: 'BarChartConfiguration',
|
||||
graphType: GraphType.VERTICAL_BAR,
|
||||
rangeMin: -100,
|
||||
rangeMax: -10,
|
||||
} as ChartConfiguration;
|
||||
|
||||
const validMin = isMinMaxRangeValid(
|
||||
CHART_CONFIGURATION_SETTING_IDS.MIN_RANGE,
|
||||
-50,
|
||||
configWithNegatives,
|
||||
);
|
||||
expect(validMin).toBe(true);
|
||||
|
||||
const invalidMin = isMinMaxRangeValid(
|
||||
CHART_CONFIGURATION_SETTING_IDS.MIN_RANGE,
|
||||
-5,
|
||||
configWithNegatives,
|
||||
);
|
||||
expect(invalidMin).toBe(false);
|
||||
});
|
||||
|
||||
it('should be valid when new rangeMin is zero and greater than existing rangeMax', () => {
|
||||
const configWithZero = {
|
||||
__typename: 'BarChartConfiguration',
|
||||
graphType: GraphType.VERTICAL_BAR,
|
||||
rangeMin: 0,
|
||||
rangeMax: 100,
|
||||
} as ChartConfiguration;
|
||||
|
||||
const result = isMinMaxRangeValid(
|
||||
CHART_CONFIGURATION_SETTING_IDS.MIN_RANGE,
|
||||
0,
|
||||
configWithZero,
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
+4
@@ -7,6 +7,8 @@ import { DATA_LABELS_SETTING } from '@/command-menu/pages/page-layout/constants/
|
||||
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 { OMIT_NULL_VALUES_SETTING } from '@/command-menu/pages/page-layout/constants/settings/OmitNullValuesSetting';
|
||||
import { RANGE_MAX_SETTING } from '@/command-menu/pages/page-layout/constants/settings/RangeMaxSetting';
|
||||
import { RANGE_MIN_SETTING } from '@/command-menu/pages/page-layout/constants/settings/RangeMinSetting';
|
||||
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 { STACKED_BARS_SETTING } from '@/command-menu/pages/page-layout/constants/settings/StackedBarsSetting';
|
||||
@@ -32,6 +34,8 @@ export const getBarChartSettings = (
|
||||
{ ...DATA_DISPLAY_Y_SETTING, Icon: dataDisplayYIcon },
|
||||
GROUP_BY_SETTING,
|
||||
SORT_BY_GROUP_BY_FIELD_SETTING,
|
||||
RANGE_MIN_SETTING,
|
||||
RANGE_MAX_SETTING,
|
||||
];
|
||||
|
||||
const xAxisItems = isHorizontal ? secondaryAxisItems : primaryAxisItems;
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import {
|
||||
type CHART_CONFIGURATION_SETTING_IDS,
|
||||
CHART_CONFIGURATION_SETTING_TO_CONFIG_KEY_MAP,
|
||||
} from '@/command-menu/pages/page-layout/types/ChartConfigurationSettingIds';
|
||||
|
||||
export const getConfigKeyFromSettingId = (
|
||||
settingId: CHART_CONFIGURATION_SETTING_IDS,
|
||||
): string => {
|
||||
return settingId in CHART_CONFIGURATION_SETTING_TO_CONFIG_KEY_MAP
|
||||
? CHART_CONFIGURATION_SETTING_TO_CONFIG_KEY_MAP[
|
||||
settingId as keyof typeof CHART_CONFIGURATION_SETTING_TO_CONFIG_KEY_MAP
|
||||
]
|
||||
: settingId;
|
||||
};
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
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 { isNonEmptyString } from '@sniptt/guards';
|
||||
|
||||
export const isChartSettingDisabled = (
|
||||
item: ChartSettingsItem,
|
||||
objectMetadataId: string,
|
||||
isGroupByEnabled: boolean,
|
||||
): boolean => {
|
||||
const hasNoObjectMetadata = !isNonEmptyString(objectMetadataId);
|
||||
const dependsOnSource = item?.dependsOn?.includes(
|
||||
CHART_CONFIGURATION_SETTING_IDS.SOURCE,
|
||||
);
|
||||
const dependsOnGroupBy = item?.dependsOn?.includes(
|
||||
CHART_CONFIGURATION_SETTING_IDS.GROUP_BY,
|
||||
);
|
||||
|
||||
return (
|
||||
(hasNoObjectMetadata && (dependsOnSource ?? false)) ||
|
||||
(!isGroupByEnabled && (dependsOnGroupBy ?? false))
|
||||
);
|
||||
};
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
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 { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const isMinMaxRangeValid = (
|
||||
settingId:
|
||||
| CHART_CONFIGURATION_SETTING_IDS.MIN_RANGE
|
||||
| CHART_CONFIGURATION_SETTING_IDS.MAX_RANGE,
|
||||
newValue: number,
|
||||
configuration: ChartConfiguration,
|
||||
): boolean => {
|
||||
if (!('rangeMax' in configuration || 'rangeMin' in configuration)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (settingId === CHART_CONFIGURATION_SETTING_IDS.MIN_RANGE) {
|
||||
if (
|
||||
isDefined(configuration.rangeMax) &&
|
||||
newValue > configuration.rangeMax
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (settingId === CHART_CONFIGURATION_SETTING_IDS.MAX_RANGE) {
|
||||
if (
|
||||
isDefined(configuration.rangeMin) &&
|
||||
newValue < configuration.rangeMin
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
+10
-1
@@ -40,6 +40,8 @@ type GraphWidgetBarChartProps = {
|
||||
layout?: 'vertical' | 'horizontal';
|
||||
groupMode?: 'grouped' | 'stacked';
|
||||
seriesLabels?: Record<string, string>;
|
||||
rangeMin?: number;
|
||||
rangeMax?: number;
|
||||
} & GraphValueFormatOptions;
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
@@ -65,6 +67,8 @@ export const GraphWidgetBarChart = ({
|
||||
layout = 'vertical',
|
||||
groupMode = 'grouped',
|
||||
seriesLabels,
|
||||
rangeMin,
|
||||
rangeMax,
|
||||
displayType,
|
||||
decimals,
|
||||
prefix,
|
||||
@@ -180,7 +184,12 @@ export const GraphWidgetBarChart = ({
|
||||
padding={0.3}
|
||||
groupMode={groupMode}
|
||||
layout={layout}
|
||||
valueScale={{ type: 'linear' }}
|
||||
valueScale={{
|
||||
type: 'linear',
|
||||
min: rangeMin ?? 'auto',
|
||||
max: rangeMax ?? 'auto',
|
||||
clamp: true,
|
||||
}}
|
||||
indexScale={{ type: 'band', round: true }}
|
||||
colors={(datum) => getBarChartColor(datum, barConfigs, theme)}
|
||||
defs={defs}
|
||||
|
||||
+18
-5
@@ -1,6 +1,6 @@
|
||||
import { ChartSkeletonLoader } from '@/page-layout/widgets/graph/components/ChartSkeletonLoader';
|
||||
import { useGraphBarChartWidgetData } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useGraphBarChartWidgetData';
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { lazy, Suspense, useMemo } from 'react';
|
||||
import {
|
||||
type BarChartConfiguration,
|
||||
type PageLayoutWidget,
|
||||
@@ -34,17 +34,28 @@ export const GraphWidgetBarChartRenderer = ({
|
||||
configuration: widget.configuration as BarChartConfiguration,
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return <ChartSkeletonLoader />;
|
||||
}
|
||||
|
||||
const configuration = widget.configuration as BarChartConfiguration;
|
||||
const groupMode =
|
||||
configuration.groupMode === 'GROUPED' ? 'grouped' : 'stacked';
|
||||
|
||||
const filterStateKey = useMemo(
|
||||
() =>
|
||||
`${configuration.rangeMin ?? ''}-${configuration.rangeMax ?? ''}-${configuration.omitNullValues ?? ''}`,
|
||||
[
|
||||
configuration.rangeMin,
|
||||
configuration.rangeMax,
|
||||
configuration.omitNullValues,
|
||||
],
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return <ChartSkeletonLoader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<ChartSkeletonLoader />}>
|
||||
<GraphWidgetBarChart
|
||||
key={filterStateKey}
|
||||
data={data}
|
||||
series={series}
|
||||
indexBy={indexBy}
|
||||
@@ -56,6 +67,8 @@ export const GraphWidgetBarChartRenderer = ({
|
||||
groupMode={groupMode}
|
||||
id={widget.id}
|
||||
displayType="shortNumber"
|
||||
rangeMin={configuration.rangeMin ?? undefined}
|
||||
rangeMax={configuration.rangeMax ?? undefined}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
-6
@@ -6,14 +6,12 @@ exports[`generateGroupByQuery should generate valid GraphQL query for empty aggr
|
||||
$groupBy: [PersonGroupByInput!]
|
||||
$filter: PersonFilterInput
|
||||
$orderBy: [PersonOrderByWithGroupByInput!]
|
||||
$omitNullValues: Boolean
|
||||
$viewId: UUID
|
||||
) {
|
||||
peopleGroupBy(
|
||||
groupBy: $groupBy
|
||||
filter: $filter
|
||||
orderBy: $orderBy
|
||||
omitNullValues: $omitNullValues
|
||||
viewId: $viewId
|
||||
) {
|
||||
groupByDimensionValues
|
||||
@@ -28,14 +26,12 @@ exports[`generateGroupByQuery should generate valid GraphQL query for multiple a
|
||||
$groupBy: [OpportunityGroupByInput!]
|
||||
$filter: OpportunityFilterInput
|
||||
$orderBy: [OpportunityOrderByWithGroupByInput!]
|
||||
$omitNullValues: Boolean
|
||||
$viewId: UUID
|
||||
) {
|
||||
opportunitiesGroupBy(
|
||||
groupBy: $groupBy
|
||||
filter: $filter
|
||||
orderBy: $orderBy
|
||||
omitNullValues: $omitNullValues
|
||||
viewId: $viewId
|
||||
) {
|
||||
groupByDimensionValues
|
||||
@@ -53,14 +49,12 @@ exports[`generateGroupByQuery should generate valid GraphQL query for single agg
|
||||
$groupBy: [OpportunityGroupByInput!]
|
||||
$filter: OpportunityFilterInput
|
||||
$orderBy: [OpportunityOrderByWithGroupByInput!]
|
||||
$omitNullValues: Boolean
|
||||
$viewId: UUID
|
||||
) {
|
||||
opportunitiesGroupBy(
|
||||
groupBy: $groupBy
|
||||
filter: $filter
|
||||
orderBy: $orderBy
|
||||
omitNullValues: $omitNullValues
|
||||
viewId: $viewId
|
||||
) {
|
||||
groupByDimensionValues
|
||||
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
import { AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { filterGroupByResults } from '../filterGroupByResults';
|
||||
|
||||
describe('filterGroupByResults', () => {
|
||||
const mockAggregateField = {
|
||||
id: 'field-1',
|
||||
name: 'amount',
|
||||
type: FieldMetadataType.NUMBER,
|
||||
label: 'Amount',
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-01-01',
|
||||
};
|
||||
|
||||
const mockObjectMetadataItem = {
|
||||
id: 'obj-1',
|
||||
nameSingular: 'opportunity',
|
||||
namePlural: 'opportunities',
|
||||
fields: [mockAggregateField],
|
||||
} as any;
|
||||
|
||||
const createMockResult = (value: number | null) => ({
|
||||
groupByDimensionValues: ['Group A'],
|
||||
SUM_amount: value,
|
||||
});
|
||||
|
||||
describe('rangeMin filtering', () => {
|
||||
it('should filter out results below rangeMin', () => {
|
||||
const rawResults = [
|
||||
createMockResult(500),
|
||||
createMockResult(1500),
|
||||
createMockResult(2500),
|
||||
];
|
||||
|
||||
const filtered = filterGroupByResults({
|
||||
rawResults,
|
||||
filterOptions: { rangeMin: 1000 },
|
||||
aggregateField: mockAggregateField,
|
||||
aggregateOperation: AggregateOperations.SUM,
|
||||
aggregateOperationFromRawResult: 'SUM_amount',
|
||||
objectMetadataItem: mockObjectMetadataItem,
|
||||
});
|
||||
|
||||
expect(filtered).toHaveLength(2);
|
||||
expect(filtered[0].SUM_amount).toBe(1500);
|
||||
expect(filtered[1].SUM_amount).toBe(2500);
|
||||
});
|
||||
|
||||
it('should include values equal to rangeMin', () => {
|
||||
const rawResults = [
|
||||
createMockResult(500),
|
||||
createMockResult(1000),
|
||||
createMockResult(1500),
|
||||
];
|
||||
|
||||
const filtered = filterGroupByResults({
|
||||
rawResults,
|
||||
filterOptions: { rangeMin: 1000 },
|
||||
aggregateField: mockAggregateField,
|
||||
aggregateOperation: AggregateOperations.SUM,
|
||||
aggregateOperationFromRawResult: 'SUM_amount',
|
||||
objectMetadataItem: mockObjectMetadataItem,
|
||||
});
|
||||
|
||||
expect(filtered).toHaveLength(2);
|
||||
expect(filtered[0].SUM_amount).toBe(1000);
|
||||
expect(filtered[1].SUM_amount).toBe(1500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rangeMax filtering', () => {
|
||||
it('should filter out results above rangeMax', () => {
|
||||
const rawResults = [
|
||||
createMockResult(500),
|
||||
createMockResult(1500),
|
||||
createMockResult(2500),
|
||||
];
|
||||
|
||||
const filtered = filterGroupByResults({
|
||||
rawResults,
|
||||
filterOptions: { rangeMax: 2000 },
|
||||
aggregateField: mockAggregateField,
|
||||
aggregateOperation: AggregateOperations.SUM,
|
||||
aggregateOperationFromRawResult: 'SUM_amount',
|
||||
objectMetadataItem: mockObjectMetadataItem,
|
||||
});
|
||||
|
||||
expect(filtered).toHaveLength(2);
|
||||
expect(filtered[0].SUM_amount).toBe(500);
|
||||
expect(filtered[1].SUM_amount).toBe(1500);
|
||||
});
|
||||
|
||||
it('should include values equal to rangeMax', () => {
|
||||
const rawResults = [
|
||||
createMockResult(1500),
|
||||
createMockResult(2000),
|
||||
createMockResult(2500),
|
||||
];
|
||||
|
||||
const filtered = filterGroupByResults({
|
||||
rawResults,
|
||||
filterOptions: { rangeMax: 2000 },
|
||||
aggregateField: mockAggregateField,
|
||||
aggregateOperation: AggregateOperations.SUM,
|
||||
aggregateOperationFromRawResult: 'SUM_amount',
|
||||
objectMetadataItem: mockObjectMetadataItem,
|
||||
});
|
||||
|
||||
expect(filtered).toHaveLength(2);
|
||||
expect(filtered[0].SUM_amount).toBe(1500);
|
||||
expect(filtered[1].SUM_amount).toBe(2000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('range filtering', () => {
|
||||
it('should keep only results within range', () => {
|
||||
const rawResults = [
|
||||
createMockResult(500),
|
||||
createMockResult(1500),
|
||||
createMockResult(2500),
|
||||
];
|
||||
|
||||
const filtered = filterGroupByResults({
|
||||
rawResults,
|
||||
filterOptions: { rangeMin: 1000, rangeMax: 2000 },
|
||||
aggregateField: mockAggregateField,
|
||||
aggregateOperation: AggregateOperations.SUM,
|
||||
aggregateOperationFromRawResult: 'SUM_amount',
|
||||
objectMetadataItem: mockObjectMetadataItem,
|
||||
});
|
||||
|
||||
expect(filtered).toHaveLength(1);
|
||||
expect(filtered[0].SUM_amount).toBe(1500);
|
||||
});
|
||||
|
||||
it('should include boundary values', () => {
|
||||
const rawResults = [
|
||||
createMockResult(500),
|
||||
createMockResult(1000),
|
||||
createMockResult(1500),
|
||||
createMockResult(2000),
|
||||
createMockResult(2500),
|
||||
];
|
||||
|
||||
const filtered = filterGroupByResults({
|
||||
rawResults,
|
||||
filterOptions: { rangeMin: 1000, rangeMax: 2000 },
|
||||
aggregateField: mockAggregateField,
|
||||
aggregateOperation: AggregateOperations.SUM,
|
||||
aggregateOperationFromRawResult: 'SUM_amount',
|
||||
objectMetadataItem: mockObjectMetadataItem,
|
||||
});
|
||||
|
||||
expect(filtered).toHaveLength(3);
|
||||
expect(filtered[0].SUM_amount).toBe(1000);
|
||||
expect(filtered[1].SUM_amount).toBe(1500);
|
||||
expect(filtered[2].SUM_amount).toBe(2000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('no filters', () => {
|
||||
it('should return all results when no filters are active', () => {
|
||||
const rawResults = [
|
||||
createMockResult(500),
|
||||
createMockResult(1500),
|
||||
createMockResult(2500),
|
||||
];
|
||||
|
||||
const filtered = filterGroupByResults({
|
||||
rawResults,
|
||||
filterOptions: {},
|
||||
aggregateField: mockAggregateField,
|
||||
aggregateOperation: AggregateOperations.SUM,
|
||||
aggregateOperationFromRawResult: 'SUM_amount',
|
||||
objectMetadataItem: mockObjectMetadataItem,
|
||||
});
|
||||
|
||||
expect(filtered).toEqual(rawResults);
|
||||
});
|
||||
|
||||
it('should return all results when omitNullValues is false', () => {
|
||||
const rawResults = [
|
||||
createMockResult(null),
|
||||
createMockResult(0),
|
||||
createMockResult(100),
|
||||
];
|
||||
|
||||
const filtered = filterGroupByResults({
|
||||
rawResults,
|
||||
filterOptions: { omitNullValues: false },
|
||||
aggregateField: mockAggregateField,
|
||||
aggregateOperation: AggregateOperations.SUM,
|
||||
aggregateOperationFromRawResult: 'SUM_amount',
|
||||
objectMetadataItem: mockObjectMetadataItem,
|
||||
});
|
||||
|
||||
expect(filtered).toEqual(rawResults);
|
||||
});
|
||||
});
|
||||
});
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
|
||||
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
|
||||
import { computeAggregateValueFromGroupByResult } from '@/page-layout/widgets/graph/utils/computeAggregateValueFromGroupByResult';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export type GroupByResultFilterOptions = {
|
||||
rangeMin?: number | null;
|
||||
rangeMax?: number | null;
|
||||
omitNullValues?: boolean;
|
||||
};
|
||||
|
||||
type FilterGroupByResultsParams = {
|
||||
rawResults: GroupByRawResult[];
|
||||
filterOptions: GroupByResultFilterOptions;
|
||||
aggregateField: FieldMetadataItem;
|
||||
aggregateOperation: ExtendedAggregateOperations;
|
||||
aggregateOperationFromRawResult: string;
|
||||
objectMetadataItem: ObjectMetadataItem;
|
||||
};
|
||||
|
||||
export const filterGroupByResults = ({
|
||||
rawResults,
|
||||
filterOptions,
|
||||
aggregateField,
|
||||
aggregateOperation,
|
||||
aggregateOperationFromRawResult,
|
||||
objectMetadataItem,
|
||||
}: FilterGroupByResultsParams): GroupByRawResult[] => {
|
||||
const { rangeMin, rangeMax, omitNullValues } = filterOptions;
|
||||
|
||||
const hasActiveFilters =
|
||||
isDefined(rangeMin) || isDefined(rangeMax) || omitNullValues === true;
|
||||
|
||||
if (!hasActiveFilters) {
|
||||
return rawResults;
|
||||
}
|
||||
|
||||
return rawResults.filter((result) => {
|
||||
const aggregateValue = computeAggregateValueFromGroupByResult({
|
||||
rawResult: result,
|
||||
aggregateField,
|
||||
aggregateOperation,
|
||||
aggregateOperationFromRawResult,
|
||||
objectMetadataItem,
|
||||
});
|
||||
|
||||
if (omitNullValues === true && !isDefined(aggregateValue)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (omitNullValues === true && aggregateValue === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof aggregateValue === 'number') {
|
||||
if (isDefined(rangeMin) && aggregateValue < rangeMin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isDefined(rangeMax) && aggregateValue > rangeMax) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
};
|
||||
-2
@@ -20,14 +20,12 @@ export const generateGroupByQuery = ({
|
||||
$groupBy: [${capitalizedSingular}GroupByInput!]
|
||||
$filter: ${capitalizedSingular}FilterInput
|
||||
$orderBy: [${capitalizedSingular}OrderByWithGroupByInput!]
|
||||
$omitNullValues: Boolean
|
||||
$viewId: UUID
|
||||
) {
|
||||
${queryFieldName}(
|
||||
groupBy: $groupBy
|
||||
filter: $filter
|
||||
orderBy: $orderBy
|
||||
omitNullValues: $omitNullValues
|
||||
viewId: $viewId
|
||||
) {
|
||||
groupByDimensionValues${aggregateOperations.length > 0 ? `\n ${aggregateOperations.join('\n ')}` : ''}
|
||||
|
||||
-1
@@ -101,6 +101,5 @@ export const generateGroupByQueryVariablesFromBarChartConfiguration = ({
|
||||
return {
|
||||
groupBy,
|
||||
...(orderBy.length > 0 && { orderBy }),
|
||||
...(barChartConfiguration.omitNullValues ? { omitNullValues: true } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
+18
-2
@@ -1,10 +1,12 @@
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
|
||||
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
|
||||
import { getGroupByQueryName } from '@/page-layout/utils/getGroupByQueryName';
|
||||
import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem';
|
||||
import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries';
|
||||
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
|
||||
import { filterGroupByResults } from '@/page-layout/widgets/graph/utils/filterGroupByResults';
|
||||
import { getFieldKey } from '@/page-layout/widgets/graph/utils/getFieldKey';
|
||||
import { transformOneDimensionalGroupByToBarChartData } from '@/page-layout/widgets/graph/utils/transformOneDimensionalGroupByToBarChartData';
|
||||
import { transformTwoDimensionalGroupByToBarChartData } from '@/page-layout/widgets/graph/utils/transformTwoDimensionalGroupByToBarChartData';
|
||||
@@ -105,6 +107,20 @@ export const transformGroupByDataToBarChartData = ({
|
||||
};
|
||||
}
|
||||
|
||||
const filteredResults = filterGroupByResults({
|
||||
rawResults,
|
||||
filterOptions: {
|
||||
rangeMin: configuration.rangeMin ?? undefined,
|
||||
rangeMax: configuration.rangeMax ?? undefined,
|
||||
omitNullValues: configuration.omitNullValues ?? false,
|
||||
},
|
||||
aggregateField,
|
||||
aggregateOperation:
|
||||
configuration.aggregateOperation as unknown as ExtendedAggregateOperations,
|
||||
aggregateOperationFromRawResult: aggregateOperation,
|
||||
objectMetadataItem,
|
||||
});
|
||||
|
||||
const showXAxis =
|
||||
configuration.axisNameDisplay === AxisNameDisplay.X ||
|
||||
configuration.axisNameDisplay === AxisNameDisplay.BOTH;
|
||||
@@ -123,7 +139,7 @@ export const transformGroupByDataToBarChartData = ({
|
||||
|
||||
const baseResult = isDefined(groupByFieldY)
|
||||
? transformTwoDimensionalGroupByToBarChartData({
|
||||
rawResults,
|
||||
rawResults: filteredResults,
|
||||
groupByFieldX,
|
||||
groupByFieldY,
|
||||
aggregateField,
|
||||
@@ -133,7 +149,7 @@ export const transformGroupByDataToBarChartData = ({
|
||||
primaryAxisSubFieldName,
|
||||
})
|
||||
: transformOneDimensionalGroupByToBarChartData({
|
||||
rawResults,
|
||||
rawResults: filteredResults,
|
||||
groupByFieldX,
|
||||
aggregateField,
|
||||
configuration,
|
||||
|
||||
-27
@@ -29,7 +29,6 @@ import {
|
||||
} from 'src/engine/api/common/types/common-query-args.type';
|
||||
import { isWorkspaceAuthContext } from 'src/engine/api/common/utils/is-workspace-auth-context.util';
|
||||
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
|
||||
import { computeIsNumericReturningAggregate } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/compute-is-numeric-returning-aggregate.util';
|
||||
import { formatResultWithGroupByDimensionValues } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/format-result-with-group-by-dimension-values.util';
|
||||
import { getGroupByExpression } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-expression.util';
|
||||
import { isGroupByDateField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field.util';
|
||||
@@ -169,32 +168,6 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic
|
||||
}
|
||||
});
|
||||
|
||||
if (processedArgs.omitNullValues) {
|
||||
const aggregateFields = selectedFieldsResult.aggregate ?? {};
|
||||
|
||||
Object.values(aggregateFields).forEach((aggregationField) => {
|
||||
const aggregateExpression =
|
||||
ProcessAggregateHelper.getAggregateExpression(
|
||||
aggregationField,
|
||||
objectMetadataNameSingular,
|
||||
);
|
||||
|
||||
if (aggregateExpression) {
|
||||
queryBuilder.andHaving(`${aggregateExpression} IS NOT NULL`);
|
||||
|
||||
const isNumericReturningAggregate =
|
||||
computeIsNumericReturningAggregate(
|
||||
aggregationField.aggregateOperation,
|
||||
aggregationField.fromFieldType,
|
||||
);
|
||||
|
||||
if (isNumericReturningAggregate) {
|
||||
queryBuilder.andHaving(`${aggregateExpression} != 0`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
commonQueryParser.applyGroupByOrderToBuilder(
|
||||
queryBuilder,
|
||||
processedArgs.orderBy ?? [],
|
||||
|
||||
@@ -49,7 +49,6 @@ export interface GroupByQueryArgs {
|
||||
filter?: ObjectRecordFilter;
|
||||
orderBy?: OrderByWithGroupBy;
|
||||
groupBy: ObjectRecordGroupBy;
|
||||
omitNullValues?: boolean;
|
||||
viewId?: string;
|
||||
}
|
||||
|
||||
|
||||
-28
@@ -25,7 +25,6 @@ import { IGroupByConnection } from 'src/engine/api/graphql/workspace-query-runne
|
||||
import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
|
||||
import { GroupByResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
|
||||
|
||||
import { computeIsNumericReturningAggregate } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/compute-is-numeric-returning-aggregate.util';
|
||||
import { formatResultWithGroupByDimensionValues } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/format-result-with-group-by-dimension-values.util';
|
||||
import { getGroupByExpression } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-expression.util';
|
||||
import { isGroupByDateField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field.util';
|
||||
@@ -137,33 +136,6 @@ export class GraphqlQueryGroupByResolverService extends GraphqlQueryBaseResolver
|
||||
}
|
||||
});
|
||||
|
||||
if (executionArgs.args.omitNullValues) {
|
||||
const aggregateFields =
|
||||
executionArgs.graphqlQuerySelectedFieldsResult.aggregate ?? {};
|
||||
|
||||
Object.values(aggregateFields).forEach((aggregationField) => {
|
||||
const aggregateExpression =
|
||||
ProcessAggregateHelper.getAggregateExpression(
|
||||
aggregationField,
|
||||
objectMetadataNameSingular,
|
||||
);
|
||||
|
||||
if (aggregateExpression) {
|
||||
queryBuilder.andHaving(`${aggregateExpression} IS NOT NULL`);
|
||||
|
||||
const isNumericReturningAggregate =
|
||||
computeIsNumericReturningAggregate(
|
||||
aggregationField.aggregateOperation,
|
||||
aggregationField.fromFieldType,
|
||||
);
|
||||
|
||||
if (isNumericReturningAggregate) {
|
||||
queryBuilder.andHaving(`${aggregateExpression} != 0`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
executionArgs.graphqlQueryParser.applyGroupByOrderToBuilder(
|
||||
queryBuilder,
|
||||
executionArgs.args.orderBy ?? [],
|
||||
|
||||
-1
@@ -63,7 +63,6 @@ export interface GroupByResolverArgs<Filter = ObjectRecordFilter> {
|
||||
groupBy: ObjectRecordGroupBy;
|
||||
viewId?: string;
|
||||
orderBy?: OrderByWithGroupBy;
|
||||
omitNullValues?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateOneResolverArgs<
|
||||
|
||||
+1
-4
@@ -177,10 +177,7 @@ export const getResolverArgs = (
|
||||
isNullable: true,
|
||||
isArray: true,
|
||||
},
|
||||
omitNullValues: {
|
||||
type: GraphQLBoolean,
|
||||
isNullable: true,
|
||||
},
|
||||
|
||||
viewId: {
|
||||
type: UUIDScalarType,
|
||||
isNullable: true,
|
||||
|
||||
+2
-12
@@ -6,7 +6,6 @@ import { CommonGroupByQueryRunnerService } from 'src/engine/api/common/common-qu
|
||||
import { parseAggregateFieldsRestRequest } from 'src/engine/api/rest/input-request-parsers/aggregate-fields-parser-utils/parse-aggregate-fields-rest-request.util';
|
||||
import { parseFilterRestRequest } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/parse-filter-rest-request.util';
|
||||
import { parseGroupByRestRequest } from 'src/engine/api/rest/input-request-parsers/group-by-parser-utils/parse-group-by-rest-request.util';
|
||||
import { parseOmitNullValuesRestRequest } from 'src/engine/api/rest/input-request-parsers/omit-null-values-parser-utils/parse-omit-null-values-rest-request.util';
|
||||
import { parseOrderByWithGroupByRestRequest } from 'src/engine/api/rest/input-request-parsers/order-by-with-group-by-parser-utils/parse-order-by-with-group-by-rest-request.util';
|
||||
import { parseViewIdRestRequest } from 'src/engine/api/rest/input-request-parsers/view-id-parser-utils/parse-view-id-rest-request.util';
|
||||
import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
|
||||
@@ -28,14 +27,8 @@ export class RestApiGroupByHandler extends RestApiBaseHandler {
|
||||
objectMetadataMaps,
|
||||
} = await this.buildCommonOptions(request);
|
||||
|
||||
const {
|
||||
filter,
|
||||
orderBy,
|
||||
viewId,
|
||||
groupBy,
|
||||
selectedFields,
|
||||
omitNullValues,
|
||||
} = this.parseRequestArgs(request);
|
||||
const { filter, orderBy, viewId, groupBy, selectedFields } =
|
||||
this.parseRequestArgs(request);
|
||||
|
||||
return await this.commonGroupByQueryRunnerService.run({
|
||||
args: {
|
||||
@@ -44,7 +37,6 @@ export class RestApiGroupByHandler extends RestApiBaseHandler {
|
||||
viewId,
|
||||
groupBy,
|
||||
selectedFields,
|
||||
omitNullValues,
|
||||
},
|
||||
authContext,
|
||||
objectMetadataMaps,
|
||||
@@ -61,7 +53,6 @@ export class RestApiGroupByHandler extends RestApiBaseHandler {
|
||||
const viewId = parseViewIdRestRequest(request);
|
||||
const groupBy = parseGroupByRestRequest(request);
|
||||
const aggregateFields = parseAggregateFieldsRestRequest(request);
|
||||
const omitNullValues = parseOmitNullValuesRestRequest(request);
|
||||
const selectedFields = { ...aggregateFields, groupByDimensionValues: true };
|
||||
|
||||
return {
|
||||
@@ -70,7 +61,6 @@ export class RestApiGroupByHandler extends RestApiBaseHandler {
|
||||
viewId,
|
||||
groupBy,
|
||||
selectedFields,
|
||||
omitNullValues,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
-187
@@ -1,6 +1,5 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import { COMPANY_GQL_FIELDS } from 'test/integration/constants/company-gql-fields.constants';
|
||||
import { PERSON_GQL_FIELDS } from 'test/integration/constants/person-gql-fields.constants';
|
||||
import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util';
|
||||
import { createViewFilterGroupOperationFactory } from 'test/integration/graphql/utils/create-view-filter-group-operation-factory.util';
|
||||
@@ -612,190 +611,4 @@ describe('group-by resolvers (integration)', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('omitNullValues', () => {
|
||||
describe('numeric aggregates', () => {
|
||||
const zeroGroupCompanyId1 = randomUUID();
|
||||
const zeroGroupCompanyId2 = randomUUID();
|
||||
const positiveGroupCompanyId = randomUUID();
|
||||
const zeroCity = 'ZeroCity';
|
||||
const positiveCity = 'PositiveCity';
|
||||
|
||||
beforeAll(async () => {
|
||||
await makeGraphqlAPIRequest(
|
||||
createOneOperationFactory({
|
||||
objectMetadataSingularName: 'company',
|
||||
gqlFields: COMPANY_GQL_FIELDS,
|
||||
data: {
|
||||
id: zeroGroupCompanyId1,
|
||||
name: 'Zero City One',
|
||||
address: { addressCity: zeroCity },
|
||||
employees: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await makeGraphqlAPIRequest(
|
||||
createOneOperationFactory({
|
||||
objectMetadataSingularName: 'company',
|
||||
gqlFields: COMPANY_GQL_FIELDS,
|
||||
data: {
|
||||
id: zeroGroupCompanyId2,
|
||||
name: 'Zero City Two',
|
||||
address: { addressCity: zeroCity },
|
||||
employees: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await makeGraphqlAPIRequest(
|
||||
createOneOperationFactory({
|
||||
objectMetadataSingularName: 'company',
|
||||
gqlFields: COMPANY_GQL_FIELDS,
|
||||
data: {
|
||||
id: positiveGroupCompanyId,
|
||||
name: 'Positive City One',
|
||||
address: { addressCity: positiveCity },
|
||||
employees: 8,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const id of [
|
||||
zeroGroupCompanyId1,
|
||||
zeroGroupCompanyId2,
|
||||
positiveGroupCompanyId,
|
||||
]) {
|
||||
await makeGraphqlAPIRequest(
|
||||
destroyOneOperationFactory({
|
||||
objectMetadataSingularName: 'company',
|
||||
gqlFields: 'id',
|
||||
recordId: id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('filters out groups with zero numeric aggregates when enabled', async () => {
|
||||
const baseOperation = {
|
||||
objectMetadataSingularName: 'company',
|
||||
objectMetadataPluralName: 'companies',
|
||||
groupBy: [{ address: { addressCity: true } }],
|
||||
gqlFields: `
|
||||
sumEmployees
|
||||
`,
|
||||
};
|
||||
|
||||
const responseWithoutFilter = await makeGraphqlAPIRequest(
|
||||
groupByOperationFactory(baseOperation),
|
||||
);
|
||||
|
||||
expect(responseWithoutFilter.body.errors).toBeUndefined();
|
||||
|
||||
const groupsWithoutFilter =
|
||||
responseWithoutFilter.body.data.companiesGroupBy;
|
||||
|
||||
const zeroAggregateGroup = groupsWithoutFilter.find(
|
||||
(group: any) => group.groupByDimensionValues?.[0] === zeroCity,
|
||||
);
|
||||
|
||||
expect(zeroAggregateGroup).toBeDefined();
|
||||
expect(zeroAggregateGroup.sumEmployees).toBe(0);
|
||||
|
||||
const responseWithFilter = await makeGraphqlAPIRequest(
|
||||
groupByOperationFactory({
|
||||
...baseOperation,
|
||||
omitNullValues: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(responseWithFilter.body.errors).toBeUndefined();
|
||||
|
||||
const groupsWithFilter = responseWithFilter.body.data.companiesGroupBy;
|
||||
|
||||
expect(
|
||||
groupsWithFilter.find(
|
||||
(group: any) => group.groupByDimensionValues?.[0] === zeroCity,
|
||||
),
|
||||
).toBeUndefined();
|
||||
|
||||
const positiveAggregateGroup = groupsWithFilter.find(
|
||||
(group: any) => group.groupByDimensionValues?.[0] === positiveCity,
|
||||
);
|
||||
|
||||
expect(positiveAggregateGroup).toBeDefined();
|
||||
expect(positiveAggregateGroup.sumEmployees).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-numeric aggregates', () => {
|
||||
const dateCityPersonId1 = randomUUID();
|
||||
const dateCityPersonId2 = randomUUID();
|
||||
const dateCity = 'DateCity';
|
||||
|
||||
beforeAll(async () => {
|
||||
await makeGraphqlAPIRequest(
|
||||
createOneOperationFactory({
|
||||
objectMetadataSingularName: 'person',
|
||||
gqlFields: PERSON_GQL_FIELDS,
|
||||
data: {
|
||||
id: dateCityPersonId1,
|
||||
name: { firstName: 'Date', lastName: 'One' },
|
||||
city: dateCity,
|
||||
createdAt: '2025-04-01T00:00:00.000Z',
|
||||
},
|
||||
}),
|
||||
);
|
||||
await makeGraphqlAPIRequest(
|
||||
createOneOperationFactory({
|
||||
objectMetadataSingularName: 'person',
|
||||
gqlFields: PERSON_GQL_FIELDS,
|
||||
data: {
|
||||
id: dateCityPersonId2,
|
||||
name: { firstName: 'Date', lastName: 'Two' },
|
||||
city: dateCity,
|
||||
createdAt: '2025-04-02T00:00:00.000Z',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const id of [dateCityPersonId1, dateCityPersonId2]) {
|
||||
await makeGraphqlAPIRequest(
|
||||
destroyOneOperationFactory({
|
||||
objectMetadataSingularName: 'person',
|
||||
gqlFields: 'id',
|
||||
recordId: id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not throw error for date aggregates when enabled', async () => {
|
||||
const response = await makeGraphqlAPIRequest(
|
||||
groupByOperationFactory({
|
||||
objectMetadataSingularName: 'person',
|
||||
objectMetadataPluralName: 'people',
|
||||
groupBy: [{ city: true }],
|
||||
gqlFields: `
|
||||
minCreatedAt
|
||||
`,
|
||||
omitNullValues: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.body.errors).toBeUndefined();
|
||||
|
||||
const groups = response.body.data.peopleGroupBy;
|
||||
|
||||
const dateCityGroup = groups.find(
|
||||
(group: any) => group.groupByDimensionValues?.[0] === dateCity,
|
||||
);
|
||||
|
||||
expect(dateCityGroup).toBeDefined();
|
||||
expect(dateCityGroup.minCreatedAt).toBe('2025-04-01T00:00:00.000Z');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+2
-5
@@ -9,7 +9,6 @@ type GroupByOperationFactoryParams = {
|
||||
orderBy?: object[];
|
||||
viewId?: string;
|
||||
gqlFields?: string;
|
||||
omitNullValues?: boolean;
|
||||
};
|
||||
|
||||
export const groupByOperationFactory = ({
|
||||
@@ -20,11 +19,10 @@ export const groupByOperationFactory = ({
|
||||
orderBy = [],
|
||||
viewId,
|
||||
gqlFields,
|
||||
omitNullValues,
|
||||
}: GroupByOperationFactoryParams) => ({
|
||||
query: gql`
|
||||
query ${capitalize(objectMetadataPluralName)}GroupBy($groupBy: [${capitalize(objectMetadataSingularName)}GroupByInput!]!, $filter: ${capitalize(objectMetadataSingularName)}FilterInput, $orderBy: [${capitalize(objectMetadataSingularName)}OrderByWithGroupByInput!], $viewId: UUID, $omitNullValues: Boolean) {
|
||||
${objectMetadataPluralName}GroupBy(groupBy: $groupBy, filter: $filter, orderBy: $orderBy, viewId: $viewId, omitNullValues: $omitNullValues) {
|
||||
query ${capitalize(objectMetadataPluralName)}GroupBy($groupBy: [${capitalize(objectMetadataSingularName)}GroupByInput!]!, $filter: ${capitalize(objectMetadataSingularName)}FilterInput, $orderBy: [${capitalize(objectMetadataSingularName)}OrderByWithGroupByInput!], $viewId: UUID) {
|
||||
${objectMetadataPluralName}GroupBy(groupBy: $groupBy, filter: $filter, orderBy: $orderBy, viewId: $viewId) {
|
||||
${gqlFields ? gqlFields : ''}
|
||||
groupByDimensionValues
|
||||
totalCount
|
||||
@@ -36,6 +34,5 @@ export const groupByOperationFactory = ({
|
||||
filter,
|
||||
orderBy,
|
||||
...(viewId && { viewId }),
|
||||
omitNullValues: omitNullValues ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -234,6 +234,8 @@ export {
|
||||
IconMailCog,
|
||||
IconMailX,
|
||||
IconMap,
|
||||
IconMathMax,
|
||||
IconMathMin,
|
||||
IconMathXy,
|
||||
IconMaximize,
|
||||
IconMessage,
|
||||
|
||||
@@ -297,6 +297,8 @@ export {
|
||||
IconMailCog,
|
||||
IconMailX,
|
||||
IconMap,
|
||||
IconMathMax,
|
||||
IconMathMin,
|
||||
IconMathXy,
|
||||
IconMaximize,
|
||||
IconMessage,
|
||||
|
||||
Reference in New Issue
Block a user