Skip to content

Commit

Permalink
feat(react-charting): Support changing legends programatically at run…
Browse files Browse the repository at this point in the history
…time and bug fixes (#33519)
  • Loading branch information
AtishayMsft authored Dec 26, 2024
1 parent bdba671 commit 6a70a11
Show file tree
Hide file tree
Showing 16 changed files with 223 additions and 44 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "Support changing legends programatically at runtime and bug fixes",
"packageName": "@fluentui/react-charting",
"email": "atishay.jain@microsoft.com",
"dependentChangeType": "patch"
}
1 change: 1 addition & 0 deletions packages/charts/react-charting/etc/react-charting.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1228,6 +1228,7 @@ export interface ISankeyChartData {
export interface ISankeyChartProps {
accessibility?: ISankeyChartAccessibilityProps;
borderColorsForNodes?: string[];
calloutProps?: Partial<ICalloutProps>;
className?: string;
colorsForNodes?: string[];
componentRef?: IRefObject<IChart>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,13 @@ export class AreaChartBase extends React.Component<IAreaChartProps, IAreaChartSt
this._cartesianChartRef = React.createRef();
}

public componentDidUpdate() {
public componentDidUpdate(prevProps: IAreaChartProps): void {
if (prevProps.legendProps?.selectedLegend !== this.props.legendProps?.selectedLegend) {
this.setState({
selectedLegend: this.props.legendProps?.selectedLegend ?? '',
});
}

if (this.state.isShowCalloutPending) {
this.setState({
refSelected: `#${this._highlightedCircleId}`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ import { IRefObject } from '@fluentui/react/lib/Utilities';
import { DonutChart } from '../DonutChart/index';
import { VerticalStackedBarChart } from '../VerticalStackedBarChart/index';
import {
isArrayOrTypedArray,
isDateArray,
isNumberArray,
sanitizeJson,
transformPlotlyJsonToDonutProps,
transformPlotlyJsonToVSBCProps,
transformPlotlyJsonToScatterChartProps,
transformPlotlyJsonToHorizontalBarWithAxisProps,
isDateArray,
isNumberArray,
transformPlotlyJsonToHeatmapProps,
transformPlotlyJsonToSankeyProps,
transformPlotlyJsonToGaugeProps,
Expand Down Expand Up @@ -82,8 +84,9 @@ export const DeclarativeChart: React.FunctionComponent<DeclarativeChartProps> =
HTMLDivElement,
DeclarativeChartProps
>((props, forwardedRef) => {
const { plotlySchema } = props.chartSchema;
const { data, layout, selectedLegends } = plotlySchema;
const { plotlySchema } = sanitizeJson(props.chartSchema);
const { data, layout } = plotlySchema;
let { selectedLegends } = plotlySchema;
const xValues = data[0].x;
const isXDate = isDateArray(xValues);
const isXNumber = isNumberArray(xValues);
Expand All @@ -92,18 +95,30 @@ export const DeclarativeChart: React.FunctionComponent<DeclarativeChartProps> =
const isDarkTheme = theme?.isInverted ?? false;
const chartRef = React.useRef<IChart>(null);

const [activeLegends, setActiveLegends] = React.useState<string[]>(selectedLegends ?? []);
if (!isArrayOrTypedArray(selectedLegends)) {
selectedLegends = [];
}

const [activeLegends, setActiveLegends] = React.useState<string[]>(selectedLegends);
const onActiveLegendsChange = (keys: string[]) => {
setActiveLegends(keys);
if (props.onSchemaChange) {
props.onSchemaChange({ plotlySchema: { data, layout, selectedLegends: keys } });
}
};

React.useEffect(() => {
// eslint-disable-next-line @typescript-eslint/no-shadow
const { plotlySchema } = sanitizeJson(props.chartSchema);
// eslint-disable-next-line @typescript-eslint/no-shadow
const { selectedLegends } = plotlySchema;
setActiveLegends(selectedLegends ?? []);
}, [props.chartSchema]);

const legendProps = {
canSelectMultipleLegends: false,
onChange: onActiveLegendsChange,
...(activeLegends.length > 0 && { selectedLegend: activeLegends[0] }),
selectedLegend: activeLegends.slice(0, 1)[0],
};

const exportAsImage = React.useCallback(
Expand All @@ -129,8 +144,10 @@ export const DeclarativeChart: React.FunctionComponent<DeclarativeChartProps> =
return (
<DonutChart
{...transformPlotlyJsonToDonutProps(plotlySchema, colorMap, isDarkTheme)}
legendProps={{ ...legendProps, canSelectMultipleLegends: true }}
legendProps={{ ...legendProps, canSelectMultipleLegends: true, selectedLegends: activeLegends }}
componentRef={chartRef}
// Bubble event to prevent right click to open menu on the callout
calloutProps={{ layerProps: { eventBubblingEnabled: true } }}
/>
);
case 'bar':
Expand All @@ -141,6 +158,7 @@ export const DeclarativeChart: React.FunctionComponent<DeclarativeChartProps> =
{...transformPlotlyJsonToHorizontalBarWithAxisProps(plotlySchema, colorMap, isDarkTheme)}
legendProps={legendProps}
componentRef={chartRef}
calloutProps={{ layerProps: { eventBubblingEnabled: true } }}
/>
);
} else {
Expand All @@ -150,6 +168,7 @@ export const DeclarativeChart: React.FunctionComponent<DeclarativeChartProps> =
{...transformPlotlyJsonToGVBCProps(plotlySchema, colorMap, isDarkTheme)}
legendProps={legendProps}
componentRef={chartRef}
calloutProps={{ layerProps: { eventBubblingEnabled: true } }}
/>
);
}
Expand All @@ -158,6 +177,7 @@ export const DeclarativeChart: React.FunctionComponent<DeclarativeChartProps> =
{...transformPlotlyJsonToVSBCProps(plotlySchema, colorMap, isDarkTheme)}
legendProps={legendProps}
componentRef={chartRef}
calloutProps={{ layerProps: { eventBubblingEnabled: true } }}
/>
);
}
Expand All @@ -170,18 +190,16 @@ export const DeclarativeChart: React.FunctionComponent<DeclarativeChartProps> =
{...transformPlotlyJsonToScatterChartProps({ data, layout }, true, colorMap, isDarkTheme)}
legendProps={legendProps}
componentRef={chartRef}
calloutProps={{ layerProps: { eventBubblingEnabled: true } }}
/>
);
}
return (
<LineChart
{...transformPlotlyJsonToScatterChartProps({ data, layout }, false, colorMap, isDarkTheme)}
legendProps={{
onChange: onActiveLegendsChange,
canSelectMultipleLegends: true,
selectedLegends: activeLegends,
}}
legendProps={{ ...legendProps, canSelectMultipleLegends: true, selectedLegends: activeLegends }}
componentRef={chartRef}
calloutProps={{ layerProps: { eventBubblingEnabled: true } }}
/>
);
}
Expand All @@ -190,6 +208,7 @@ export const DeclarativeChart: React.FunctionComponent<DeclarativeChartProps> =
{...transformPlotlyJsonToVSBCProps(plotlySchema, colorMap, isDarkTheme)}
legendProps={legendProps}
componentRef={chartRef}
calloutProps={{ layerProps: { eventBubblingEnabled: true } }}
/>
);
case 'heatmap':
Expand All @@ -198,13 +217,15 @@ export const DeclarativeChart: React.FunctionComponent<DeclarativeChartProps> =
{...transformPlotlyJsonToHeatmapProps(plotlySchema)}
legendProps={legendProps}
componentRef={chartRef}
calloutProps={{ layerProps: { eventBubblingEnabled: true } }}
/>
);
case 'sankey':
return (
<SankeyChart
{...transformPlotlyJsonToSankeyProps(plotlySchema, colorMap, isDarkTheme)}
componentRef={chartRef}
calloutProps={{ layerProps: { eventBubblingEnabled: true } }}
/>
);
case 'indicator':
Expand All @@ -214,6 +235,7 @@ export const DeclarativeChart: React.FunctionComponent<DeclarativeChartProps> =
{...transformPlotlyJsonToGaugeProps(plotlySchema, colorMap, isDarkTheme)}
legendProps={legendProps}
componentRef={chartRef}
calloutProps={{ layerProps: { eventBubblingEnabled: true } }}
/>
);
}
Expand All @@ -224,10 +246,11 @@ export const DeclarativeChart: React.FunctionComponent<DeclarativeChartProps> =
{...transformPlotlyJsonToVBCProps(plotlySchema, colorMap, isDarkTheme)}
legendProps={legendProps}
componentRef={chartRef}
calloutProps={{ layerProps: { eventBubblingEnabled: true } }}
/>
);
default:
return <div>Unsupported Schema</div>;
throw new Error('Unsupported chart schema');
}
});
DeclarativeChart.displayName = 'DeclarativeChart';
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,6 @@ export const transformPlotlyJsonToVBCProps = (
chartTitle: typeof layout?.title === 'string' ? layout?.title : '',
// width: layout?.width,
// height: layout?.height,
// hideLegend: true,
barWidth: 24,
supportNegativeData: true,
};
Expand Down Expand Up @@ -505,11 +504,32 @@ export const transformPlotlyJsonToGaugeProps = (
};
};

const MAX_DEPTH = 8;
export const sanitizeJson = (jsonObject: any, depth: number = 0): any => {
if (depth > MAX_DEPTH) {
throw new Error('Maximum json depth exceeded');
}

if (typeof jsonObject === 'object' && jsonObject !== null) {
for (const key in jsonObject) {
if (jsonObject.hasOwnProperty(key)) {
if (typeof jsonObject[key] === 'string') {
jsonObject[key] = jsonObject[key].replace(/</g, '&lt;').replace(/>/g, '&gt;');
} else {
jsonObject[key] = sanitizeJson(jsonObject[key], depth + 1);
}
}
}
}

return jsonObject;
};

function isTypedArray(a: any) {
return ArrayBuffer.isView(a) && !(a instanceof DataView);
}

function isArrayOrTypedArray(a: any) {
export function isArrayOrTypedArray(a: any) {
return Array.isArray(a) || isTypedArray(a);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export class DonutChartBase extends React.Component<IDonutChartProps, IDonutChar
xCalloutValue: '',
yCalloutValue: '',
focusedArcId: '',
selectedLegends: [],
selectedLegends: props.legendProps?.selectedLegends || [],
};
this._hoverCallback = this._hoverCallback.bind(this);
this._focusCallback = this._focusCallback.bind(this);
Expand All @@ -87,6 +87,7 @@ export class DonutChartBase extends React.Component<IDonutChartProps, IDonutChar
this._uniqText = getId('_Pie_');
this._emptyChartId = getId('_DonutChart_empty');
}

public componentDidMount(): void {
if (this._rootElem) {
this.setState({
Expand All @@ -96,6 +97,14 @@ export class DonutChartBase extends React.Component<IDonutChartProps, IDonutChar
}
}

public componentDidUpdate(prevProps: IDonutChartProps): void {
if (prevProps.legendProps?.selectedLegends !== this.props.legendProps?.selectedLegends) {
this.setState({
selectedLegends: this.props.legendProps?.selectedLegends || [],
});
}
}

public render(): JSX.Element {
const { data, hideLegend = false } = this.props;
const points = this._addDefaultColors(data?.chartData);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,14 @@ export class GaugeChartBase extends React.Component<IGaugeChartProps, IGaugeChar
}
}

public componentDidUpdate(prevProps: IGaugeChartProps): void {
if (prevProps.legendProps?.selectedLegend !== this.props.legendProps?.selectedLegend) {
this.setState({
selectedLegend: this.props.legendProps?.selectedLegend ?? '',
});
}
}

public render(): React.ReactNode {
this._margins = this._getMargins();
this._legendsHeight = !this.props.hideLegend ? 24 : 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,14 @@ export class GroupedVerticalBarChartBase
this._cartesianChartRef = React.createRef();
}

public componentDidUpdate(prevProps: IGroupedVerticalBarChartProps): void {
if (prevProps.legendProps?.selectedLegend !== this.props.legendProps?.selectedLegend) {
this.setState({
selectedLegend: this.props.legendProps?.selectedLegend ?? '',
});
}
}

public render(): React.ReactNode {
const points = this.props.data;
const { keys, xAxisLabels, datasetForBars } = this._createSet(points);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,14 @@ export class HeatMapChartBase extends React.Component<IHeatMapChartProps, IHeatM
this._cartesianChartRef = React.createRef();
}

public componentDidUpdate(prevProps: IHeatMapChartProps): void {
if (prevProps.legendProps?.selectedLegend !== this.props.legendProps?.selectedLegend) {
this.setState({
selectedLegend: this.props.legendProps?.selectedLegend ?? '',
});
}
}

public render(): React.ReactNode {
const { x, y } = this._getXandY();
this._xAxisType = getTypeOfAxis(x, true) as XAxisTypes;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,14 @@ export class HorizontalBarChartWithAxisBase
this._cartesianChartRef = React.createRef();
}

public componentDidUpdate(prevProps: IHorizontalBarChartWithAxisProps): void {
if (prevProps.legendProps?.selectedLegend !== this.props.legendProps?.selectedLegend) {
this.setState({
selectedLegend: this.props.legendProps?.selectedLegend ?? '',
});
}
}

public render(): JSX.Element {
this._adjustProps();
const reversedBars = [...this._points].reverse();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -230,8 +230,19 @@ export class LineChartBase extends React.Component<ILineChartProps, ILineChartSt
}

public componentDidUpdate(prevProps: ILineChartProps): void {
if (
prevProps.legendProps?.selectedLegend !== this.props.legendProps?.selectedLegend ||
prevProps.legendProps?.selectedLegends !== this.props.legendProps?.selectedLegends
) {
this.setState({
selectedLegend: this.props.legendProps?.selectedLegend ?? '',
selectedLegendPoints: this._injectIndexPropertyInLineChartData(this.props.data.lineChartData, true),
isSelectedLegend: (this.props.legendProps?.selectedLegends?.length ?? 0) > 0,
});
}

/** note that height and width are not used to resize or set as dimesions of the chart,
* fitParentContainer is responisble for setting the height and width or resizing of the svg/chart
* fitParentContainer is responsible for setting the height and width or resizing of the svg/chart
*/
if (
prevProps.height !== this.props.height ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -834,6 +834,7 @@ export class SankeyChartBase extends React.Component<ISankeyChartProps, ISankeyC
onDismiss: this._onCloseCallout,
className: classNames.calloutContentRoot,
preventDismissOnLostFocus: true,
...this.props.calloutProps!,
};
return (
<div
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { IStyle, ITheme } from '@fluentui/react/lib/Styling';
import { IRefObject, IStyleFunctionOrObject } from '@fluentui/react/lib/Utilities';
import { ICalloutProps } from '@fluentui/react/lib/Callout';
import { IChart, IChartProps } from '../../types/IDataPoint';

export type { IChartProps, IDataPoint, ISankeyChartData } from '../../types/IDataPoint';
Expand Down Expand Up @@ -90,6 +91,11 @@ export interface ISankeyChartProps {
* the public methods and properties of the component.
*/
componentRef?: IRefObject<IChart>;

/**
* props for the callout in the chart
*/
calloutProps?: Partial<ICalloutProps>;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,14 @@ export class VerticalBarChartBase
this._cartesianChartRef = React.createRef();
}

public componentDidUpdate(prevProps: IVerticalBarChartProps): void {
if (prevProps.legendProps?.selectedLegend !== this.props.legendProps?.selectedLegend) {
this.setState({
selectedLegend: this.props.legendProps?.selectedLegend ?? '',
});
}
}

public render(): JSX.Element {
this._adjustProps();
this._xAxisLabels = this._points.map((point: IVerticalBarChartDataPoint) => point.x as string);
Expand Down
Loading

0 comments on commit 6a70a11

Please sign in to comment.