diff --git a/packages/pluggableWidgets/custom-chart-web/CHANGELOG.md b/packages/pluggableWidgets/custom-chart-web/CHANGELOG.md index 5bdf485c32..b57b370abb 100644 --- a/packages/pluggableWidgets/custom-chart-web/CHANGELOG.md +++ b/packages/pluggableWidgets/custom-chart-web/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Breaking changes + +- We changed how "Static" data and "Source attribute" data are merged. Previously, traces were appended as separate chart elements. Now, traces are merged by index, where source attribute values override static values for the same trace position. This enables proper customization of chart traces through dynamic data. + ## [1.2.3] - 2025-10-10 ### Changed diff --git a/packages/pluggableWidgets/custom-chart-web/package.json b/packages/pluggableWidgets/custom-chart-web/package.json index 12c35e3717..9d06257793 100644 --- a/packages/pluggableWidgets/custom-chart-web/package.json +++ b/packages/pluggableWidgets/custom-chart-web/package.json @@ -1,7 +1,7 @@ { "name": "@mendix/custom-chart-web", "widgetName": "CustomChart", - "version": "1.2.3", + "version": "1.2.4", "description": "Create customizable charts with Plotly.js for advanced visualization needs", "copyright": "© Mendix Technology BV 2025. All rights reserved.", "license": "Apache-2.0", @@ -49,6 +49,7 @@ "@mendix/widget-plugin-mobx-kit": "workspace:*", "@mendix/widget-plugin-platform": "workspace:*", "classnames": "^2.5.1", + "deepmerge": "^4.3.1", "plotly.js-dist-min": "^3.0.0" }, "devDependencies": { diff --git a/packages/pluggableWidgets/custom-chart-web/src/package.xml b/packages/pluggableWidgets/custom-chart-web/src/package.xml index 4f62dd1658..3f9d9cd4ae 100644 --- a/packages/pluggableWidgets/custom-chart-web/src/package.xml +++ b/packages/pluggableWidgets/custom-chart-web/src/package.xml @@ -1,6 +1,6 @@ - + diff --git a/packages/pluggableWidgets/custom-chart-web/src/utils/utils.spec.ts b/packages/pluggableWidgets/custom-chart-web/src/utils/utils.spec.ts index 1c96505039..9fc9a7e991 100644 --- a/packages/pluggableWidgets/custom-chart-web/src/utils/utils.spec.ts +++ b/packages/pluggableWidgets/custom-chart-web/src/utils/utils.spec.ts @@ -10,15 +10,150 @@ describe("parseData", () => { expect(parseData(staticData)).toEqual([{ x: [1], y: [2] }]); }); - it("parses sampleData when attributeData and staticData are empty", () => { - const sampleData = JSON.stringify([{ x: [3], y: [4] }]); - expect(parseData(undefined, undefined, sampleData)).toEqual([{ x: [3], y: [4] }]); + it("parses attributeData only", () => { + const attributeData = JSON.stringify([{ x: [5], y: [6] }]); + expect(parseData(undefined, attributeData)).toEqual([{ x: [5], y: [6] }]); }); - it("parses attributeData and ignores sampleData if attributeData is present", () => { - const attributeData = JSON.stringify([{ x: [5], y: [6] }]); - const sampleData = JSON.stringify([{ x: [7], y: [8] }]); - expect(parseData(undefined, attributeData, sampleData)).toEqual([{ x: [5], y: [6] }]); + it("merges static and attribute traces by index with equal lengths", () => { + const staticData = JSON.stringify([ + { type: "bar", x: [1, 2, 3] }, + { type: "line", x: [4, 5, 6] } + ]); + const attributeData = JSON.stringify([{ y: [10, 20, 30] }, { y: [40, 50, 60] }]); + expect(parseData(staticData, attributeData)).toEqual([ + { type: "bar", x: [1, 2, 3], y: [10, 20, 30] }, + { type: "line", x: [4, 5, 6], y: [40, 50, 60] } + ]); + }); + + it("attribute data overrides static properties", () => { + const staticData = JSON.stringify([{ name: "static", x: [1, 2] }]); + const attributeData = JSON.stringify([{ name: "attribute", y: [3, 4] }]); + expect(parseData(staticData, attributeData)).toEqual([{ name: "attribute", x: [1, 2], y: [3, 4] }]); + }); + + it("appends extra static traces when static has more traces", () => { + const staticData = JSON.stringify([ + { type: "bar", x: [1] }, + { type: "line", x: [2] }, + { type: "scatter", x: [3] } + ]); + const attributeData = JSON.stringify([{ y: [10] }]); + expect(parseData(staticData, attributeData)).toEqual([ + { type: "bar", x: [1], y: [10] }, + { type: "line", x: [2] }, + { type: "scatter", x: [3] } + ]); + }); + + it("appends extra attribute traces when attribute has more traces", () => { + const staticData = JSON.stringify([{ type: "bar", x: [1] }]); + const attributeData = JSON.stringify([{ y: [10] }, { y: [20] }, { y: [30] }]); + expect(parseData(staticData, attributeData)).toEqual([ + { type: "bar", x: [1], y: [10] }, + { y: [20] }, + { y: [30] } + ]); + }); + + it("returns empty array on invalid JSON", () => { + expect(parseData("invalid json")).toEqual([]); + }); + + it("merges sampleData with static when attributeData is empty", () => { + const staticData = JSON.stringify([{ type: "bar", x: [1, 2, 3] }]); + const sampleData = JSON.stringify([{ y: [10, 20, 30] }]); + expect(parseData(staticData, undefined, sampleData)).toEqual([{ type: "bar", x: [1, 2, 3], y: [10, 20, 30] }]); + }); + + it("ignores sampleData when attributeData is present", () => { + const staticData = JSON.stringify([{ type: "bar", x: [1] }]); + const attributeData = JSON.stringify([{ y: [10] }]); + const sampleData = JSON.stringify([{ y: [99], name: "sample" }]); + expect(parseData(staticData, attributeData, sampleData)).toEqual([{ type: "bar", x: [1], y: [10] }]); + }); + + it("uses sampleData only when attributeData is empty array string", () => { + const staticData = JSON.stringify([{ type: "line", x: [1] }]); + const attributeData = JSON.stringify([]); + const sampleData = JSON.stringify([{ y: [5] }]); + expect(parseData(staticData, attributeData, sampleData)).toEqual([{ type: "line", x: [1], y: [5] }]); + }); + + describe("deep merge behavior", () => { + it("deeply merges nested marker objects", () => { + const staticData = JSON.stringify([ + { type: "bar", marker: { color: "red", size: 10, line: { width: 2 } } } + ]); + const attributeData = JSON.stringify([{ marker: { symbol: "circle", line: { color: "blue" } } }]); + expect(parseData(staticData, attributeData)).toEqual([ + { + type: "bar", + marker: { + color: "red", + size: 10, + symbol: "circle", + line: { width: 2, color: "blue" } + } + } + ]); + }); + + it("deeply merges multiple traces with nested objects", () => { + const staticData = JSON.stringify([ + { type: "scatter", marker: { color: "red" }, line: { width: 2 } }, + { type: "bar", marker: { size: 10 } } + ]); + const attributeData = JSON.stringify([ + { marker: { symbol: "diamond" }, line: { dash: "dot" } }, + { marker: { color: "blue" } } + ]); + expect(parseData(staticData, attributeData)).toEqual([ + { + type: "scatter", + marker: { color: "red", symbol: "diamond" }, + line: { width: 2, dash: "dot" } + }, + { + type: "bar", + marker: { size: 10, color: "blue" } + } + ]); + }); + + it("attribute arrays replace static arrays (not concatenate)", () => { + const staticData = JSON.stringify([{ x: [1, 2, 3], y: [4, 5, 6] }]); + const attributeData = JSON.stringify([{ x: [10, 20] }]); + expect(parseData(staticData, attributeData)).toEqual([{ x: [10, 20], y: [4, 5, 6] }]); + }); + + it("deeply merges font and other nested layout-like properties in traces", () => { + const staticData = JSON.stringify([ + { + type: "scatter", + textfont: { family: "Arial", size: 12 }, + hoverlabel: { bgcolor: "white", font: { size: 10 } } + } + ]); + const attributeData = JSON.stringify([ + { + textfont: { color: "black" }, + hoverlabel: { bordercolor: "gray", font: { family: "Helvetica" } } + } + ]); + expect(parseData(staticData, attributeData)).toEqual([ + { + type: "scatter", + textfont: { family: "Arial", size: 12, color: "black" }, + hoverlabel: { + bgcolor: "white", + bordercolor: "gray", + font: { size: 10, family: "Helvetica" } + } + } + ]); + }); }); }); @@ -42,6 +177,73 @@ describe("parseLayout", () => { const sampleLayout = JSON.stringify({ title: "Sample" }); expect(parseLayout(undefined, attributeLayout, sampleLayout)).toEqual({ title: "Attr" }); }); + + describe("deep merge behavior", () => { + it("deeply merges nested font objects", () => { + const staticLayout = JSON.stringify({ + title: { text: "Chart Title", font: { family: "Arial", size: 16 } } + }); + const attributeLayout = JSON.stringify({ + title: { font: { color: "blue", weight: "bold" } } + }); + expect(parseLayout(staticLayout, attributeLayout)).toEqual({ + title: { + text: "Chart Title", + font: { family: "Arial", size: 16, color: "blue", weight: "bold" } + } + }); + }); + + it("deeply merges xaxis and yaxis configurations", () => { + const staticLayout = JSON.stringify({ + xaxis: { title: "X Axis", tickfont: { size: 12 }, gridcolor: "lightgray" }, + yaxis: { title: "Y Axis", showgrid: true } + }); + const attributeLayout = JSON.stringify({ + xaxis: { tickfont: { color: "black" }, range: [0, 100] }, + yaxis: { gridcolor: "gray" } + }); + expect(parseLayout(staticLayout, attributeLayout)).toEqual({ + xaxis: { + title: "X Axis", + tickfont: { size: 12, color: "black" }, + gridcolor: "lightgray", + range: [0, 100] + }, + yaxis: { title: "Y Axis", showgrid: true, gridcolor: "gray" } + }); + }); + + it("deeply merges legend configuration", () => { + const staticLayout = JSON.stringify({ + legend: { x: 0.5, y: 1, font: { size: 10 }, bgcolor: "white" } + }); + const attributeLayout = JSON.stringify({ + legend: { orientation: "h", font: { family: "Helvetica" } } + }); + expect(parseLayout(staticLayout, attributeLayout)).toEqual({ + legend: { + x: 0.5, + y: 1, + font: { size: 10, family: "Helvetica" }, + bgcolor: "white", + orientation: "h" + } + }); + }); + + it("attribute arrays replace static arrays in layout", () => { + const staticLayout = JSON.stringify({ + annotations: [{ text: "Note 1" }, { text: "Note 2" }] + }); + const attributeLayout = JSON.stringify({ + annotations: [{ text: "New Note" }] + }); + expect(parseLayout(staticLayout, attributeLayout)).toEqual({ + annotations: [{ text: "New Note" }] + }); + }); + }); }); describe("parseConfig", () => { diff --git a/packages/pluggableWidgets/custom-chart-web/src/utils/utils.ts b/packages/pluggableWidgets/custom-chart-web/src/utils/utils.ts index c5eadc2810..97b504607d 100644 --- a/packages/pluggableWidgets/custom-chart-web/src/utils/utils.ts +++ b/packages/pluggableWidgets/custom-chart-web/src/utils/utils.ts @@ -1,38 +1,47 @@ import { EditorStoreState } from "@mendix/shared-charts/main"; +import deepmerge from "deepmerge"; import { Config, Data, Layout } from "plotly.js-dist-min"; import { ChartProps } from "../components/PlotlyChart"; -export function parseData(staticData?: string, attributeData?: string, sampleData?: string): Data[] { - let finalData: Data[] = []; +// Plotly-specific deep merge: arrays are replaced (not concatenated) to match Plotly expectations +const deepmergePlotly = (target: T, source: T): T => + deepmerge(target, source, { arrayMerge: (_target, src) => src }); +export function parseData(staticData?: string, attributeData?: string, sampleData?: string): Data[] { try { - const dataAttribute = attributeData ? JSON.parse(attributeData) : []; - finalData = [...finalData, ...(staticData ? JSON.parse(staticData) : []), ...dataAttribute]; + const staticTraces: Data[] = staticData ? JSON.parse(staticData) : []; + const attrTraces: Data[] = attributeData ? JSON.parse(attributeData) : []; + + // Use sampleData as fallback when attributeData is empty + const dynamicTraces: Data[] = attrTraces.length > 0 ? attrTraces : sampleData ? JSON.parse(sampleData) : []; - if (dataAttribute.length === 0) { - finalData = [...finalData, ...(sampleData ? JSON.parse(sampleData) : [])]; + const maxLen = Math.max(staticTraces.length, dynamicTraces.length); + const result: Data[] = []; + + for (let i = 0; i < maxLen; i++) { + const staticTrace = (staticTraces[i] ?? {}) as Record; + const dynamicTrace = (dynamicTraces[i] ?? {}) as Record; + result.push(deepmergePlotly(staticTrace, dynamicTrace)); } + + return result; } catch (error) { console.error("Error parsing chart data:", error); + return []; } - - return finalData; } export function parseLayout(staticLayout?: string, attributeLayout?: string, sampleLayout?: string): Partial { - let finalLayout: Partial = {}; - try { - const layoutAttribute = attributeLayout ? JSON.parse(attributeLayout) : {}; - finalLayout = { ...finalLayout, ...(staticLayout ? JSON.parse(staticLayout) : {}), ...layoutAttribute }; + const staticObj = staticLayout ? JSON.parse(staticLayout) : {}; + const attrObj = attributeLayout ? JSON.parse(attributeLayout) : {}; + const dynamicObj = Object.keys(attrObj).length > 0 ? attrObj : sampleLayout ? JSON.parse(sampleLayout) : {}; - if (Object.keys(layoutAttribute).length === 0) { - finalLayout = { ...finalLayout, ...(sampleLayout ? JSON.parse(sampleLayout) : {}) }; - } + return deepmergePlotly(staticObj, dynamicObj); } catch (error) { console.error("Error parsing chart layout:", error); + return {}; } - return finalLayout; } export function parseConfig(configOptions?: string): Partial { @@ -51,16 +60,10 @@ export function parseConfig(configOptions?: string): Partial { export function mergeChartProps(chartProps: ChartProps, editorState: EditorStoreState): ChartProps { return { ...chartProps, - config: { - ...chartProps.config, - ...parseConfig(editorState.config) - }, - layout: { - ...chartProps.layout, - ...parseLayout(editorState.layout) - }, + config: deepmergePlotly(chartProps.config, parseConfig(editorState.config)), + layout: deepmergePlotly(chartProps.layout, parseLayout(editorState.layout)), data: chartProps.data.map((trace, index) => { - let stateTrace: Data = {}; + let stateTrace: Data | null = null; try { if (!editorState.data || !editorState.data[index]) { return trace; @@ -70,10 +73,11 @@ export function mergeChartProps(chartProps: ChartProps, editorState: EditorStore console.warn(`Editor props for trace(${index}) is not a valid JSON:${editorState.data[index]}`); console.warn("Please make sure the props is a valid JSON string."); } - return { - ...trace, - ...stateTrace - } as Data; + // deepmerge can't handle null, so return trace unchanged if stateTrace is null/undefined + if (stateTrace == null || typeof stateTrace !== "object") { + return trace; + } + return deepmergePlotly(trace, stateTrace); }) }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 684f01279c..9605dfa421 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1072,6 +1072,9 @@ importers: classnames: specifier: ^2.5.1 version: 2.5.1 + deepmerge: + specifier: ^4.3.1 + version: 4.3.1 plotly.js-dist-min: specifier: ^3.0.0 version: 3.1.1