diff --git a/packages/pluggableWidgets/custom-chart-web/CHANGELOG.md b/packages/pluggableWidgets/custom-chart-web/CHANGELOG.md index 5bdf485c32..a84bed5553 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] +### Fixed + +- We fixed an issue where static data and source attribute wouldn't merge properly. + ## [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..9585ac718d 100644 --- a/packages/pluggableWidgets/custom-chart-web/src/utils/utils.ts +++ b/packages/pluggableWidgets/custom-chart-web/src/utils/utils.ts @@ -1,38 +1,48 @@ 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[] = []; +// Custom merge options: arrays are replaced (not concatenated) to match Plotly expectations +const mergeOptions: deepmerge.Options = { + arrayMerge: (_target, source) => source +}; +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(deepmerge(staticTrace, dynamicTrace, mergeOptions) as Data); } + + 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 deepmerge(staticObj, dynamicObj, mergeOptions); } catch (error) { console.error("Error parsing chart layout:", error); + return {}; } - return finalLayout; } export function parseConfig(configOptions?: string): Partial { @@ -51,16 +61,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: deepmerge(chartProps.config, parseConfig(editorState.config), mergeOptions), + layout: deepmerge(chartProps.layout, parseLayout(editorState.layout), mergeOptions), 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 +74,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 deepmerge(trace as object, stateTrace as object, mergeOptions) as Data; }) }; } 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