Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Donut] Legends multi selection for Donut Charts #33447

Merged
merged 23 commits into from
Dec 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b0c7663
Legends multi selection for Donut Charts
srmukher Dec 11, 2024
24ce6bf
Making legend multi selection optional
srmukher Dec 12, 2024
434fc6d
Showing total values when multiple legends are selected
srmukher Dec 12, 2024
70b0404
Separating hover and multi selection and adding example
srmukher Dec 12, 2024
addca43
Merge branch 'master' into users/srmukher/LegendsMultiSel
srmukher Dec 12, 2024
7293f33
Taking multiple legend selection from legend component callback
srmukher Dec 13, 2024
ae348e2
Merge branch 'users/srmukher/LegendsMultiSel' of https://github.com/m…
srmukher Dec 13, 2024
88eb247
Modifying handlers
srmukher Dec 17, 2024
db555d3
Merge branch 'master' of https://github.com/microsoft/fluentui into u…
srmukher Dec 17, 2024
4fdc773
Merge branch 'master' into users/srmukher/LegendsMultiSel
srmukher Dec 17, 2024
a1dc7a9
Merge branch 'master' into users/srmukher/LegendsMultiSel
srmukher Dec 18, 2024
13876f3
Removing canSelectMultipleLegends prop
srmukher Dec 18, 2024
cea2d29
Rearranging states and handlers
srmukher Dec 18, 2024
be186ba
Fixing donut labels
srmukher Dec 19, 2024
d4641c5
Add change file
srmukher Dec 19, 2024
0e62c96
Merge branch 'master' into users/srmukher/LegendsMultiSel
srmukher Dec 20, 2024
568ca4e
Fixing tests
srmukher Dec 20, 2024
36e6bdc
Merge branch 'users/srmukher/LegendsMultiSel' of https://github.com/m…
srmukher Dec 20, 2024
a94e9f1
Adding and updating tests
srmukher Dec 23, 2024
cfd09ea
Moving checks inside function
srmukher Dec 23, 2024
0e7fc13
Merge branch 'master' into users/srmukher/LegendsMultiSel
srmukher Dec 23, 2024
1b9f819
Making changes related to arc highlight
srmukher Dec 24, 2024
7189eb9
Merge branch 'users/srmukher/LegendsMultiSel' of https://github.com/m…
srmukher Dec 24, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "Legends multi selection for Donut Charts",
"packageName": "@fluentui/react-charting",
"email": "[email protected]",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ export const DeclarativeChart: React.FunctionComponent<DeclarativeChartProps> =
return (
<DonutChart
{...transformPlotlyJsonToDonutProps(plotlySchema, colorMap, isDarkTheme)}
legendProps={legendProps}
legendProps={{ ...legendProps, canSelectMultipleLegends: true }}
componentRef={chartRef}
/>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,11 @@ export class Arc extends React.Component<IArcProps, IArcState> {
}
srmukher marked this conversation as resolved.
Show resolved Hide resolved

public render(): JSX.Element {
const { arc, href, focusedArcId } = this.props;
const { arc, href, focusedArcId, activeArc } = this.props;
const getClassNames = classNamesFunction<IArcStyleProps, IArcStyles>();
const id = this.props.uniqText! + this.props.data!.data.legend!.replace(/\s+/, '') + this.props.data!.data.data;
const opacity: number =
this.props.activeArc === this.props.data!.data.legend || this.props.activeArc === '' ? 1 : 0.1;

activeArc && activeArc.length > 0 ? (activeArc.includes(this.props.data?.data.legend!) ? 1 : 0.1) : 1;
const startAngle = this.props.data?.startAngle ?? 0;
const endAngle = (this.props.data?.endAngle ?? 0) - startAngle;
const cornerRadius = this.props.roundCorners ? 3 : 0;
Expand Down Expand Up @@ -70,7 +69,9 @@ export class Arc extends React.Component<IArcProps, IArcState> {
d={arc.cornerRadius(cornerRadius)(this.props.data)}
onFocus={this._onFocus.bind(this, this.props.data!.data, id)}
className={classNames.root}
data-is-focusable={this.props.activeArc === this.props.data!.data.legend || this.props.activeArc === ''}
data-is-focusable={
this._shouldHighlightArc(this.props.data!.data.legend!) || this.props.activeArc?.length === 0
}
onMouseOver={this._hoverOn.bind(this, this.props.data!.data)}
onMouseMove={this._hoverOn.bind(this, this.props.data!.data)}
onMouseLeave={this._hoverOff}
Expand Down Expand Up @@ -123,13 +124,18 @@ export class Arc extends React.Component<IArcProps, IArcState> {
return point.callOutAccessibilityData?.ariaLabel || (legend ? `${legend}, ` : '') + `${yValue}.`;
};

private _renderArcLabel = (className: string) => {
const { arc, data, innerRadius, outerRadius, showLabelsInPercent, totalValue, hideLabels, activeArc } = this.props;
private _shouldHighlightArc = (legend?: string): boolean => {
const { activeArc } = this.props;
// If no activeArc is provided, highlight all arcs. Otherwise, only highlight the arcs that are active.
return !activeArc || activeArc.length === 0 || legend === undefined || activeArc.includes(legend);
};

private _renderArcLabel = (className: string) => {
const { arc, data, innerRadius, outerRadius, showLabelsInPercent, totalValue, hideLabels } = this.props;
if (
hideLabels ||
Math.abs(data!.endAngle - data!.startAngle) < Math.PI / 12 ||
(activeArc !== data!.data.legend && activeArc !== '')
!this._shouldHighlightArc(data!.data.legend!)
) {
return null;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export interface IArcProps {
/**
* Active Arc for chart
*/
activeArc?: string;
activeArc?: string[];

/**
* internal prop for href
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ export interface IDonutChartState {
xCalloutValue?: string;
yCalloutValue?: string;
focusedArcId?: string;
selectedLegend: string;
dataPointCalloutProps?: IChartDataPoint;
callOutAccessibilityData?: IAccessibilityProps;
selectedLegends: string[];
srmukher marked this conversation as resolved.
Show resolved Hide resolved
}

export class DonutChartBase extends React.Component<IDonutChartProps, IDonutChartState> implements IChart {
Expand Down Expand Up @@ -73,12 +73,12 @@ export class DonutChartBase extends React.Component<IDonutChartProps, IDonutChar
legend: '',
_width: this.props.width || 200,
_height: this.props.height || 200,
activeLegend: '',
activeLegend: undefined,
color: '',
xCalloutValue: '',
yCalloutValue: '',
selectedLegend: props.legendProps?.selectedLegend ?? '',
focusedArcId: '',
selectedLegends: [],
};
this._hoverCallback = this._hoverCallback.bind(this);
this._focusCallback = this._focusCallback.bind(this);
Expand Down Expand Up @@ -115,7 +115,6 @@ export class DonutChartBase extends React.Component<IDonutChartProps, IDonutChar
Math.min(this.state._width! - donutMarginHorizontal, this.state._height! - donutMarginVertical) / 2;
const chartData = this._elevateToMinimums(points.filter((d: IChartDataPoint) => d.data! >= 0));
const valueInsideDonut = this._valueInsideDonut(this.props.valueInsideDonut!, chartData!);

return !this._isChartEmpty() ? (
<div
className={this._classNames.root}
Expand Down Expand Up @@ -248,23 +247,17 @@ export class DonutChartBase extends React.Component<IDonutChartProps, IDonutChar
const legend: ILegend = {
title: point.legend!,
color,
action: () => {
if (this.state.selectedLegend === point.legend) {
this.setState({ selectedLegend: '' });
} else {
this.setState({ selectedLegend: point.legend! });
}
},
hoverAction: () => {
this._handleChartMouseLeave();
this.setState({ activeLegend: point.legend! });
},
onMouseOutAction: () => {
this.setState({ activeLegend: '' });
this.setState({ activeLegend: undefined });
srmukher marked this conversation as resolved.
Show resolved Hide resolved
},
};
return legend;
});

const legends = (
<Legends
legends={legendDataItems}
Expand All @@ -273,16 +266,33 @@ export class DonutChartBase extends React.Component<IDonutChartProps, IDonutChar
focusZonePropsInHoverCard={this.props.focusZonePropsForLegendsInHoverCard}
overflowText={this.props.legendsOverflowText}
{...this.props.legendProps}
// eslint-disable-next-line react/jsx-no-bind
onChange={this._onLegendSelectionChange.bind(this)}
srmukher marked this conversation as resolved.
Show resolved Hide resolved
/>
);
return legends;
}

private _onLegendSelectionChange(
selectedLegends: string[],
event: React.MouseEvent<HTMLButtonElement>,
currentLegend?: ILegend,
): void {
if (this.props.legendProps && this.props.legendProps?.canSelectMultipleLegends) {
this.setState({ selectedLegends });
} else {
this.setState({ selectedLegends: selectedLegends.slice(-1) });
srmukher marked this conversation as resolved.
Show resolved Hide resolved
}
if (this.props.legendProps?.onChange) {
this.props.legendProps.onChange(selectedLegends, event, currentLegend);
}
}

private _focusCallback = (data: IChartDataPoint, id: string, element: SVGPathElement): void => {
this._currentHoverElement = element;
this.setState({
/** Show the callout if highlighted arc is focused and Hide it if unhighlighted arc is focused */
showHover: this.state.selectedLegend === '' || this.state.selectedLegend === data.legend,
showHover: this._noLegendsHighlighted() || this._isLegendHighlighted(data.legend),
value: data.data!.toString(),
legend: data.legend,
color: data.color!,
Expand All @@ -307,7 +317,7 @@ export class DonutChartBase extends React.Component<IDonutChartProps, IDonutChar

this.setState({
/** Show the callout if highlighted arc is hovered and Hide it if unhighlighted arc is hovered */
showHover: this.state.selectedLegend === '' || this.state.selectedLegend === data.legend,
showHover: this._noLegendsHighlighted() || this._isLegendHighlighted(data.legend),
value: data.data!.toString(),
legend: data.legend,
color,
Expand All @@ -332,16 +342,22 @@ export class DonutChartBase extends React.Component<IDonutChartProps, IDonutChar
};

private _valueInsideDonut(valueInsideDonut: string | number | undefined, data: IChartDataPoint[]) {
const highlightedLegend = this._getHighlightedLegend();
if (valueInsideDonut !== undefined && (highlightedLegend !== '' || this.state.showHover)) {
let legendValue = valueInsideDonut;
data!.map((point: IChartDataPoint, index: number) => {
if (point.legend === highlightedLegend || (this.state.showHover && point.legend === this.state.legend)) {
legendValue = point.yAxisCalloutData ? point.yAxisCalloutData : point.data!;
const highlightedLegends = this._getHighlightedLegend();
if (valueInsideDonut !== undefined && (highlightedLegends.length === 1 || this.state.showHover)) {
const pointValue = data.find(point => this._isLegendHighlighted(point.legend));
return pointValue
? pointValue.yAxisCalloutData
? pointValue.yAxisCalloutData
: pointValue.data!
: valueInsideDonut;
} else if (highlightedLegends.length > 0) {
let totalValue = 0;
data.forEach(point => {
if (highlightedLegends.includes(point.legend!)) {
totalValue += point.data!;
}
return;
});
return legendValue;
return totalValue;
} else {
return valueInsideDonut;
}
Expand All @@ -359,12 +375,23 @@ export class DonutChartBase extends React.Component<IDonutChartProps, IDonutChar
* This function returns
* the selected legend if there is one
* or the hovered legend if none of the legends is selected.
* Note: This won't work in case of multiple legends selection.
*/
private _getHighlightedLegend() {
srmukher marked this conversation as resolved.
Show resolved Hide resolved
return this.state.selectedLegend || this.state.activeLegend;
srmukher marked this conversation as resolved.
Show resolved Hide resolved
return this.state.selectedLegends.length > 0
? this.state.selectedLegends
: this.state.activeLegend
? [this.state.activeLegend]
: [];
}

private _isLegendHighlighted = (legend: string | undefined): boolean => {
return this._getHighlightedLegend().includes(legend!);
};

private _noLegendsHighlighted = (): boolean => {
return this._getHighlightedLegend().length === 0;
};

private _isChartEmpty(): boolean {
return !(
this.props.data &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ describe('Donut chart interactions', () => {
beforeEach(() => {
sharedBeforeEach();
jest.spyOn(global.Math, 'random').mockReturnValue(0.1);
// Mock the implementation of wrapTextInsideDonut as it internally calls a Browser Function like
// getComputedTextLength() which will otherwise lead to a crash if mounted
jest.spyOn(utils, 'wrapTextInsideDonut').mockImplementation(() => '20000');
});
afterEach(() => {
jest.spyOn(global.Math, 'random').mockRestore();
Expand Down Expand Up @@ -70,7 +73,7 @@ describe('Donut chart interactions', () => {

test('Should highlight the corresponding Pie on mouse over on legends', () => {
// Arrange
const { container } = render(<DonutChart data={chartPointsDC} innerRadius={55} hideLegend={false} />);
const { container } = render(<DonutChart data={chartPointsDC} innerRadius={55} hideLabels={false} />);

// Act
const legend = screen.queryByText('first');
Expand Down Expand Up @@ -157,9 +160,6 @@ describe('Donut chart interactions', () => {
});

test('Should change value inside donut with the legend value on mouseOver legend ', () => {
// Mock the implementation of wrapTextInsideDonut as it internally calls a Browser Function like
// getComputedTextLength() which will otherwise lead to a crash if mounted
jest.spyOn(utils, 'wrapTextInsideDonut').mockImplementation(() => '1000');
// Arrange
const { container } = render(
<DonutChart data={chartPointsDC} innerRadius={55} hideLegend={false} valueInsideDonut={1000} />,
Expand All @@ -184,6 +184,38 @@ describe('Donut chart interactions', () => {
// Assert
expect(container).toMatchSnapshot();
});

// add test for legend multi select
test('Should select multiple legends on click', () => {
// Arrange
const { container } = render(
<DonutChart
data={chartPointsDC}
innerRadius={55}
hideLegend={false}
legendProps={{
canSelectMultipleLegends: true,
}}
/>,
);

// Act
const firstLegend = screen.queryByText('first')?.closest('button');
const secondLegend = screen.queryByText('second')?.closest('button');
expect(firstLegend).toBeDefined();
expect(secondLegend).toBeDefined();
fireEvent.click(firstLegend!);
fireEvent.click(secondLegend!);

// Assert
expect(firstLegend).toHaveAttribute('aria-selected', 'true');
expect(secondLegend).toHaveAttribute('aria-selected', 'true');

const getById = queryAllByAttribute.bind(null, 'id');
expect(getById(container, /Pie.*?first/i)[0]).toHaveStyle('opacity: 1.0');
expect(getById(container, /Pie.*?second/i)[0]).toHaveStyle('opacity: 1.0');
srmukher marked this conversation as resolved.
Show resolved Hide resolved
expect(getById(container, /Pie.*?third/i)[0]).toHaveStyle('opacity: 0.1');
});
});

describe('Donut Chart - axe-core', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export interface IPieProps {
/**
* Active Arc for chart
*/
activeArc?: string;
activeArc?: string[];

/**
* string for callout id
Expand Down
Loading
Loading