Update bar chart design (#15372)
- Implement new dynamic color palettes - Create custom bar component with rounded edges https://github.com/user-attachments/assets/8246f16d-0239-4807-bb4a-9647367575f2
This commit is contained in:
+1
-1
@@ -117,7 +117,7 @@ export const ChartSettingItem = ({
|
||||
id={item.id}
|
||||
dropdownId={item.id}
|
||||
dropdownComponents={
|
||||
<DropdownContent>
|
||||
<DropdownContent widthInPixels={item.dropdownWidth}>
|
||||
{item.DropdownContent && <item.DropdownContent />}
|
||||
</DropdownContent>
|
||||
}
|
||||
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
import { createGraphColorRegistry } from '@/page-layout/widgets/graph/utils/createGraphColorRegistry';
|
||||
import { generateGroupColor } from '@/page-layout/widgets/graph/utils/generateGroupColor';
|
||||
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { ColorSample } from 'twenty-ui/display';
|
||||
import { MenuItemSelect } from 'twenty-ui/navigation';
|
||||
import { type ThemeColor } from 'twenty-ui/theme';
|
||||
|
||||
type ChartColorGradientOptionProps = {
|
||||
colorOption: {
|
||||
id: string;
|
||||
name: string;
|
||||
colorName: ThemeColor | 'auto';
|
||||
};
|
||||
selectedItemId: string | null;
|
||||
currentColor: string | null | undefined;
|
||||
onSelectColor: (colorName: ThemeColor | 'auto') => void;
|
||||
};
|
||||
|
||||
const StyledColorSamplesContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.spacing(0.5)};
|
||||
`;
|
||||
|
||||
const COLOR_GROUP_COUNT = 5;
|
||||
|
||||
export const ChartColorGradientOption = ({
|
||||
colorOption,
|
||||
selectedItemId,
|
||||
currentColor,
|
||||
onSelectColor,
|
||||
}: ChartColorGradientOptionProps) => {
|
||||
const colorName = colorOption.colorName as ThemeColor;
|
||||
const theme = useTheme();
|
||||
const colorRegistry = createGraphColorRegistry(theme);
|
||||
|
||||
const colorSamples = (
|
||||
<StyledColorSamplesContainer>
|
||||
{Array.from({ length: COLOR_GROUP_COUNT }).map((_, index) => {
|
||||
const colorScheme = colorRegistry[colorName];
|
||||
const reversedIndex = COLOR_GROUP_COUNT - 1 - index;
|
||||
const groupColor = generateGroupColor({
|
||||
colorScheme,
|
||||
groupIndex: reversedIndex,
|
||||
totalGroups: COLOR_GROUP_COUNT,
|
||||
});
|
||||
return <ColorSample key={index} color={groupColor} />;
|
||||
})}
|
||||
</StyledColorSamplesContainer>
|
||||
);
|
||||
|
||||
return (
|
||||
<SelectableListItem
|
||||
key={colorOption.id}
|
||||
itemId={colorOption.id}
|
||||
onEnter={() => {
|
||||
onSelectColor(colorOption.colorName);
|
||||
}}
|
||||
>
|
||||
<MenuItemSelect
|
||||
text={colorOption.name}
|
||||
selected={false}
|
||||
focused={
|
||||
selectedItemId === colorOption.id || currentColor === colorOption.id
|
||||
}
|
||||
contextualText={colorSamples}
|
||||
contextualTextPosition="right"
|
||||
onClick={() => {
|
||||
onSelectColor(colorOption.colorName);
|
||||
}}
|
||||
/>
|
||||
</SelectableListItem>
|
||||
);
|
||||
};
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
|
||||
import styled from '@emotion/styled';
|
||||
import { ColorSample } from 'twenty-ui/display';
|
||||
import { MenuItemSelect } from 'twenty-ui/navigation';
|
||||
import { MAIN_COLORS, type ThemeColor } from 'twenty-ui/theme';
|
||||
|
||||
type ChartColorPaletteOptionProps = {
|
||||
selectedItemId: string | null;
|
||||
currentColor: string | null | undefined;
|
||||
onSelectColor: (colorName: ThemeColor | 'auto') => void;
|
||||
};
|
||||
|
||||
const StyledColorSamplesContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.spacing(0.5)};
|
||||
`;
|
||||
|
||||
export const ChartColorPaletteOption = ({
|
||||
selectedItemId,
|
||||
currentColor,
|
||||
onSelectColor,
|
||||
}: ChartColorPaletteOptionProps) => {
|
||||
const paletteColors: Array<keyof typeof MAIN_COLORS> = [
|
||||
'purple',
|
||||
'pink',
|
||||
'red',
|
||||
'orange',
|
||||
'yellow',
|
||||
];
|
||||
|
||||
const colorSamples = (
|
||||
<StyledColorSamplesContainer>
|
||||
{paletteColors.map((paletteColorName) => {
|
||||
const baseColor = MAIN_COLORS[paletteColorName];
|
||||
return <ColorSample key={paletteColorName} color={baseColor} />;
|
||||
})}
|
||||
</StyledColorSamplesContainer>
|
||||
);
|
||||
|
||||
return (
|
||||
<SelectableListItem
|
||||
key={'auto'}
|
||||
itemId={'auto'}
|
||||
onEnter={() => {
|
||||
onSelectColor('auto');
|
||||
}}
|
||||
>
|
||||
<MenuItemSelect
|
||||
text={'Palette'}
|
||||
selected={false}
|
||||
focused={selectedItemId === 'auto' || currentColor === 'auto'}
|
||||
contextualText={colorSamples}
|
||||
contextualTextPosition="right"
|
||||
onClick={() => {
|
||||
onSelectColor('auto');
|
||||
}}
|
||||
/>
|
||||
</SelectableListItem>
|
||||
);
|
||||
};
|
||||
+56
-30
@@ -1,24 +1,30 @@
|
||||
import { ChartColorGradientOption } from '@/command-menu/pages/page-layout/components/dropdown-content/ChartColorGradientOption';
|
||||
import { ChartColorPaletteOption } from '@/command-menu/pages/page-layout/components/dropdown-content/ChartColorPaletteOption';
|
||||
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 { 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 { 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';
|
||||
|
||||
type ColorOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
colorName: ThemeColor | 'auto';
|
||||
};
|
||||
|
||||
export const ChartColorSelectionDropdownContent = () => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const { pageLayoutId } = usePageLayoutIdFromContextStoreTargetedRecord();
|
||||
@@ -54,11 +60,18 @@ export const ChartColorSelectionDropdownContent = () => {
|
||||
|
||||
const currentColor = configuration.color;
|
||||
|
||||
const colorOptions = MAIN_COLOR_NAMES.map((colorName) => ({
|
||||
id: colorName,
|
||||
name: capitalize(colorName),
|
||||
colorName: colorName,
|
||||
}));
|
||||
const colorOptions: ColorOption[] = [
|
||||
{
|
||||
id: 'auto',
|
||||
name: 'Palette',
|
||||
colorName: 'auto',
|
||||
},
|
||||
...MAIN_COLOR_NAMES.map((colorName) => ({
|
||||
id: colorName,
|
||||
name: capitalize(colorName),
|
||||
colorName: colorName,
|
||||
})),
|
||||
];
|
||||
|
||||
const filteredColorOptions = filterBySearchQuery({
|
||||
items: colorOptions,
|
||||
@@ -66,7 +79,7 @@ export const ChartColorSelectionDropdownContent = () => {
|
||||
getSearchableValues: (item) => [item.name],
|
||||
});
|
||||
|
||||
const handleSelectColor = (colorName: ThemeColor) => {
|
||||
const handleSelectColor = (colorName: ThemeColor | 'auto') => {
|
||||
updateCurrentWidgetConfig({
|
||||
configToUpdate: {
|
||||
color: colorName,
|
||||
@@ -75,6 +88,10 @@ export const ChartColorSelectionDropdownContent = () => {
|
||||
closeDropdown();
|
||||
};
|
||||
|
||||
const regularColorOptions = filteredColorOptions.filter(
|
||||
(option) => option.colorName !== 'auto',
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuSearchInput
|
||||
@@ -84,6 +101,7 @@ export const ChartColorSelectionDropdownContent = () => {
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
value={searchQuery}
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItemsContainer>
|
||||
<SelectableList
|
||||
selectableListInstanceId={dropdownId}
|
||||
@@ -92,29 +110,37 @@ export const ChartColorSelectionDropdownContent = () => {
|
||||
(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>
|
||||
))}
|
||||
<ChartColorPaletteOption
|
||||
selectedItemId={selectedItemId}
|
||||
currentColor={currentColor}
|
||||
onSelectColor={handleSelectColor}
|
||||
/>
|
||||
</SelectableList>
|
||||
</DropdownMenuItemsContainer>
|
||||
{regularColorOptions.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItemsContainer>
|
||||
<SelectableList
|
||||
selectableListInstanceId={dropdownId}
|
||||
focusId={dropdownId}
|
||||
selectableItemIdArray={filteredColorOptions.map(
|
||||
(colorOption) => colorOption.id,
|
||||
)}
|
||||
>
|
||||
{regularColorOptions.map((colorOption) => (
|
||||
<ChartColorGradientOption
|
||||
key={colorOption.id}
|
||||
colorOption={colorOption}
|
||||
selectedItemId={selectedItemId}
|
||||
currentColor={currentColor}
|
||||
onSelectColor={handleSelectColor}
|
||||
/>
|
||||
))}
|
||||
</SelectableList>
|
||||
</DropdownMenuItemsContainer>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+2
@@ -2,6 +2,7 @@ import { ChartColorSelectionDropdownContent } from '@/command-menu/pages/page-la
|
||||
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 { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
|
||||
import { IconColorSwatch } from 'twenty-ui/display';
|
||||
|
||||
export const COLORS_SETTING: ChartSettingsItem = {
|
||||
@@ -10,4 +11,5 @@ export const COLORS_SETTING: ChartSettingsItem = {
|
||||
label: CHART_CONFIGURATION_SETTING_LABELS.COLORS,
|
||||
id: CHART_CONFIGURATION_SETTING_IDS.COLORS,
|
||||
DropdownContent: ChartColorSelectionDropdownContent,
|
||||
dropdownWidth: GenericDropdownContentWidth.ExtraLarge,
|
||||
};
|
||||
|
||||
+1
@@ -16,6 +16,7 @@ export type ChartSettingsItem = {
|
||||
isBoolean: boolean;
|
||||
dependsOn?: CHART_CONFIGURATION_SETTING_IDS[];
|
||||
DropdownContent?: ComponentType;
|
||||
dropdownWidth?: number;
|
||||
isInput?: boolean;
|
||||
inputPlaceholder?: MessageDescriptor;
|
||||
};
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const GRAPH_GROUP_COLOR_MINIMUM_ALPHA = 0.2;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const GRAPH_MAXIMUM_NUMBER_OF_GROUP_COLORS = 10;
|
||||
-45
@@ -1,45 +0,0 @@
|
||||
import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem';
|
||||
import { type BarChartEnrichedKey } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartEnrichedKey';
|
||||
import { calculateBarChartEndLineCoordinates } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateBarChartEndLineCoordinates';
|
||||
import { type ComputedBarDatum } from '@nivo/bar';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type BarChartEndLinesProps = {
|
||||
bars: readonly ComputedBarDatum<BarChartDataItem>[];
|
||||
enrichedKeysMap: Map<string, BarChartEnrichedKey>;
|
||||
layout: 'vertical' | 'horizontal';
|
||||
};
|
||||
|
||||
export const BarChartEndLines = ({
|
||||
bars,
|
||||
enrichedKeysMap,
|
||||
layout,
|
||||
}: BarChartEndLinesProps) => {
|
||||
return (
|
||||
<g>
|
||||
{bars.map((bar: ComputedBarDatum<BarChartDataItem>, index: number) => {
|
||||
const enrichedKey = enrichedKeysMap.get(String(bar.data.id));
|
||||
if (!isDefined(enrichedKey)) {
|
||||
return null;
|
||||
}
|
||||
const lineColor = enrichedKey.colorScheme.solid;
|
||||
const { x1, y1, x2, y2 } = calculateBarChartEndLineCoordinates(
|
||||
bar,
|
||||
layout,
|
||||
);
|
||||
|
||||
return (
|
||||
<line
|
||||
key={`${bar.data.id}-${bar.data.indexValue}-endline-${index}`}
|
||||
x1={x1}
|
||||
y1={y1}
|
||||
x2={x2}
|
||||
y2={y2}
|
||||
stroke={lineColor}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
import { BAR_CHART_HOVER_BRIGHTNESS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartHoverBrightness';
|
||||
import { type BarDatum, type BarItemProps } from '@nivo/bar';
|
||||
import { Text } from '@nivo/text';
|
||||
import { useTheme } from '@nivo/theming';
|
||||
import { useTooltip } from '@nivo/tooltip';
|
||||
import { animated, to } from '@react-spring/web';
|
||||
import { isNumber } from '@sniptt/guards';
|
||||
import { createElement, useCallback, useMemo, type MouseEvent } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type CustomBarItemProps<D extends BarDatum> = BarItemProps<D> & {
|
||||
keys?: string[];
|
||||
groupMode?: 'grouped' | 'stacked';
|
||||
data?: readonly D[];
|
||||
indexBy?: string;
|
||||
layout?: 'vertical' | 'horizontal';
|
||||
};
|
||||
|
||||
const StyledBarRect = styled(animated.rect)<{ $isInteractive?: boolean }>`
|
||||
cursor: ${({ $isInteractive }) => ($isInteractive ? 'pointer' : 'default')};
|
||||
transition: filter 0.15s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
filter: ${({ $isInteractive }) =>
|
||||
$isInteractive ? `brightness(${BAR_CHART_HOVER_BRIGHTNESS})` : 'none'};
|
||||
}
|
||||
`;
|
||||
|
||||
// This is a copy of the BarItem component from @nivo/bar with some design modifications
|
||||
export const CustomBarItem = <D extends BarDatum>({
|
||||
bar: { data: barData, ...bar },
|
||||
style: {
|
||||
borderColor,
|
||||
color,
|
||||
height,
|
||||
labelColor,
|
||||
labelOpacity,
|
||||
labelX,
|
||||
labelY,
|
||||
transform,
|
||||
width,
|
||||
textAnchor,
|
||||
},
|
||||
borderRadius,
|
||||
borderWidth,
|
||||
label,
|
||||
shouldRenderLabel,
|
||||
isInteractive,
|
||||
onClick,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
tooltip,
|
||||
isFocusable,
|
||||
ariaLabel,
|
||||
ariaLabelledBy,
|
||||
ariaDescribedBy,
|
||||
ariaDisabled,
|
||||
ariaHidden,
|
||||
keys,
|
||||
groupMode = 'grouped',
|
||||
data: chartData,
|
||||
indexBy,
|
||||
layout = 'vertical',
|
||||
}: CustomBarItemProps<D>) => {
|
||||
const theme = useTheme();
|
||||
const { showTooltipFromEvent, showTooltipAt, hideTooltip } = useTooltip();
|
||||
|
||||
const renderTooltip = useMemo(
|
||||
() => () => createElement(tooltip, { ...bar, ...barData }),
|
||||
[tooltip, bar, barData],
|
||||
);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(event: MouseEvent<SVGRectElement>) => {
|
||||
onClick?.({ color: bar.color, ...barData }, event);
|
||||
},
|
||||
[bar, barData, onClick],
|
||||
);
|
||||
const handleTooltip = useCallback(
|
||||
(event: MouseEvent<SVGRectElement>) =>
|
||||
showTooltipFromEvent(renderTooltip(), event),
|
||||
[showTooltipFromEvent, renderTooltip],
|
||||
);
|
||||
const handleMouseEnter = useCallback(
|
||||
(event: MouseEvent<SVGRectElement>) => {
|
||||
onMouseEnter?.(barData, event);
|
||||
showTooltipFromEvent(renderTooltip(), event);
|
||||
},
|
||||
[barData, onMouseEnter, showTooltipFromEvent, renderTooltip],
|
||||
);
|
||||
const handleMouseLeave = useCallback(
|
||||
(event: MouseEvent<SVGRectElement>) => {
|
||||
onMouseLeave?.(barData, event);
|
||||
hideTooltip();
|
||||
},
|
||||
[barData, hideTooltip, onMouseLeave],
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
showTooltipAt(renderTooltip(), [bar.absX + bar.width / 2, bar.absY]);
|
||||
}, [showTooltipAt, renderTooltip, bar]);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
hideTooltip();
|
||||
}, [hideTooltip]);
|
||||
|
||||
const isTopBar = useMemo(() => {
|
||||
const isStackedAndValid =
|
||||
groupMode === 'stacked' &&
|
||||
isDefined(keys) &&
|
||||
keys.length > 0 &&
|
||||
isDefined(chartData) &&
|
||||
isDefined(indexBy);
|
||||
|
||||
if (!isStackedAndValid) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const dataPoint = chartData.find(
|
||||
(data) => data[indexBy] === barData.indexValue,
|
||||
);
|
||||
|
||||
if (!isDefined(dataPoint)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const currentKeyIndex = keys.findIndex((key) => key === barData.id);
|
||||
|
||||
if (currentKeyIndex === -1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const keysAboveCurrentKey = keys.slice(currentKeyIndex + 1);
|
||||
const hasBarAbove = keysAboveCurrentKey.some((key) => {
|
||||
const value = dataPoint[key];
|
||||
return isNumber(value) && value > 0;
|
||||
});
|
||||
|
||||
return !hasBarAbove;
|
||||
}, [groupMode, keys, barData, chartData, indexBy]);
|
||||
|
||||
const isHorizontal = layout === 'horizontal';
|
||||
|
||||
return (
|
||||
<animated.g transform={transform}>
|
||||
{isTopBar && (
|
||||
<defs>
|
||||
<clipPath id={`round-corner-${barData.index}`}>
|
||||
<animated.rect
|
||||
x={isHorizontal ? -borderRadius : 0}
|
||||
y={0}
|
||||
rx={borderRadius}
|
||||
ry={borderRadius}
|
||||
width={to(width, (value) =>
|
||||
Math.max(value + (isHorizontal ? borderRadius : 0), 0),
|
||||
)}
|
||||
height={to(height, (value) =>
|
||||
Math.max(value + (isHorizontal ? 0 : borderRadius), 0),
|
||||
)}
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
)}
|
||||
|
||||
<StyledBarRect
|
||||
$isInteractive={isInteractive}
|
||||
clipPath={isTopBar ? `url(#round-corner-${barData.index})` : undefined}
|
||||
width={to(width, (value) => Math.max(value, 0))}
|
||||
height={to(height, (value) => Math.max(value, 0))}
|
||||
fill={color}
|
||||
strokeWidth={borderWidth}
|
||||
stroke={borderColor}
|
||||
focusable={isFocusable}
|
||||
tabIndex={isFocusable ? 0 : undefined}
|
||||
aria-label={ariaLabel ? ariaLabel(barData) : undefined}
|
||||
aria-labelledby={ariaLabelledBy ? ariaLabelledBy(barData) : undefined}
|
||||
aria-describedby={
|
||||
ariaDescribedBy ? ariaDescribedBy(barData) : undefined
|
||||
}
|
||||
aria-disabled={ariaDisabled ? ariaDisabled(barData) : undefined}
|
||||
aria-hidden={ariaHidden ? ariaHidden(barData) : undefined}
|
||||
onMouseEnter={isInteractive ? handleMouseEnter : undefined}
|
||||
onMouseMove={isInteractive ? handleTooltip : undefined}
|
||||
onMouseLeave={isInteractive ? handleMouseLeave : undefined}
|
||||
onClick={isInteractive ? handleClick : undefined}
|
||||
onFocus={isInteractive && isFocusable ? handleFocus : undefined}
|
||||
onBlur={isInteractive && isFocusable ? handleBlur : undefined}
|
||||
data-testid={`bar.item.${barData.id}.${barData.index}`}
|
||||
/>
|
||||
|
||||
{shouldRenderLabel && (
|
||||
<Text
|
||||
x={labelX}
|
||||
y={labelY}
|
||||
textAnchor={textAnchor}
|
||||
dominantBaseline="central"
|
||||
fillOpacity={labelOpacity}
|
||||
style={{
|
||||
...theme.labels.text,
|
||||
// We don't want the label to intercept mouse events
|
||||
pointerEvents: 'none',
|
||||
fill: labelColor,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
)}
|
||||
</animated.g>
|
||||
);
|
||||
};
|
||||
+20
-26
@@ -1,7 +1,7 @@
|
||||
import { GraphWidgetChartContainer } from '@/page-layout/widgets/graph/components/GraphWidgetChartContainer';
|
||||
import { GraphWidgetLegend } from '@/page-layout/widgets/graph/components/GraphWidgetLegend';
|
||||
import { GraphWidgetTooltip } from '@/page-layout/widgets/graph/components/GraphWidgetTooltip';
|
||||
import { BarChartEndLines } from '@/page-layout/widgets/graph/graphWidgetBarChart/components/BarChartEndLines';
|
||||
import { CustomBarItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/components/CustomBarItem';
|
||||
import { BAR_CHART_MARGINS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartMargins';
|
||||
import { useBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartData';
|
||||
import { useBarChartHandlers } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartHandlers';
|
||||
@@ -19,8 +19,8 @@ import {
|
||||
import { NodeDimensionEffect } from '@/ui/utilities/dimensions/components/NodeDimensionEffect';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { ResponsiveBar, type BarCustomLayerProps } from '@nivo/bar';
|
||||
import { useId, useRef, useState } from 'react';
|
||||
import { ResponsiveBar } from '@nivo/bar';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const LEGEND_THRESHOLD = 10;
|
||||
@@ -76,7 +76,6 @@ export const GraphWidgetBarChart = ({
|
||||
customFormatter,
|
||||
}: GraphWidgetBarChartProps) => {
|
||||
const theme = useTheme();
|
||||
const instanceId = useId();
|
||||
const colorRegistry = createGraphColorRegistry(theme);
|
||||
const [chartWidth, setChartWidth] = useState<number>(0);
|
||||
const [chartHeight, setChartHeight] = useState<number>(0);
|
||||
@@ -98,17 +97,13 @@ export const GraphWidgetBarChart = ({
|
||||
|
||||
const chartTheme = useBarChartTheme();
|
||||
|
||||
const { barConfigs, enrichedKeys, enrichedKeysMap, defs } = useBarChartData({
|
||||
const { barConfigs, enrichedKeys } = useBarChartData({
|
||||
data,
|
||||
indexBy,
|
||||
keys,
|
||||
series,
|
||||
colorRegistry,
|
||||
id,
|
||||
instanceId,
|
||||
seriesLabels,
|
||||
hoveredBar,
|
||||
layout,
|
||||
});
|
||||
|
||||
const { renderTooltip: getTooltipData } = useBarChartTooltip({
|
||||
@@ -152,22 +147,27 @@ export const GraphWidgetBarChart = ({
|
||||
);
|
||||
};
|
||||
|
||||
const barEndLinesLayer = (props: BarCustomLayerProps<BarChartDataItem>) => {
|
||||
return (
|
||||
<BarChartEndLines
|
||||
bars={props.bars}
|
||||
enrichedKeysMap={enrichedKeysMap}
|
||||
const BarItemWithContext = useMemo(
|
||||
() => (props: any) => (
|
||||
<CustomBarItem
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...props}
|
||||
keys={keys}
|
||||
groupMode={groupMode}
|
||||
data={data}
|
||||
indexBy={indexBy}
|
||||
layout={layout}
|
||||
/>
|
||||
);
|
||||
};
|
||||
),
|
||||
[keys, groupMode, data, indexBy, layout],
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledContainer id={id}>
|
||||
<GraphWidgetChartContainer
|
||||
ref={containerRef}
|
||||
$isClickable={hasClickableItems}
|
||||
$cursorSelector='svg g[transform] rect[fill^="url(#gradient-"]'
|
||||
$cursorSelector="svg g[transform] rect[fill]"
|
||||
>
|
||||
<NodeDimensionEffect
|
||||
elementRef={containerRef}
|
||||
@@ -177,6 +177,7 @@ export const GraphWidgetBarChart = ({
|
||||
}}
|
||||
/>
|
||||
<ResponsiveBar
|
||||
barComponent={BarItemWithContext}
|
||||
data={data}
|
||||
keys={keys}
|
||||
indexBy={indexBy}
|
||||
@@ -192,15 +193,7 @@ export const GraphWidgetBarChart = ({
|
||||
}}
|
||||
indexScale={{ type: 'band', round: true }}
|
||||
colors={(datum) => getBarChartColor(datum, barConfigs, theme)}
|
||||
defs={defs}
|
||||
layers={[
|
||||
'grid',
|
||||
'axes',
|
||||
'bars',
|
||||
barEndLinesLayer,
|
||||
'markers',
|
||||
'legends',
|
||||
]}
|
||||
layers={['grid', 'axes', 'bars', 'markers', 'legends']}
|
||||
axisTop={null}
|
||||
axisRight={null}
|
||||
axisBottom={axisBottomConfig}
|
||||
@@ -226,6 +219,7 @@ export const GraphWidgetBarChart = ({
|
||||
}}
|
||||
onMouseLeave={() => setHoveredBar(null)}
|
||||
theme={chartTheme}
|
||||
borderRadius={parseInt(theme.border.radius.sm)}
|
||||
/>
|
||||
</GraphWidgetChartContainer>
|
||||
<GraphWidgetLegend
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const BAR_CHART_HOVER_BRIGHTNESS = 0.85;
|
||||
+25
-133
@@ -47,10 +47,6 @@ describe('useBarChartData', () => {
|
||||
keys: ['sales', 'costs'],
|
||||
series: mockSeries,
|
||||
colorRegistry: mockColorRegistry,
|
||||
id: 'test-chart',
|
||||
instanceId: 'instance-1',
|
||||
hoveredBar: null,
|
||||
layout: 'vertical',
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -66,10 +62,6 @@ describe('useBarChartData', () => {
|
||||
keys: ['sales', 'costs'],
|
||||
series: mockSeries,
|
||||
colorRegistry: mockColorRegistry,
|
||||
id: 'test-chart',
|
||||
instanceId: 'instance-1',
|
||||
hoveredBar: null,
|
||||
layout: 'vertical',
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -77,14 +69,18 @@ describe('useBarChartData', () => {
|
||||
expect(result.current.barConfigs[0]).toMatchObject({
|
||||
key: 'sales',
|
||||
indexValue: 'Jan',
|
||||
gradientId: 'gradient-test-chart-instance-1-sales-0-0',
|
||||
colorScheme: mockColorRegistry.green,
|
||||
colorScheme: {
|
||||
name: 'green',
|
||||
gradient: mockColorRegistry.green.gradient,
|
||||
},
|
||||
});
|
||||
expect(result.current.barConfigs[1]).toMatchObject({
|
||||
key: 'costs',
|
||||
indexValue: 'Jan',
|
||||
gradientId: 'gradient-test-chart-instance-1-costs-0-1',
|
||||
colorScheme: mockColorRegistry.purple,
|
||||
colorScheme: {
|
||||
name: 'purple',
|
||||
gradient: mockColorRegistry.purple.gradient,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,25 +92,28 @@ describe('useBarChartData', () => {
|
||||
keys: ['sales', 'costs'],
|
||||
series: mockSeries,
|
||||
colorRegistry: mockColorRegistry,
|
||||
id: 'test-chart',
|
||||
instanceId: 'instance-1',
|
||||
hoveredBar: null,
|
||||
layout: 'vertical',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.enrichedKeys).toEqual([
|
||||
{
|
||||
key: 'sales',
|
||||
colorScheme: mockColorRegistry.green,
|
||||
label: 'Sales',
|
||||
expect(result.current.enrichedKeys).toHaveLength(2);
|
||||
expect(result.current.enrichedKeys[0]).toMatchObject({
|
||||
key: 'sales',
|
||||
label: 'Sales',
|
||||
colorScheme: {
|
||||
name: 'green',
|
||||
gradient: mockColorRegistry.green.gradient,
|
||||
},
|
||||
{
|
||||
key: 'costs',
|
||||
colorScheme: mockColorRegistry.purple,
|
||||
label: 'Costs',
|
||||
});
|
||||
expect(result.current.enrichedKeys[0].colorScheme.solid).toBeDefined();
|
||||
expect(result.current.enrichedKeys[1]).toMatchObject({
|
||||
key: 'costs',
|
||||
label: 'Costs',
|
||||
colorScheme: {
|
||||
name: 'purple',
|
||||
gradient: mockColorRegistry.purple.gradient,
|
||||
},
|
||||
]);
|
||||
});
|
||||
expect(result.current.enrichedKeys[1].colorScheme.solid).toBeDefined();
|
||||
});
|
||||
|
||||
it('should use series labels when series config is not provided', () => {
|
||||
@@ -125,11 +124,7 @@ describe('useBarChartData', () => {
|
||||
keys: ['sales', 'costs'],
|
||||
series: undefined,
|
||||
colorRegistry: mockColorRegistry,
|
||||
id: 'test-chart',
|
||||
instanceId: 'instance-1',
|
||||
seriesLabels: { sales: 'Revenue', costs: 'Expenses' },
|
||||
hoveredBar: null,
|
||||
layout: 'vertical',
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -137,70 +132,6 @@ describe('useBarChartData', () => {
|
||||
expect(result.current.enrichedKeys[1].label).toBe('Expenses');
|
||||
});
|
||||
|
||||
it('should handle hover state for bars', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useBarChartData({
|
||||
data: mockData,
|
||||
indexBy: 'month',
|
||||
keys: ['sales', 'costs'],
|
||||
series: mockSeries,
|
||||
colorRegistry: mockColorRegistry,
|
||||
id: 'test-chart',
|
||||
instanceId: 'instance-1',
|
||||
hoveredBar: { key: 'sales', indexValue: 'Feb' },
|
||||
layout: 'vertical',
|
||||
}),
|
||||
);
|
||||
|
||||
const hoveredDef = result.current.defs.find(
|
||||
(def) => def.id === 'gradient-test-chart-instance-1-sales-1-0',
|
||||
);
|
||||
expect(hoveredDef?.colors).toEqual([
|
||||
{ offset: 0, color: 'green4' },
|
||||
{ offset: 100, color: 'green3' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should generate vertical gradients for vertical layout', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useBarChartData({
|
||||
data: mockData,
|
||||
indexBy: 'month',
|
||||
keys: ['sales'],
|
||||
series: mockSeries,
|
||||
colorRegistry: mockColorRegistry,
|
||||
id: 'test-chart',
|
||||
instanceId: 'instance-1',
|
||||
hoveredBar: null,
|
||||
layout: 'vertical',
|
||||
}),
|
||||
);
|
||||
|
||||
const def = result.current.defs[0];
|
||||
expect(def.y1).toBe('0%');
|
||||
expect(def.y2).toBe('100%');
|
||||
});
|
||||
|
||||
it('should generate horizontal gradients for horizontal layout', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useBarChartData({
|
||||
data: mockData,
|
||||
indexBy: 'month',
|
||||
keys: ['sales'],
|
||||
series: mockSeries,
|
||||
colorRegistry: mockColorRegistry,
|
||||
id: 'test-chart',
|
||||
instanceId: 'instance-1',
|
||||
hoveredBar: null,
|
||||
layout: 'horizontal',
|
||||
}),
|
||||
);
|
||||
|
||||
const def = result.current.defs[0];
|
||||
expect(def.x1).toBe('0%');
|
||||
expect(def.x2).toBe('100%');
|
||||
});
|
||||
|
||||
it('should handle empty data', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useBarChartData({
|
||||
@@ -209,15 +140,10 @@ describe('useBarChartData', () => {
|
||||
keys: ['sales', 'costs'],
|
||||
series: mockSeries,
|
||||
colorRegistry: mockColorRegistry,
|
||||
id: 'test-chart',
|
||||
instanceId: 'instance-1',
|
||||
hoveredBar: null,
|
||||
layout: 'vertical',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.barConfigs).toEqual([]);
|
||||
expect(result.current.defs).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle empty keys', () => {
|
||||
@@ -228,16 +154,11 @@ describe('useBarChartData', () => {
|
||||
keys: [],
|
||||
series: mockSeries,
|
||||
colorRegistry: mockColorRegistry,
|
||||
id: 'test-chart',
|
||||
instanceId: 'instance-1',
|
||||
hoveredBar: null,
|
||||
layout: 'vertical',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.barConfigs).toEqual([]);
|
||||
expect(result.current.enrichedKeys).toEqual([]);
|
||||
expect(result.current.defs).toEqual([]);
|
||||
});
|
||||
|
||||
it('should fall back to key name when no label is provided', () => {
|
||||
@@ -248,40 +169,11 @@ describe('useBarChartData', () => {
|
||||
keys: ['sales', 'costs'],
|
||||
series: undefined,
|
||||
colorRegistry: mockColorRegistry,
|
||||
id: 'test-chart',
|
||||
instanceId: 'instance-1',
|
||||
seriesLabels: undefined,
|
||||
hoveredBar: null,
|
||||
layout: 'vertical',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.enrichedKeys[0].label).toBe('sales');
|
||||
expect(result.current.enrichedKeys[1].label).toBe('costs');
|
||||
});
|
||||
|
||||
it('should recalculate when instanceId changes', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ instanceId }) =>
|
||||
useBarChartData({
|
||||
data: mockData,
|
||||
indexBy: 'month',
|
||||
keys: ['sales', 'costs'],
|
||||
series: mockSeries,
|
||||
colorRegistry: mockColorRegistry,
|
||||
id: 'test-chart',
|
||||
instanceId,
|
||||
hoveredBar: null,
|
||||
layout: 'vertical',
|
||||
}),
|
||||
{ initialProps: { instanceId: 'instance-1' } },
|
||||
);
|
||||
|
||||
const firstBarConfigs = result.current.barConfigs;
|
||||
|
||||
rerender({ instanceId: 'instance-2' });
|
||||
|
||||
expect(result.current.barConfigs).not.toBe(firstBarConfigs);
|
||||
expect(result.current.barConfigs[0].gradientId).toContain('instance-2');
|
||||
});
|
||||
});
|
||||
|
||||
+16
-43
@@ -3,7 +3,6 @@ import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBa
|
||||
import { type BarChartEnrichedKey } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartEnrichedKey';
|
||||
import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries';
|
||||
import { type GraphColorRegistry } from '@/page-layout/widgets/graph/types/GraphColorRegistry';
|
||||
import { createGradientDef } from '@/page-layout/widgets/graph/utils/createGradientDef';
|
||||
import { getColorScheme } from '@/page-layout/widgets/graph/utils/getColorScheme';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
@@ -13,11 +12,8 @@ type UseBarChartDataProps = {
|
||||
keys: string[];
|
||||
series?: BarChartSeries[];
|
||||
colorRegistry: GraphColorRegistry;
|
||||
id: string;
|
||||
instanceId: string;
|
||||
seriesLabels?: Record<string, string>;
|
||||
hoveredBar: { key: string; indexValue: string | number } | null;
|
||||
layout: 'vertical' | 'horizontal';
|
||||
groupMode?: 'grouped' | 'stacked';
|
||||
};
|
||||
|
||||
export const useBarChartData = ({
|
||||
@@ -26,11 +22,7 @@ export const useBarChartData = ({
|
||||
keys,
|
||||
series,
|
||||
colorRegistry,
|
||||
id,
|
||||
instanceId,
|
||||
seriesLabels,
|
||||
hoveredBar,
|
||||
layout,
|
||||
}: UseBarChartDataProps) => {
|
||||
const seriesConfigMap = useMemo(
|
||||
() => new Map<string, BarChartSeries>(series?.map((s) => [s.key, s]) || []),
|
||||
@@ -38,35 +30,35 @@ export const useBarChartData = ({
|
||||
);
|
||||
|
||||
const barConfigs = useMemo((): BarChartConfig[] => {
|
||||
return data.flatMap((dataPoint, dataIndex) => {
|
||||
return data.flatMap((dataPoint) => {
|
||||
const indexValue = dataPoint[indexBy];
|
||||
return keys.map((key, keyIndex): BarChartConfig => {
|
||||
const seriesConfig = seriesConfigMap.get(key);
|
||||
const colorScheme = getColorScheme(
|
||||
colorRegistry,
|
||||
seriesConfig?.color,
|
||||
keyIndex,
|
||||
);
|
||||
const sanitizedKey = key.replace(/\s+/g, '-');
|
||||
const gradientId = `gradient-${id}-${instanceId}-${sanitizedKey}-${dataIndex}-${keyIndex}`;
|
||||
const colorScheme = getColorScheme({
|
||||
registry: colorRegistry,
|
||||
colorName: seriesConfig?.color,
|
||||
fallbackIndex: keyIndex,
|
||||
totalGroups: keys.length,
|
||||
});
|
||||
|
||||
return {
|
||||
key,
|
||||
indexValue,
|
||||
gradientId,
|
||||
colorScheme,
|
||||
};
|
||||
});
|
||||
});
|
||||
}, [data, indexBy, keys, colorRegistry, id, instanceId, seriesConfigMap]);
|
||||
}, [data, indexBy, keys, colorRegistry, seriesConfigMap]);
|
||||
|
||||
const enrichedKeys: BarChartEnrichedKey[] = keys.map((key, index) => {
|
||||
const seriesConfig = seriesConfigMap.get(key);
|
||||
const colorScheme = getColorScheme(
|
||||
colorRegistry,
|
||||
seriesConfig?.color,
|
||||
index,
|
||||
);
|
||||
const colorScheme = getColorScheme({
|
||||
registry: colorRegistry,
|
||||
colorName: seriesConfig?.color,
|
||||
fallbackIndex: index,
|
||||
totalGroups: keys.length,
|
||||
});
|
||||
|
||||
return {
|
||||
key,
|
||||
colorScheme,
|
||||
@@ -74,28 +66,9 @@ export const useBarChartData = ({
|
||||
};
|
||||
});
|
||||
|
||||
const enrichedKeysMap = useMemo(
|
||||
() => new Map(enrichedKeys.map((item) => [item.key, item])),
|
||||
[enrichedKeys],
|
||||
);
|
||||
|
||||
const defs = barConfigs.map((bar) => {
|
||||
const isHovered =
|
||||
hoveredBar?.key === bar.key && hoveredBar?.indexValue === bar.indexValue;
|
||||
return createGradientDef(
|
||||
bar.colorScheme,
|
||||
bar.gradientId,
|
||||
isHovered,
|
||||
layout === 'horizontal' ? 0 : 90,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
seriesConfigMap,
|
||||
barConfigs,
|
||||
enrichedKeys,
|
||||
enrichedKeysMap,
|
||||
defs,
|
||||
};
|
||||
};
|
||||
|
||||
-1
@@ -3,6 +3,5 @@ import { type GraphColorScheme } from '@/page-layout/widgets/graph/types/GraphCo
|
||||
export type BarChartConfig = {
|
||||
key: string;
|
||||
indexValue: string | number;
|
||||
gradientId: string;
|
||||
colorScheme: GraphColorScheme;
|
||||
};
|
||||
|
||||
+1
-1
@@ -14,5 +14,5 @@ export const getBarChartColor = (
|
||||
if (!isDefined(bar)) {
|
||||
return theme.border.color.light;
|
||||
}
|
||||
return `url(#${bar.gradientId})`;
|
||||
return bar.colorScheme.solid;
|
||||
};
|
||||
|
||||
+5
-1
@@ -22,7 +22,11 @@ export const useGaugeChartData = ({
|
||||
const { value, min, max, color = 'blue' } = data;
|
||||
|
||||
const colorScheme = useMemo(
|
||||
() => getColorScheme(colorRegistry, color),
|
||||
() =>
|
||||
getColorScheme({
|
||||
registry: colorRegistry,
|
||||
colorName: color,
|
||||
}),
|
||||
[colorRegistry, color],
|
||||
);
|
||||
|
||||
|
||||
+2
-24
@@ -80,17 +80,11 @@ describe('useLineChartData', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.enrichedSeries[0].colorScheme).toBe(
|
||||
mockColorRegistry.red,
|
||||
);
|
||||
expect(result.current.enrichedSeries[0].gradientId).toBe(
|
||||
'lineGradient-test-chart-instance-1-series1-0',
|
||||
);
|
||||
expect(result.current.enrichedSeries[0].label).toBe('Sales');
|
||||
|
||||
expect(result.current.enrichedSeries[1].colorScheme).toBe(
|
||||
mockColorRegistry.blue,
|
||||
);
|
||||
expect(result.current.enrichedSeries[1].label).toBe('Costs');
|
||||
});
|
||||
|
||||
@@ -268,22 +262,6 @@ describe('useLineChartData', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('should extract colors for series', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLineChartData({
|
||||
data: mockData,
|
||||
colorRegistry: mockColorRegistry,
|
||||
id: 'test-chart',
|
||||
instanceId: 'instance-1',
|
||||
enableArea: false,
|
||||
theme: mockTheme,
|
||||
formatOptions: mockFormatOptions,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.colors).toEqual(['redSolid', 'blueSolid']);
|
||||
});
|
||||
|
||||
it('should calculate legend items with totals', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLineChartData({
|
||||
@@ -302,13 +280,13 @@ describe('useLineChartData', () => {
|
||||
id: 'series1',
|
||||
label: 'Sales',
|
||||
formattedValue: '370',
|
||||
color: 'redSolid',
|
||||
color: expect.any(String),
|
||||
},
|
||||
{
|
||||
id: 'series2',
|
||||
label: 'Costs',
|
||||
formattedValue: '270',
|
||||
color: 'blueSolid',
|
||||
color: expect.any(String),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
+6
-1
@@ -34,7 +34,12 @@ export const useLineChartData = ({
|
||||
const dataMap = Object.fromEntries(data.map((series) => [series.id, series]));
|
||||
const enrichedSeries = useMemo((): LineChartEnrichedSeries[] => {
|
||||
return data.map((series, index) => {
|
||||
const colorScheme = getColorScheme(colorRegistry, series.color, index);
|
||||
const colorScheme = getColorScheme({
|
||||
registry: colorRegistry,
|
||||
colorName: series.color,
|
||||
fallbackIndex: index,
|
||||
totalGroups: data.length,
|
||||
});
|
||||
const shouldEnableArea = series.enableArea ?? enableArea;
|
||||
const gradientId = `lineGradient-${id}-${instanceId}-${series.id}-${index}`;
|
||||
|
||||
|
||||
+8
-2
@@ -1,12 +1,12 @@
|
||||
import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem';
|
||||
import { type PieChartEnrichedData } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartEnrichedData';
|
||||
import { useMemo } from 'react';
|
||||
import { calculatePieChartAngles } from '@/page-layout/widgets/graph/graphWidgetPieChart/utils/calculatePieChartAngles';
|
||||
import { calculatePieChartPercentage } from '@/page-layout/widgets/graph/graphWidgetPieChart/utils/calculatePieChartPercentage';
|
||||
import { type GraphColorRegistry } from '@/page-layout/widgets/graph/types/GraphColorRegistry';
|
||||
import { createGradientDef } from '@/page-layout/widgets/graph/utils/createGradientDef';
|
||||
import { getColorScheme } from '@/page-layout/widgets/graph/utils/getColorScheme';
|
||||
import { type DatumId } from '@nivo/pie';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
type UsePieChartDataProps = {
|
||||
data: PieChartDataItem[];
|
||||
@@ -26,7 +26,13 @@ export const usePieChartData = ({
|
||||
|
||||
let cumulativeAngle = 0;
|
||||
return data.map((item, index) => {
|
||||
const colorScheme = getColorScheme(colorRegistry, item.color, index);
|
||||
const colorScheme = getColorScheme({
|
||||
registry: colorRegistry,
|
||||
colorName: item.color,
|
||||
fallbackIndex: index,
|
||||
totalGroups: data.length,
|
||||
});
|
||||
|
||||
const isHovered = hoveredSliceId === item.id;
|
||||
const gradientId = `${colorScheme.name}Gradient-${id}-${index}`;
|
||||
const percentage = calculatePieChartPercentage(item.value, totalValue);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export type GraphColor =
|
||||
| 'auto'
|
||||
| 'blue'
|
||||
| 'purple'
|
||||
| 'turquoise'
|
||||
|
||||
+10
-10
@@ -11,7 +11,7 @@ export const createGraphColorRegistry = (
|
||||
normal: [theme.adaptiveColors.blue1, theme.adaptiveColors.blue2],
|
||||
hover: [theme.adaptiveColors.blue3, theme.adaptiveColors.blue4],
|
||||
},
|
||||
solid: theme.color.blue,
|
||||
solid: theme.adaptiveColors.blue4,
|
||||
},
|
||||
purple: {
|
||||
name: 'purple',
|
||||
@@ -19,7 +19,7 @@ export const createGraphColorRegistry = (
|
||||
normal: [theme.adaptiveColors.purple1, theme.adaptiveColors.purple2],
|
||||
hover: [theme.adaptiveColors.purple3, theme.adaptiveColors.purple4],
|
||||
},
|
||||
solid: theme.color.purple,
|
||||
solid: theme.adaptiveColors.purple4,
|
||||
},
|
||||
turquoise: {
|
||||
name: 'turquoise',
|
||||
@@ -30,7 +30,7 @@ export const createGraphColorRegistry = (
|
||||
],
|
||||
hover: [theme.adaptiveColors.turquoise3, theme.adaptiveColors.turquoise4],
|
||||
},
|
||||
solid: theme.color.turquoise,
|
||||
solid: theme.adaptiveColors.turquoise4,
|
||||
},
|
||||
orange: {
|
||||
name: 'orange',
|
||||
@@ -38,7 +38,7 @@ export const createGraphColorRegistry = (
|
||||
normal: [theme.adaptiveColors.orange1, theme.adaptiveColors.orange2],
|
||||
hover: [theme.adaptiveColors.orange3, theme.adaptiveColors.orange4],
|
||||
},
|
||||
solid: theme.color.orange,
|
||||
solid: theme.adaptiveColors.orange4,
|
||||
},
|
||||
pink: {
|
||||
name: 'pink',
|
||||
@@ -46,7 +46,7 @@ export const createGraphColorRegistry = (
|
||||
normal: [theme.adaptiveColors.pink1, theme.adaptiveColors.pink2],
|
||||
hover: [theme.adaptiveColors.pink3, theme.adaptiveColors.pink4],
|
||||
},
|
||||
solid: theme.color.pink,
|
||||
solid: theme.adaptiveColors.pink4,
|
||||
},
|
||||
yellow: {
|
||||
name: 'yellow',
|
||||
@@ -54,7 +54,7 @@ export const createGraphColorRegistry = (
|
||||
normal: [theme.adaptiveColors.yellow1, theme.adaptiveColors.yellow2],
|
||||
hover: [theme.adaptiveColors.yellow3, theme.adaptiveColors.yellow4],
|
||||
},
|
||||
solid: theme.color.yellow,
|
||||
solid: theme.adaptiveColors.yellow4,
|
||||
},
|
||||
red: {
|
||||
name: 'red',
|
||||
@@ -62,7 +62,7 @@ export const createGraphColorRegistry = (
|
||||
normal: [theme.adaptiveColors.red1, theme.adaptiveColors.red2],
|
||||
hover: [theme.adaptiveColors.red3, theme.adaptiveColors.red4],
|
||||
},
|
||||
solid: theme.color.red,
|
||||
solid: theme.adaptiveColors.red4,
|
||||
},
|
||||
green: {
|
||||
name: 'green',
|
||||
@@ -70,7 +70,7 @@ export const createGraphColorRegistry = (
|
||||
normal: [theme.adaptiveColors.green1, theme.adaptiveColors.green2],
|
||||
hover: [theme.adaptiveColors.green3, theme.adaptiveColors.green4],
|
||||
},
|
||||
solid: theme.color.green,
|
||||
solid: theme.adaptiveColors.green4,
|
||||
},
|
||||
sky: {
|
||||
name: 'sky',
|
||||
@@ -78,7 +78,7 @@ export const createGraphColorRegistry = (
|
||||
normal: [theme.adaptiveColors.sky1, theme.adaptiveColors.sky2],
|
||||
hover: [theme.adaptiveColors.sky3, theme.adaptiveColors.sky4],
|
||||
},
|
||||
solid: theme.color.sky,
|
||||
solid: theme.adaptiveColors.sky4,
|
||||
},
|
||||
gray: {
|
||||
name: 'gray',
|
||||
@@ -86,6 +86,6 @@ export const createGraphColorRegistry = (
|
||||
normal: [theme.adaptiveColors.gray1, theme.adaptiveColors.gray2],
|
||||
hover: [theme.adaptiveColors.gray3, theme.adaptiveColors.gray4],
|
||||
},
|
||||
solid: theme.color.gray,
|
||||
solid: theme.adaptiveColors.gray4,
|
||||
},
|
||||
});
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { RGBA } from 'twenty-ui/theme';
|
||||
|
||||
import { GRAPH_GROUP_COLOR_MINIMUM_ALPHA } from '@/page-layout/widgets/graph/constants/GraphGroupColorMinimumAlpha.constant';
|
||||
import { GRAPH_MAXIMUM_NUMBER_OF_GROUP_COLORS } from '@/page-layout/widgets/graph/constants/GraphMaximumNumberOfGroupColors';
|
||||
import { type GraphColorScheme } from '@/page-layout/widgets/graph/types/GraphColorScheme';
|
||||
|
||||
export const generateGroupColor = ({
|
||||
colorScheme,
|
||||
groupIndex,
|
||||
totalGroups,
|
||||
}: {
|
||||
colorScheme: GraphColorScheme;
|
||||
groupIndex: number;
|
||||
totalGroups: number;
|
||||
}): string => {
|
||||
if (totalGroups <= 1) {
|
||||
return colorScheme.solid;
|
||||
}
|
||||
|
||||
const effectiveGroupIndex = groupIndex % GRAPH_MAXIMUM_NUMBER_OF_GROUP_COLORS;
|
||||
const effectiveTotalGroups = Math.min(
|
||||
totalGroups,
|
||||
GRAPH_MAXIMUM_NUMBER_OF_GROUP_COLORS,
|
||||
);
|
||||
|
||||
const ratio = (effectiveGroupIndex + 1) / effectiveTotalGroups;
|
||||
const alpha =
|
||||
GRAPH_GROUP_COLOR_MINIMUM_ALPHA +
|
||||
(1 - GRAPH_GROUP_COLOR_MINIMUM_ALPHA) * ratio;
|
||||
|
||||
return RGBA(colorScheme.solid, alpha);
|
||||
};
|
||||
+27
-8
@@ -1,16 +1,35 @@
|
||||
import { generateGroupColor } from '@/page-layout/widgets/graph/utils/generateGroupColor';
|
||||
import { getColorSchemeByIndex } from '@/page-layout/widgets/graph/utils/getColorSchemeByIndex';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type GraphColor } from '../types/GraphColor';
|
||||
import { type GraphColorRegistry } from '../types/GraphColorRegistry';
|
||||
import { type GraphColorScheme } from '../types/GraphColorScheme';
|
||||
import { getColorSchemeByIndex } from './getColorSchemeByIndex';
|
||||
|
||||
export const getColorScheme = (
|
||||
registry: GraphColorRegistry,
|
||||
colorName?: GraphColor,
|
||||
fallbackIndex?: number,
|
||||
): GraphColorScheme => {
|
||||
if (isDefined(colorName) && isDefined(registry[colorName])) {
|
||||
export const getColorScheme = ({
|
||||
registry,
|
||||
colorName,
|
||||
fallbackIndex,
|
||||
totalGroups,
|
||||
}: {
|
||||
registry: GraphColorRegistry;
|
||||
colorName?: GraphColor;
|
||||
fallbackIndex?: number;
|
||||
totalGroups?: number;
|
||||
}): GraphColorScheme => {
|
||||
if (!isDefined(colorName) || !isDefined(registry[colorName])) {
|
||||
return getColorSchemeByIndex(registry, fallbackIndex ?? 0);
|
||||
}
|
||||
|
||||
if (!isDefined(totalGroups)) {
|
||||
return registry[colorName];
|
||||
}
|
||||
return getColorSchemeByIndex(registry, fallbackIndex || 0);
|
||||
|
||||
return {
|
||||
...registry[colorName],
|
||||
solid: generateGroupColor({
|
||||
colorScheme: registry[colorName],
|
||||
groupIndex: fallbackIndex ?? 0,
|
||||
totalGroups,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
+2
@@ -4,6 +4,7 @@ import { type ExtendedAggregateOperations } from '@/object-record/record-table/t
|
||||
import { GRAPH_MAXIMUM_NUMBER_OF_GROUPS } from '@/page-layout/widgets/graph/constants/GraphMaximumNumberOfGroups.constant';
|
||||
import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem';
|
||||
import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries';
|
||||
import { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor';
|
||||
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
|
||||
import { computeAggregateValueFromGroupByResult } from '@/page-layout/widgets/graph/utils/computeAggregateValueFromGroupByResult';
|
||||
import { formatDimensionValue } from '@/page-layout/widgets/graph/utils/formatDimensionValue';
|
||||
@@ -115,6 +116,7 @@ export const transformTwoDimensionalGroupByToBarChartData = ({
|
||||
const series: BarChartSeries[] = keys.map((key) => ({
|
||||
key,
|
||||
label: key,
|
||||
color: configuration.color as GraphColor,
|
||||
}));
|
||||
|
||||
return {
|
||||
|
||||
+4
@@ -176,6 +176,7 @@ export const getPageLayoutWidgetDataSeeds = (
|
||||
primaryAxisOrderBy: 'FIELD_ASC',
|
||||
axisNameDisplay: AxisNameDisplay.BOTH,
|
||||
displayDataLabel: false,
|
||||
color: 'auto',
|
||||
}
|
||||
: null,
|
||||
objectMetadataId: opportunityObject?.id ?? null,
|
||||
@@ -204,6 +205,7 @@ export const getPageLayoutWidgetDataSeeds = (
|
||||
primaryAxisOrderBy: 'FIELD_ASC',
|
||||
axisNameDisplay: AxisNameDisplay.NONE,
|
||||
displayDataLabel: false,
|
||||
color: 'auto',
|
||||
}
|
||||
: null,
|
||||
objectMetadataId: rocketObject?.id ?? null,
|
||||
@@ -306,6 +308,7 @@ export const getPageLayoutWidgetDataSeeds = (
|
||||
primaryAxisOrderBy: 'FIELD_ASC',
|
||||
axisNameDisplay: AxisNameDisplay.BOTH,
|
||||
displayDataLabel: false,
|
||||
color: 'auto',
|
||||
}
|
||||
: null,
|
||||
objectMetadataId: companyObject?.id ?? null,
|
||||
@@ -471,6 +474,7 @@ export const getPageLayoutWidgetDataSeeds = (
|
||||
primaryAxisOrderBy: 'VALUE_DESC',
|
||||
axisNameDisplay: AxisNameDisplay.NONE,
|
||||
displayDataLabel: false,
|
||||
color: 'auto',
|
||||
}
|
||||
: null,
|
||||
objectMetadataId: personObject?.id ?? null,
|
||||
|
||||
@@ -1,24 +1,36 @@
|
||||
import { css } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { type ThemeColor } from '@ui/theme';
|
||||
import { type ThemeColor, type ThemeType } from '@ui/theme';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export type ColorSampleVariant = 'default' | 'pipeline';
|
||||
|
||||
export type ColorSampleProps = {
|
||||
colorName: ThemeColor;
|
||||
colorName?: ThemeColor;
|
||||
color?: string;
|
||||
variant?: ColorSampleVariant;
|
||||
};
|
||||
|
||||
const getColor = (theme: ThemeType, colorName?: ThemeColor, color?: string) => {
|
||||
if (isDefined(color)) {
|
||||
return color;
|
||||
}
|
||||
if (isDefined(colorName)) {
|
||||
return theme.tag.background[colorName];
|
||||
}
|
||||
return 'transparent';
|
||||
};
|
||||
|
||||
const StyledColorSample = styled.div<ColorSampleProps>`
|
||||
background-color: ${({ theme, colorName }) =>
|
||||
theme.tag.background[colorName]};
|
||||
border: 1px solid ${({ theme, colorName }) => theme.tag.text[colorName]};
|
||||
background-color: ${({ theme, colorName, color }) =>
|
||||
getColor(theme, colorName, color)};
|
||||
border: 1px solid ${({ theme }) => theme.border.color.transparentStrong};
|
||||
border-radius: 60px;
|
||||
height: ${({ theme }) => theme.spacing(4)};
|
||||
width: ${({ theme }) => theme.spacing(3)};
|
||||
|
||||
${({ colorName, theme, variant }) => {
|
||||
${({ colorName, color, theme, variant }) => {
|
||||
if (variant === 'pipeline')
|
||||
return css`
|
||||
align-items: center;
|
||||
@@ -27,7 +39,7 @@ const StyledColorSample = styled.div<ColorSampleProps>`
|
||||
justify-content: center;
|
||||
|
||||
&:after {
|
||||
background-color: ${theme.tag.text[colorName]};
|
||||
background-color: ${getColor(theme, colorName, color)};
|
||||
border-radius: ${theme.border.radius.rounded};
|
||||
content: '';
|
||||
display: block;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { RGBA } from '@ui/theme/constants/Rgba';
|
||||
import { BORDER_COMMON } from './BorderCommon';
|
||||
import { COLOR } from './Colors';
|
||||
import { GRAY_SCALE } from './GrayScale';
|
||||
@@ -11,6 +12,7 @@ export const BORDER_DARK = {
|
||||
inverted: GRAY_SCALE.gray20,
|
||||
danger: COLOR.red70,
|
||||
blue: COLOR.blue30,
|
||||
transparentStrong: RGBA(GRAY_SCALE.gray100, 0.16),
|
||||
},
|
||||
...BORDER_COMMON,
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { RGBA } from '@ui/theme/constants/Rgba';
|
||||
import { BORDER_COMMON } from './BorderCommon';
|
||||
import { COLOR } from './Colors';
|
||||
import { GRAY_SCALE } from './GrayScale';
|
||||
@@ -11,6 +12,7 @@ export const BORDER_LIGHT = {
|
||||
inverted: GRAY_SCALE.gray60,
|
||||
danger: COLOR.red20,
|
||||
blue: COLOR.blue30,
|
||||
transparentStrong: RGBA(GRAY_SCALE.gray100, 0.16),
|
||||
},
|
||||
...BORDER_COMMON,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user