Skip to content

Commit

Permalink
Add Support for Controlled Selection of Legends (#33436)
Browse files Browse the repository at this point in the history
  • Loading branch information
Anush2303 authored Dec 11, 2024
1 parent a6cc5b0 commit 608336d
Show file tree
Hide file tree
Showing 9 changed files with 221 additions and 35 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "Add support for controlled selction of legends.",
"packageName": "@fluentui/react-charting",
"email": "[email protected]",
"dependentChangeType": "patch"
}
2 changes: 2 additions & 0 deletions packages/charts/react-charting/etc/react-charting.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -876,6 +876,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
Original file line number Diff line number Diff line change
Expand Up @@ -46,20 +46,52 @@ export class LegendsBase extends React.Component<ILegendsProps, ILegendState> {
/** Boolean variable to check if one or more legends are selected */
private _isLegendSelected = false;

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

if (newProps.canSelectMultipleLegends && selectedLegends !== undefined) {
return {
...prevState,
selectedLegends: selectedLegends.reduce(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(combinedDict: any, key: any) => ({ [key]: true, ...combinedDict }),
{},
),
};
}

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

return prevState;
}

public constructor(props: ILegendsProps) {
super(props);
let defaultSelectedLegends = {};
//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(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(combineDict: any, key: any) => ({ [key]: true, ...combineDict }),
{},
) || {};
} else if (initialSelectedLegend) {
selectedLegendsState = { [initialSelectedLegend]: true };
}

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

Expand Down Expand Up @@ -164,11 +196,21 @@ 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 _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 _canSelectMultipleLegends = (legend: ILegend): { [key: string]: boolean } => {
private _getNewSelectedLegendsForMultiselect = (legend: ILegend): { [key: string]: boolean } => {
let selectedLegends = { ...this.state.selectedLegends };
if (selectedLegends[legend.title]) {
// Delete entry for the deselected legend to make
Expand All @@ -181,37 +223,29 @@ export class LegendsBase extends React.Component<ILegendsProps, ILegendState> {
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
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} selectedLegend={legends[0].title} />);
let renderedLegends = wrapper.getDOMNode().querySelectorAll('button[aria-selected="true"]');
expect(renderedLegends?.length).toBe(1);

wrapper.setProps({ selectedLegend: legends[1].title });
renderedLegends = wrapper.getDOMNode().querySelectorAll('button[aria-selected="true"]');
expect(renderedLegends?.length).toBe(1);
});
});
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 selectedLegends for setting the selected legends when `canSelectMultipleLegends` is `true`.
*/
selectedLegends?: string[];

/**
* 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 selectedLegend for setting the selected legend when `canSelectMultipleLegends` is `false`.
*/
selectedLegend?: string;

/**
* The shape for the legend.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/* eslint-disable react/jsx-no-bind */
import * as React from 'react';
import { Legends, ILegend, DataVizPalette, getColorFromToken } from '@fluentui/react-charting';
import { DefaultButton, 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);
};

const handleSelect1And3 = () => {
setSelectedLegends(['Legend 1', 'Legend 3']);
};

const handleSelect2And4 = () => {
setSelectedLegends(['Legend 2', 'Legend 4']);
};

const handleSelectAll = () => {
setSelectedLegends(legends.map(legend => legend.title));
};

return (
<div>
<Stack horizontal tokens={{ childrenGap: 10 }} styles={{ root: { marginBottom: 15 } }}>
<DefaultButton onClick={handleSelect1And3}>Select 1 and 3</DefaultButton>
<DefaultButton onClick={handleSelect2And4}>Select 2 and 4</DefaultButton>
<DefaultButton onClick={handleSelectAll}>Select all</DefaultButton>
</Stack>
<Legends
legends={legends}
canSelectMultipleLegends
selectedLegends={selectedLegends}
// eslint-disable-next-line react/jsx-no-bind
onChange={onChange}
styles={{ root: { marginBottom: 10 } }}
/>
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 Down Expand Up @@ -42,6 +45,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',
};

0 comments on commit 608336d

Please sign in to comment.