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

react-charting: Add controlled selection to Legends component #32908

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@
{
Copy link
Collaborator

Choose a reason for hiding this comment

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

🕵🏾‍♀️ visual regressions to review in the fluentuiv8 Visual Regression Report

react-charting-AreaChart 1 screenshots
Image Name Diff(in Pixels) Image Type
react-charting-AreaChart.Custom Accessibility.chromium.png 11 Changed
react-charting-LineChart 1 screenshots
Image Name Diff(in Pixels) Image Type
react-charting-LineChart.Gaps.chromium.png 1 Changed

"type": "patch",
"comment": "Add controlled selection capability to Legends component",
"packageName": "@fluentui/react-charting",
"email": "email not defined",
"dependentChangeType": "patch"
}
2 changes: 2 additions & 0 deletions packages/react-charting/etc/react-charting.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -867,6 +867,8 @@ export interface ILegendsProps {
onLegendHoverCardLeave?: VoidFunction;
overflowProps?: Partial<IOverflowSetProps>;
overflowText?: string;
selectedLegend?: string;
selectedLegends?: string[];
shape?: LegendShape;
styles?: IStyleFunctionOrObject<ILegendStyleProps, ILegendsStyles>;
theme?: ITheme;
Expand Down
95 changes: 64 additions & 31 deletions packages/react-charting/src/components/Legends/Legends.base.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,21 +48,48 @@ export class LegendsBase extends React.Component<ILegendsProps, ILegendState> {

public constructor(props: ILegendsProps) {
super(props);
let defaultSelectedLegends = {};

const initialSelectedLegends = props.selectedLegends ?? props.defaultSelectedLegends;
const initialSelectedLegend = props.selectedLegend ?? props.defaultSelectedLegend;
let selectedLegendsState = {};

if (props.canSelectMultipleLegends) {
defaultSelectedLegends =
props.defaultSelectedLegends?.reduce((combinedDict, key) => ({ [key]: true, ...combinedDict }), {}) || {};
} else if (props.defaultSelectedLegend) {
defaultSelectedLegends = { [props.defaultSelectedLegend]: true };
selectedLegendsState =
(initialSelectedLegends ?? [])?.reduce(
(combinedDict, key) => ({ [key]: true, ...combinedDict }),
{},
) || {};
} else if (initialSelectedLegend) {
selectedLegendsState = { [initialSelectedLegend]: true };
}

this.state = {
activeLegend: '',
isHoverCardVisible: false,
selectedLegends: defaultSelectedLegends,
selectedLegends: selectedLegendsState,
};
}

public static getDerivedStateFromProps(newProps: ILegendsProps, prevState: ILegendState): ILegendState {
const { selectedLegend, selectedLegends } = newProps;

if (newProps.canSelectMultipleLegends && selectedLegends !== undefined) {
return {
...prevState,
selectedLegends: selectedLegends.reduce((combinedDict, key) => ({ [key]: true, ...combinedDict }), {}),
};
}

if (!newProps.canSelectMultipleLegends && selectedLegend !== undefined) {
return {
...prevState,
selectedLegends: { [selectedLegend]: true },
};
}

return prevState;
}

public render(): JSX.Element {
const { theme, className, styles } = this.props;
this._classNames = getClassNames(styles!, {
Expand Down Expand Up @@ -164,54 +191,60 @@ export class LegendsBase extends React.Component<ILegendsProps, ILegendState> {
};

/**
* This function will get called when there is an ability to
* select multiple legends
* @param legend ILegend
* Determine whether the component is in "controlled" mode for selections, where the selected legend(s) are
* determined entirely by props passed in from the parent component.
*/
private _canSelectMultipleLegends = (legend: ILegend): { [key: string]: boolean } => {
private _isInControlledMode = (): boolean => {
return this.props.canSelectMultipleLegends
? this.props.selectedLegends !== undefined
: this.props.selectedLegend !== undefined;
};

/**
* Get the new selected legends based on the legend that was clicked when multi-select is enabled.
* @param legend The legend that was clicked
* @returns An object with the new selected legend(s) state data.
*/
private _getNewSelectedLegendsForMultiselect = (legend: ILegend): { [key: string]: boolean } => {
let selectedLegends = { ...this.state.selectedLegends };

if (selectedLegends[legend.title]) {
// Delete entry for the deselected legend to make
// the number of keys equal to the number of selected legends
delete selectedLegends[legend.title];
} else {
selectedLegends[legend.title] = true;

// Clear set if all legends are selected
if (Object.keys(selectedLegends).length === this.props.legends.length) {
selectedLegends = {};
}
}
this.setState({ selectedLegends });

return selectedLegends;
};

/**
* This function will get called when there is
* ability to select only single legend
* @param legend ILegend
* Get the new selected legends based on the legend that was clicked when single-select is enabled.
* @param legend The legend that was clicked
* @returns An object with the new selected legend state data.
*/

private _canSelectOnlySingleLegend = (legend: ILegend): boolean => {
if (this.state.selectedLegends[legend.title]) {
this.setState({ selectedLegends: {} });
return false;
} else {
this.setState({ selectedLegends: { [legend.title]: true } });
return true;
}
private _getNewSelectedLegendsForSingleSelect = (legend: ILegend): { [key: string]: boolean } => {
return this.state.selectedLegends[legend.title] ? {} : { [legend.title]: true };
};

private _onClick = (legend: ILegend, event: React.MouseEvent<HTMLButtonElement>): void => {
const { canSelectMultipleLegends = false } = this.props;
let selectedLegends: string[] = [];
if (canSelectMultipleLegends) {
const nextSelectedLegends = this._canSelectMultipleLegends(legend);
selectedLegends = Object.keys(nextSelectedLegends);
} else {
const isSelected = this._canSelectOnlySingleLegend(legend);
selectedLegends = isSelected ? [legend.title] : [];

const nextSelectedLegends = canSelectMultipleLegends
? this._getNewSelectedLegendsForMultiselect(legend)
: this._getNewSelectedLegendsForSingleSelect(legend);

if (!this._isInControlledMode()) {
this.setState({ selectedLegends: nextSelectedLegends });
}
this.props.onChange?.(selectedLegends, event, legend);

this.props.onChange?.(Object.keys(nextSelectedLegends), event, legend);
legend.action?.();
};

Expand Down
24 changes: 24 additions & 0 deletions packages/react-charting/src/components/Legends/Legends.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -234,3 +234,27 @@ describe('Legends - multi Legends', () => {
expect(renderedLegends?.length).toBe(2);
});
});

describe('Legends - controlled legend selection', () => {
beforeEach(sharedBeforeEach);
afterEach(sharedAfterEach);
it('follows updates in the selectedLegends prop', () => {
wrapper = mount(<Legends legends={legends} canSelectMultipleLegends={true} selectedLegends={[legends[0].title]} />);
let renderedLegends = wrapper.getDOMNode().querySelectorAll('button[aria-selected="true"]');
expect(renderedLegends?.length).toBe(1);

wrapper.setProps({ selectedLegends: [legends[1].title, legends[2].title] });
renderedLegends = wrapper.getDOMNode().querySelectorAll('button[aria-selected="true"]');
expect(renderedLegends?.length).toBe(2);
});

it('follows updates in the selectedLegend prop', () => {
wrapper = mount(<Legends legends={legends} selectedLegends={[legends[0].title]} />);
let renderedLegends = wrapper.getDOMNode().querySelectorAll('button[aria-selected="true"]');
expect(renderedLegends?.length).toBe(1);

wrapper.setProps({ selectedLegends: [legends[1].title] });
renderedLegends = wrapper.getDOMNode().querySelectorAll('button[aria-selected="true"]');
expect(renderedLegends?.length).toBe(1);
});
});
47 changes: 42 additions & 5 deletions packages/react-charting/src/components/Legends/Legends.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,18 +226,55 @@ export interface ILegendsProps {
onChange?: (selectedLegends: string[], event: React.MouseEvent<HTMLButtonElement>, currentLegend?: ILegend) => void;

/**
* Keys (title) that will be initially used to set selected items.
* This prop is used for multiSelect scenarios.
* In other cases, defaultSelectedLegend should be used.
* Keys (title) that will be initially used to set selected items. This prop is used for multi-select scenarios when
* canSelectMultipleLegends is true; for single-select, use defaultSelectedLegend.
*
* Updating this prop does not change the selection after the component has been initialized. For controlled
* selections, use selectedLegends instead.
*
* @see selectedLegends for setting the selected legends in controlled mode.
* @see defaultSelectedLegend for setting the initially selected legend when canSelectMultipleLegends is false.
*/
defaultSelectedLegends?: string[];

/**
* Key that will be initially used to set selected item.
* This prop is used for singleSelect scenarios.
* Key that will be initially used to set selected item. This prop is used for single-select scenarios when
* canSelectMultipleLegends is false or unspecified; for multi-select, use defaultSelectedLegends.
*
* Updating this prop does not change the selection after the component has been initialized. For controlled
* selections, use selectedLegend instead.
*
* @see selectedLegend for setting the selected legend in controlled mode.
* @see defaultSelectedLegends for setting the initially selected legends when canSelectMultipleLegends is true.
*/
defaultSelectedLegend?: string;

/**
* Keys (title) that will be used to set selected items in multi-select scenarios when canSelectMultipleLegends is
* true. For single-select, use selectedLegend.
*
* When this prop is provided, the component is controlled and does not automatically update the selection based on
* user interactions; the parent component must update the value passed to this property by handling the onChange
* event.
*
* @see defaultSelectedLegends for setting the initially-selected legends in uncontrolled mode.
* @see selectedLegend for setting the selected legend when `canSelectMultipleLegends` is `false`.
*/
selectedLegends?: string[];
insaneinside marked this conversation as resolved.
Show resolved Hide resolved

/**
* Key (title) that will be used to set the selected item in single-select scenarios when canSelectMultipleLegends is
* false or unspecified. For multi-select, use selectedLegends.
*
* When this prop is provided, the component is controlled and does not automatically update the selection based on
* user interactions; the parent component must update the value passed to this property by handling the onChange
* event.
*
* @see defaultSelectedLegend for setting the initially-selected legend in uncontrolled mode.
* @see selectedLegends for setting the selected legends when `canSelectMultipleLegends` is `true`.
*/
selectedLegend?: string;

/**
* The shape for the legend.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import * as React from 'react';
import { Legends, ILegend, DataVizPalette, getColorFromToken } from '@fluentui/react-charting';
import { Button, Stack } from '@fluentui/react';

const legends: ILegend[] = [
{
title: 'Legend 1',
color: getColorFromToken(DataVizPalette.color1),
},
{
title: 'Legend 2',
color: getColorFromToken(DataVizPalette.color2),
},
{
title: 'Legend 3',
color: getColorFromToken(DataVizPalette.color3),
shape: 'diamond',
},
{
title: 'Legend 4',
color: getColorFromToken(DataVizPalette.color4),
shape: 'triangle',
},
];

export const LegendsControlledExample: React.FunctionComponent = () => {
const [selectedLegends, setSelectedLegends] = React.useState<string[]>([]);

const onChange = (keys: string[]) => {
setSelectedLegends(keys);
};

return (
<div>
<Stack horizontal tokens={{ childrenGap: 10 }}>
<Button onClick={() => setSelectedLegends(['Legend 1', 'Legend 3'])}>Select 1 and 3</Button>
<Button onClick={() => setSelectedLegends(['Legend 2', 'Legend 4'])}>Select 2 and 4</Button>
<Button onClick={() => setSelectedLegends(legends.map(legend => legend.title))}>Select all</Button>
</Stack>
<Legends
legends={legends}
canSelectMultipleLegends
selectedLegends={selectedLegends}
// eslint-disable-next-line react/jsx-no-bind
onChange={onChange}
/>
Selected legends: {selectedLegends.join(', ')}
</div>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { LegendOverflowExample } from './Legends.Overflow.Example';
import { LegendBasicExample } from './Legends.Basic.Example';
import { LegendWrapLinesExample } from './Legends.WrapLines.Example';
import { LegendStyledExample } from './Legends.Styled.Example';
import { LegendsControlledExample } from './Legends.Controlled.Example';

const LegendsOverflowExampleCode =
require('!raw-loader?esModule=false!@fluentui/react-examples/src/react-charting/Legends/Legends.Overflow.Example.tsx') as string;
Expand All @@ -15,6 +16,8 @@ const LegendsBasicExampleCode =
require('!raw-loader?esModule=false!@fluentui/react-examples/src/react-charting/Legends/Legends.Basic.Example.tsx') as string;
const LegendsStyledExampleCode =
require('!raw-loader?esModule=false!@fluentui/react-examples/src/react-charting/Legends/Legends.Styled.Example.tsx') as string;
const LegendsControlledExampleCode =
require('!raw-loader?esModule=false!@fluentui/react-examples/src/react-charting/Legends/Legends.Controlled.Example.tsx') as string;

export const LegendsPageProps: IDocPageProps = {
title: 'Legends',
Expand All @@ -41,6 +44,11 @@ export const LegendsPageProps: IDocPageProps = {
code: LegendsStyledExampleCode,
view: <LegendStyledExample />,
},
{
title: 'Legend controlled selection',
code: LegendsControlledExampleCode,
view: <LegendsControlledExample />,
},
],
overview: require<string>('!raw-loader?esModule=false!@fluentui/react-examples/src/react-charting/Legends/docs/LegendsOverview.md'),
bestPractices: require<string>('!raw-loader?esModule=false!@fluentui/react-examples/src/react-charting/Legends/docs/LegendsBestPractices.md'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { LegendBasicExample } from './Legends.Basic.Example';
import { LegendWrapLinesExample } from './Legends.WrapLines.Example';
import { LegendStyledExample } from './Legends.Styled.Example';
import { LegendsOnChangeExample } from './Legends.OnChange.Example';
import { LegendsControlledExample } from './Legends.Controlled.Example';

const LegendsOverflowExampleCode =
require('!raw-loader?esModule=false!@fluentui/react-examples/src/react-charting/Legends/Legends.Overflow.Example.tsx') as string;
Expand All @@ -24,6 +25,8 @@ const LegendsStyledExampleCode =
require('!raw-loader?esModule=false!@fluentui/react-examples/src/react-charting/Legends/Legends.Styled.Example.tsx') as string;
const LegendsOnChangeExampleCode =
require('!raw-loader?esModule=false!@fluentui/react-examples/src/react-charting/Legends/Legends.OnChange.Example.tsx') as string;
const LegendsControlledExampleCode =
require('!raw-loader?esModule=false!@fluentui/react-examples/src/react-charting/Legends/Legends.Controlled.Example.tsx') as string;

export class LegendsPage extends React.Component<IComponentDemoPageProps, {}> {
public render(): JSX.Element {
Expand Down Expand Up @@ -52,6 +55,10 @@ export class LegendsPage extends React.Component<IComponentDemoPageProps, {}> {
<ExampleCard title="Legends onChange" code={LegendsOnChangeExampleCode}>
<LegendsOnChangeExample />
</ExampleCard>

<ExampleCard title="Legends controlled selection" code={LegendsControlledExampleCode}>
<LegendsControlledExample />
</ExampleCard>
</div>
}
propertiesTables={
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { LegendsOnChangeExample } from './Legends.OnChange.Example';
import { LegendOverflowExample } from './Legends.Overflow.Example';
import { LegendStyledExample } from './Legends.Styled.Example';
import { LegendWrapLinesExample } from './Legends.WrapLines.Example';
import { LegendsControlledExample } from './Legends.Controlled.Example';

export const Basic = () => <LegendBasicExample />;

Expand All @@ -16,6 +17,8 @@ export const Styled = () => <LegendStyledExample />;

export const WrapLines = () => <LegendWrapLinesExample />;

export const Controlled = () => <LegendsControlledExample />;

export default {
title: 'Components/Legends',
};
Loading