diff --git a/change/@fluentui-react-charting-14c34b26-6050-4fa3-8ab9-ccfc1661f953.json b/change/@fluentui-react-charting-14c34b26-6050-4fa3-8ab9-ccfc1661f953.json new file mode 100644 index 0000000000000..dc02c066b09c8 --- /dev/null +++ b/change/@fluentui-react-charting-14c34b26-6050-4fa3-8ab9-ccfc1661f953.json @@ -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" +} diff --git a/packages/charts/react-charting/etc/react-charting.api.md b/packages/charts/react-charting/etc/react-charting.api.md index f07cb027b9303..a9793c3c65d41 100644 --- a/packages/charts/react-charting/etc/react-charting.api.md +++ b/packages/charts/react-charting/etc/react-charting.api.md @@ -1228,6 +1228,7 @@ export interface ISankeyChartData { export interface ISankeyChartProps { accessibility?: ISankeyChartAccessibilityProps; borderColorsForNodes?: string[]; + calloutProps?: Partial; className?: string; colorsForNodes?: string[]; componentRef?: IRefObject; diff --git a/packages/charts/react-charting/src/components/AreaChart/AreaChart.base.tsx b/packages/charts/react-charting/src/components/AreaChart/AreaChart.base.tsx index 578261e346084..227eb8686eaac 100644 --- a/packages/charts/react-charting/src/components/AreaChart/AreaChart.base.tsx +++ b/packages/charts/react-charting/src/components/AreaChart/AreaChart.base.tsx @@ -162,7 +162,13 @@ export class AreaChartBase extends React.Component = 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); @@ -92,7 +95,11 @@ export const DeclarativeChart: React.FunctionComponent = const isDarkTheme = theme?.isInverted ?? false; const chartRef = React.useRef(null); - const [activeLegends, setActiveLegends] = React.useState(selectedLegends ?? []); + if (!isArrayOrTypedArray(selectedLegends)) { + selectedLegends = []; + } + + const [activeLegends, setActiveLegends] = React.useState(selectedLegends); const onActiveLegendsChange = (keys: string[]) => { setActiveLegends(keys); if (props.onSchemaChange) { @@ -100,10 +107,18 @@ export const DeclarativeChart: React.FunctionComponent = } }; + 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( @@ -129,8 +144,10 @@ export const DeclarativeChart: React.FunctionComponent = return ( ); case 'bar': @@ -141,6 +158,7 @@ export const DeclarativeChart: React.FunctionComponent = {...transformPlotlyJsonToHorizontalBarWithAxisProps(plotlySchema, colorMap, isDarkTheme)} legendProps={legendProps} componentRef={chartRef} + calloutProps={{ layerProps: { eventBubblingEnabled: true } }} /> ); } else { @@ -150,6 +168,7 @@ export const DeclarativeChart: React.FunctionComponent = {...transformPlotlyJsonToGVBCProps(plotlySchema, colorMap, isDarkTheme)} legendProps={legendProps} componentRef={chartRef} + calloutProps={{ layerProps: { eventBubblingEnabled: true } }} /> ); } @@ -158,6 +177,7 @@ export const DeclarativeChart: React.FunctionComponent = {...transformPlotlyJsonToVSBCProps(plotlySchema, colorMap, isDarkTheme)} legendProps={legendProps} componentRef={chartRef} + calloutProps={{ layerProps: { eventBubblingEnabled: true } }} /> ); } @@ -170,18 +190,16 @@ export const DeclarativeChart: React.FunctionComponent = {...transformPlotlyJsonToScatterChartProps({ data, layout }, true, colorMap, isDarkTheme)} legendProps={legendProps} componentRef={chartRef} + calloutProps={{ layerProps: { eventBubblingEnabled: true } }} /> ); } return ( ); } @@ -190,6 +208,7 @@ export const DeclarativeChart: React.FunctionComponent = {...transformPlotlyJsonToVSBCProps(plotlySchema, colorMap, isDarkTheme)} legendProps={legendProps} componentRef={chartRef} + calloutProps={{ layerProps: { eventBubblingEnabled: true } }} /> ); case 'heatmap': @@ -198,6 +217,7 @@ export const DeclarativeChart: React.FunctionComponent = {...transformPlotlyJsonToHeatmapProps(plotlySchema)} legendProps={legendProps} componentRef={chartRef} + calloutProps={{ layerProps: { eventBubblingEnabled: true } }} /> ); case 'sankey': @@ -205,6 +225,7 @@ export const DeclarativeChart: React.FunctionComponent = ); case 'indicator': @@ -214,6 +235,7 @@ export const DeclarativeChart: React.FunctionComponent = {...transformPlotlyJsonToGaugeProps(plotlySchema, colorMap, isDarkTheme)} legendProps={legendProps} componentRef={chartRef} + calloutProps={{ layerProps: { eventBubblingEnabled: true } }} /> ); } @@ -224,10 +246,11 @@ export const DeclarativeChart: React.FunctionComponent = {...transformPlotlyJsonToVBCProps(plotlySchema, colorMap, isDarkTheme)} legendProps={legendProps} componentRef={chartRef} + calloutProps={{ layerProps: { eventBubblingEnabled: true } }} /> ); default: - return
Unsupported Schema
; + throw new Error('Unsupported chart schema'); } }); DeclarativeChart.displayName = 'DeclarativeChart'; diff --git a/packages/charts/react-charting/src/components/DeclarativeChart/PlotlySchemaAdapter.ts b/packages/charts/react-charting/src/components/DeclarativeChart/PlotlySchemaAdapter.ts index 1339f1827c2be..471db94b0c098 100644 --- a/packages/charts/react-charting/src/components/DeclarativeChart/PlotlySchemaAdapter.ts +++ b/packages/charts/react-charting/src/components/DeclarativeChart/PlotlySchemaAdapter.ts @@ -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, }; @@ -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, '>'); + } 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); } diff --git a/packages/charts/react-charting/src/components/DonutChart/DonutChart.base.tsx b/packages/charts/react-charting/src/components/DonutChart/DonutChart.base.tsx index fb747b57d5fef..e1bdcc6cef078 100644 --- a/packages/charts/react-charting/src/components/DonutChart/DonutChart.base.tsx +++ b/packages/charts/react-charting/src/components/DonutChart/DonutChart.base.tsx @@ -78,7 +78,7 @@ export class DonutChartBase extends React.Component 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 || diff --git a/packages/charts/react-charting/src/components/SankeyChart/SankeyChart.base.tsx b/packages/charts/react-charting/src/components/SankeyChart/SankeyChart.base.tsx index 721d9ea33f543..163ce0796fb1e 100644 --- a/packages/charts/react-charting/src/components/SankeyChart/SankeyChart.base.tsx +++ b/packages/charts/react-charting/src/components/SankeyChart/SankeyChart.base.tsx @@ -834,6 +834,7 @@ export class SankeyChartBase extends React.Component; + + /** + * props for the callout in the chart + */ + calloutProps?: Partial; } /** diff --git a/packages/charts/react-charting/src/components/VerticalBarChart/VerticalBarChart.base.tsx b/packages/charts/react-charting/src/components/VerticalBarChart/VerticalBarChart.base.tsx index 5159a77012108..5a01b1ac417d6 100644 --- a/packages/charts/react-charting/src/components/VerticalBarChart/VerticalBarChart.base.tsx +++ b/packages/charts/react-charting/src/components/VerticalBarChart/VerticalBarChart.base.tsx @@ -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); diff --git a/packages/charts/react-charting/src/components/VerticalStackedBarChart/VerticalStackedBarChart.base.tsx b/packages/charts/react-charting/src/components/VerticalStackedBarChart/VerticalStackedBarChart.base.tsx index 72f8436f71293..06bcd655861c8 100644 --- a/packages/charts/react-charting/src/components/VerticalStackedBarChart/VerticalStackedBarChart.base.tsx +++ b/packages/charts/react-charting/src/components/VerticalStackedBarChart/VerticalStackedBarChart.base.tsx @@ -170,6 +170,12 @@ export class VerticalStackedBarChartBase } public componentDidUpdate(prevProps: IVerticalStackedBarChartProps): void { + if (prevProps.legendProps?.selectedLegend !== this.props.legendProps?.selectedLegend) { + this.setState({ + selectedLegend: this.props.legendProps?.selectedLegend ?? '', + }); + } + if ( prevProps.height !== this.props.height || prevProps.width !== this.props.width || diff --git a/packages/react-examples/src/react-charting/DeclarativeChart/DeclarativeChart.Basic.Example.tsx b/packages/react-examples/src/react-charting/DeclarativeChart/DeclarativeChart.Basic.Example.tsx index ec0d58eabee23..4009fddcc5287 100644 --- a/packages/react-examples/src/react-charting/DeclarativeChart/DeclarativeChart.Basic.Example.tsx +++ b/packages/react-examples/src/react-charting/DeclarativeChart/DeclarativeChart.Basic.Example.tsx @@ -1,11 +1,39 @@ import * as React from 'react'; import { Dropdown, IDropdownOption } from '@fluentui/react/lib/Dropdown'; -import { Toggle } from '@fluentui/react/lib/Toggle'; +import { TextField, ITextFieldStyles } from '@fluentui/react/lib/TextField'; import { DeclarativeChart, DeclarativeChartProps, IDeclarativeChart, Schema } from '@fluentui/react-charting'; +interface IErrorBoundaryProps { + children: React.ReactNode; +} + +interface IErrorBoundaryState { + hasError: boolean; + error: string; +} + +class ErrorBoundary extends React.Component { + public static getDerivedStateFromError(error: Error) { + // Update state so the next render will show the fallback UI. + return { hasError: true, error: `${error.message} ${error.stack}` }; + } + + constructor(props: IErrorBoundaryProps) { + super(props); + this.state = { hasError: false, error: '' }; + } + + public render() { + if (this.state.hasError) { + return

${this.state.error}

; + } + + return this.props.children; + } +} + interface IDeclarativeChartState { selectedChoice: string; - preSelectLegends: boolean; selectedLegends: string; } @@ -37,6 +65,8 @@ const schemas: any[] = [ const dropdownStyles = { dropdown: { width: 200 } }; +const textFieldStyles: Partial = { root: { maxWidth: 300 } }; + function fileSaver(url: string) { const saveLink = document.createElement('a'); saveLink.href = url; @@ -48,16 +78,26 @@ function fileSaver(url: string) { export class DeclarativeChartBasicExample extends React.Component<{}, IDeclarativeChartState> { private _declarativeChartRef: React.RefObject; + private _lastKnownValidLegends: string[] | undefined; constructor(props: DeclarativeChartProps) { super(props); + const defaultselection = 'donutchart'; + const selectedPlotlySchema = this._getSchemaByKey(defaultselection); + const { selectedLegends } = selectedPlotlySchema; this.state = { - selectedChoice: 'donutchart', - preSelectLegends: false, - selectedLegends: '', + selectedChoice: defaultselection, + selectedLegends: JSON.stringify(selectedLegends), }; this._declarativeChartRef = React.createRef(); + this._lastKnownValidLegends = selectedLegends; + } + + public componentDidMount() { + document.addEventListener('contextmenu', e => { + e.preventDefault(); + }); } public render(): JSX.Element { @@ -65,16 +105,21 @@ export class DeclarativeChartBasicExample extends React.Component<{}, IDeclarati } private _onChange = (ev: React.FormEvent, option: IDropdownOption): void => { - this.setState({ selectedChoice: option.key as string, selectedLegends: '' }); + const selectedPlotlySchema = this._getSchemaByKey(option.key as string); + const { selectedLegends } = selectedPlotlySchema; + this.setState({ selectedChoice: option.key as string, selectedLegends: JSON.stringify(selectedLegends) }); }; - private _onTogglePreselectLegends = (ev: React.MouseEvent, checked: boolean) => { - this.setState({ preSelectLegends: checked }); + private _onSelectedLegendsEdited = ( + event: React.FormEvent, + newValue?: string, + ): void => { + this.setState({ selectedLegends: newValue ?? '' }); }; private _handleChartSchemaChanged = (eventData: Schema) => { const { selectedLegends } = eventData.plotlySchema; - this.setState({ selectedLegends: selectedLegends.join(', ') }); + this.setState({ selectedLegends: JSON.stringify(selectedLegends) }); }; private _getSchemaByKey(key: string): any { @@ -83,17 +128,23 @@ export class DeclarativeChartBasicExample extends React.Component<{}, IDeclarati } private _createDeclarativeChart(): JSX.Element { - const selectedPlotlySchema = this._getSchemaByKey(this.state.selectedChoice); - const uniqueKey = `${this.state.selectedChoice}_${this.state.preSelectLegends}`; - let inputSchema: Schema = { plotlySchema: selectedPlotlySchema }; - - if (this.state.preSelectLegends === false) { - const { data, layout } = selectedPlotlySchema; - inputSchema = { plotlySchema: { data, layout } }; + const uniqueKey = `${this.state.selectedChoice}`; + const currentPlotlySchema = this._getSchemaByKey(this.state.selectedChoice); + const { data, layout } = currentPlotlySchema; + if (this.state.selectedLegends === '') { + this._lastKnownValidLegends = undefined; + } else { + try { + this._lastKnownValidLegends = JSON.parse(this.state.selectedLegends); + } catch (error) { + // Nothing to do here + } } + const plotlySchema = { data, layout, selectedLegends: this._lastKnownValidLegends }; + const inputSchema: Schema = { plotlySchema }; return ( - <> +
    -