-
Notifications
You must be signed in to change notification settings - Fork 2.8k
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
base: master
Are you sure you want to change the base?
Changes from all commits
b0c7663
24ce6bf
434fc6d
70b0404
addca43
7293f33
ae348e2
88eb247
db555d3
4fdc773
a1dc7a9
13876f3
cea2d29
be186ba
d4641c5
0e62c96
568ca4e
36e6bdc
a94e9f1
cfd09ea
0e7fc13
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 |
---|---|---|
|
@@ -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; | ||
|
@@ -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} | ||
|
@@ -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)); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
}; | ||
|
||
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; | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -25,9 +25,9 @@ export interface IDonutChartState { | |
xCalloutValue?: string; | ||
yCalloutValue?: string; | ||
focusedArcId?: string; | ||
selectedLegend: string; | ||
dataPointCalloutProps?: IChartDataPoint; | ||
callOutAccessibilityData?: IAccessibilityProps; | ||
selectedLegends: string[]; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
} | ||
|
||
export class DonutChartBase extends React.Component<IDonutChartProps, IDonutChartState> implements IChart { | ||
|
@@ -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); | ||
|
@@ -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} | ||
|
@@ -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 }); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ok |
||
}, | ||
}; | ||
return legend; | ||
}); | ||
|
||
const legends = ( | ||
<Legends | ||
legends={legendDataItems} | ||
|
@@ -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)} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is used just once There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) }); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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!, | ||
|
@@ -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, | ||
|
@@ -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; | ||
} | ||
|
@@ -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() { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this was already modified |
||
return this.state.selectedLegend || this.state.activeLegend; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
? [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 && | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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(); | ||
|
@@ -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'); | ||
|
@@ -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} />, | ||
|
@@ -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', () => { | ||
|
@@ -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'); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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', () => { | ||
|
There was a problem hiding this comment.
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.