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

Open
wants to merge 21 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 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
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 @@ -94,6 +94,9 @@ export const transformPlotlyJsonToDonutProps = (
hideLabels,
showLabelsInPercent: firstData.textinfo ? firstData.textinfo === 'percent' : true,
styles,
legendProps: {
canSelectMultipleLegends: true,
},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

legend props are defined in DeclarativeChart.tsx. Use that only.

};
};

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,17 @@ 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;
return !(activeArc && activeArc.length > 0 && legend !== undefined && !activeArc.includes(legend));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

!

why is there negation sign here

};

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[];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

selectedLegends

can you resolve all addressed comments

}

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 });
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

undefined

setting as empty should be ok here

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no, checked with a legend having '' title, it does not work in that case

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok

},
};
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)}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this._onLegendSelectionChange.bind(this)

do you need to use this twice?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is used just once

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i mean the class instance "this"

/>
);
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) });
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

selectedLegends.slice(-1)

does this work when the legend is unselected

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes working

}
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 !== 0 || this.state.showHover)) {
const pointValue = data.find(point => point.legend === this.state.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() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_getHighlightedLegend

modify this function for multiselect

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this was already modified

return this.state.selectedLegend || this.state.activeLegend;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return this.state.selectedLegend || this.state.activeLegend;

This is an or condition between selected and active.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For multiple select also checking if this.state.selectedLegends is populated other return the this.state.activeLegend
return this.state.selectedLegends.length > 0 ? this.state.selectedLegends : [this.state.activeLegend];

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 @@ -170,7 +170,7 @@ describe('Donut chart interactions', () => {
fireEvent.mouseOver(screen.getByText('first'));

// Assert
expect(getByClass(container, /insideDonutString.*?/)[0].textContent).toBe('20,000');
expect(getByClass(container, /insideDonutString.*?/)[0].textContent).toBe('1,000');
});

test('Should reflect theme change', () => {
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');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is good. Thanks.

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