From f8d72606ed613d9baae59cf9b853cf7b3c2d36cb Mon Sep 17 00:00:00 2001 From: Rahman Date: Wed, 10 Dec 2025 09:49:03 +0100 Subject: [PATCH 1/4] fix(custom-chart-web): fix static-source data merge --- .../custom-chart-web/CHANGELOG.md | 4 + .../custom-chart-web/package.json | 2 +- .../custom-chart-web/src/package.xml | 2 +- .../custom-chart-web/src/utils/utils.spec.ts | 74 +++++++++++++++++-- .../custom-chart-web/src/utils/utils.ts | 21 ++++-- 5 files changed, 86 insertions(+), 17 deletions(-) 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..bd05532653 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", 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..dbd134a096 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,75 @@ 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] }]); }); }); diff --git a/packages/pluggableWidgets/custom-chart-web/src/utils/utils.ts b/packages/pluggableWidgets/custom-chart-web/src/utils/utils.ts index c5eadc2810..adb5d050c5 100644 --- a/packages/pluggableWidgets/custom-chart-web/src/utils/utils.ts +++ b/packages/pluggableWidgets/custom-chart-web/src/utils/utils.ts @@ -3,20 +3,25 @@ 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[] = []; - 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) : []; + + const maxLen = Math.max(staticTraces.length, dynamicTraces.length); + const result: Data[] = []; - if (dataAttribute.length === 0) { - finalData = [...finalData, ...(sampleData ? JSON.parse(sampleData) : [])]; + for (let i = 0; i < maxLen; i++) { + result.push({ ...staticTraces[i], ...dynamicTraces[i] } 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 { From 3b0ce14655f261561c77f2e4bc4bf05a11d087e3 Mon Sep 17 00:00:00 2001 From: Rahman Date: Thu, 11 Dec 2025 21:35:26 +0100 Subject: [PATCH 2/4] fix(custom-chart-web): use deepmerge for data --- .../custom-chart-web/package.json | 1 + .../custom-chart-web/src/utils/utils.spec.ts | 142 ++++++++++++++++++ .../custom-chart-web/src/utils/utils.ts | 44 +++--- 3 files changed, 165 insertions(+), 22 deletions(-) diff --git a/packages/pluggableWidgets/custom-chart-web/package.json b/packages/pluggableWidgets/custom-chart-web/package.json index bd05532653..9d06257793 100644 --- a/packages/pluggableWidgets/custom-chart-web/package.json +++ b/packages/pluggableWidgets/custom-chart-web/package.json @@ -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/utils/utils.spec.ts b/packages/pluggableWidgets/custom-chart-web/src/utils/utils.spec.ts index dbd134a096..9fc9a7e991 100644 --- a/packages/pluggableWidgets/custom-chart-web/src/utils/utils.spec.ts +++ b/packages/pluggableWidgets/custom-chart-web/src/utils/utils.spec.ts @@ -80,6 +80,81 @@ describe("parseData", () => { 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" } + } + } + ]); + }); + }); }); describe("parseLayout", () => { @@ -102,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 adb5d050c5..9585ac718d 100644 --- a/packages/pluggableWidgets/custom-chart-web/src/utils/utils.ts +++ b/packages/pluggableWidgets/custom-chart-web/src/utils/utils.ts @@ -1,7 +1,13 @@ 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"; +// 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 staticTraces: Data[] = staticData ? JSON.parse(staticData) : []; @@ -14,7 +20,9 @@ export function parseData(staticData?: string, attributeData?: string, sampleDat const result: Data[] = []; for (let i = 0; i < maxLen; i++) { - result.push({ ...staticTraces[i], ...dynamicTraces[i] } as Data); + const staticTrace = (staticTraces[i] ?? {}) as Record; + const dynamicTrace = (dynamicTraces[i] ?? {}) as Record; + result.push(deepmerge(staticTrace, dynamicTrace, mergeOptions) as Data); } return result; @@ -25,19 +33,16 @@ export function parseData(staticData?: string, attributeData?: string, sampleDat } 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 { @@ -56,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; @@ -75,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; }) }; } From 91492af68bed78466eec63343900c7f3b32e3331 Mon Sep 17 00:00:00 2001 From: Rahman Date: Fri, 12 Dec 2025 14:59:47 +0100 Subject: [PATCH 3/4] fix(custom-chart-web): update pnpm-lock --- pnpm-lock.yaml | 3 +++ 1 file changed, 3 insertions(+) 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 From e251a40d5138509637a51de8ebbcad1ca4a746b4 Mon Sep 17 00:00:00 2001 From: Rahman Date: Mon, 29 Dec 2025 19:32:40 +0100 Subject: [PATCH 4/4] fix(custom-chart-web): refactor, updates changelog --- .../custom-chart-web/CHANGELOG.md | 4 ++-- .../custom-chart-web/src/utils/utils.ts | 17 ++++++++--------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/pluggableWidgets/custom-chart-web/CHANGELOG.md b/packages/pluggableWidgets/custom-chart-web/CHANGELOG.md index a84bed5553..b57b370abb 100644 --- a/packages/pluggableWidgets/custom-chart-web/CHANGELOG.md +++ b/packages/pluggableWidgets/custom-chart-web/CHANGELOG.md @@ -6,9 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] -### Fixed +### Breaking changes -- We fixed an issue where static data and source attribute wouldn't merge properly. +- 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 diff --git a/packages/pluggableWidgets/custom-chart-web/src/utils/utils.ts b/packages/pluggableWidgets/custom-chart-web/src/utils/utils.ts index 9585ac718d..97b504607d 100644 --- a/packages/pluggableWidgets/custom-chart-web/src/utils/utils.ts +++ b/packages/pluggableWidgets/custom-chart-web/src/utils/utils.ts @@ -3,10 +3,9 @@ import deepmerge from "deepmerge"; import { Config, Data, Layout } from "plotly.js-dist-min"; import { ChartProps } from "../components/PlotlyChart"; -// Custom merge options: arrays are replaced (not concatenated) to match Plotly expectations -const mergeOptions: deepmerge.Options = { - arrayMerge: (_target, source) => source -}; +// 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 { @@ -22,7 +21,7 @@ export function parseData(staticData?: string, attributeData?: string, sampleDat 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); + result.push(deepmergePlotly(staticTrace, dynamicTrace)); } return result; @@ -38,7 +37,7 @@ export function parseLayout(staticLayout?: string, attributeLayout?: string, sam const attrObj = attributeLayout ? JSON.parse(attributeLayout) : {}; const dynamicObj = Object.keys(attrObj).length > 0 ? attrObj : sampleLayout ? JSON.parse(sampleLayout) : {}; - return deepmerge(staticObj, dynamicObj, mergeOptions); + return deepmergePlotly(staticObj, dynamicObj); } catch (error) { console.error("Error parsing chart layout:", error); return {}; @@ -61,8 +60,8 @@ export function parseConfig(configOptions?: string): Partial { export function mergeChartProps(chartProps: ChartProps, editorState: EditorStoreState): ChartProps { return { ...chartProps, - config: deepmerge(chartProps.config, parseConfig(editorState.config), mergeOptions), - layout: deepmerge(chartProps.layout, parseLayout(editorState.layout), mergeOptions), + config: deepmergePlotly(chartProps.config, parseConfig(editorState.config)), + layout: deepmergePlotly(chartProps.layout, parseLayout(editorState.layout)), data: chartProps.data.map((trace, index) => { let stateTrace: Data | null = null; try { @@ -78,7 +77,7 @@ export function mergeChartProps(chartProps: ChartProps, editorState: EditorStore if (stateTrace == null || typeof stateTrace !== "object") { return trace; } - return deepmerge(trace as object, stateTrace as object, mergeOptions) as Data; + return deepmergePlotly(trace, stateTrace); }) }; }