From 15627f8dd694e6231dd321fbfcd7b9cfdec2372c Mon Sep 17 00:00:00 2001 From: Rahman Date: Wed, 10 Dec 2025 09:49:03 +0100 Subject: [PATCH 1/5] 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 ea258f1f3e2201f11acb6ed71ffb7e393588a666 Mon Sep 17 00:00:00 2001 From: Rahman Date: Thu, 11 Dec 2025 21:35:26 +0100 Subject: [PATCH 2/5] 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 56b0386ba9cf0b5ac6b1612b31d132c4353b0731 Mon Sep 17 00:00:00 2001 From: Rahman Date: Fri, 12 Dec 2025 14:59:47 +0100 Subject: [PATCH 3/5] 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 a058423185..1fd9e98359 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 57a24ab8962ed02553a945a74e3a28a0a55f3343 Mon Sep 17 00:00:00 2001 From: Rahman Date: Mon, 29 Dec 2025 19:32:40 +0100 Subject: [PATCH 4/5] 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); }) }; } From 0e31c1bf420cb3dfcc7057e758dfba6574d3e167 Mon Sep 17 00:00:00 2001 From: Rahman Date: Mon, 2 Feb 2026 14:59:11 +0100 Subject: [PATCH 5/5] fix(custom-chart-web): concatenate independtent traces instead of merging --- .../custom-chart-web/src/utils/utils.spec.ts | 78 ++++++++++++++++++- .../custom-chart-web/src/utils/utils.ts | 38 ++++++++- 2 files changed, 111 insertions(+), 5 deletions(-) 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 9fc9a7e991..bbe903971f 100644 --- a/packages/pluggableWidgets/custom-chart-web/src/utils/utils.spec.ts +++ b/packages/pluggableWidgets/custom-chart-web/src/utils/utils.spec.ts @@ -58,7 +58,10 @@ describe("parseData", () => { }); it("returns empty array on invalid JSON", () => { + const spy = jest.spyOn(console, "error").mockImplementation(() => {}); expect(parseData("invalid json")).toEqual([]); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); }); it("merges sampleData with static when attributeData is empty", () => { @@ -81,6 +84,74 @@ describe("parseData", () => { expect(parseData(staticData, attributeData, sampleData)).toEqual([{ type: "line", x: [1], y: [5] }]); }); + it("concatenates independent traces when both static and dynamic have data arrays", () => { + const staticData = JSON.stringify([ + { + type: "scatter", + mode: "lines+markers+text", + name: "Sessions", + x: ["2025-12-01", "2025-12-02", "2025-12-03"], + y: [10, 15, 13] + } + ]); + const attributeData = JSON.stringify([ + { + type: "scatter", + mode: "lines+markers+text", + name: "Sessions (Source B)", + x: ["2025-12-01", "2025-12-02", "2025-12-03"], + y: [8, 12, 11] + } + ]); + const result = parseData(staticData, attributeData); + expect(result).toHaveLength(2); + expect(result[0].name).toBe("Sessions"); + expect(result[1].name).toBe("Sessions (Source B)"); + }); + + it("merges template static trace with data-only dynamic trace (template pattern)", () => { + const staticData = JSON.stringify([ + { type: "bar", name: "Sessions A", marker: { color: "rgb(30, 94, 168)" } }, + { type: "scatter", name: "Sessions B", mode: "lines+markers", marker: { color: "rgb(35, 195, 159)" } } + ]); + const attributeData = JSON.stringify([ + { x: ["2025-12-01", "2025-12-02"], y: [10, 15] }, + { x: ["2025-12-01", "2025-12-02"], y: [8, 12] } + ]); + const result = parseData(staticData, attributeData); + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + type: "bar", + name: "Sessions A", + marker: { color: "rgb(30, 94, 168)" }, + x: ["2025-12-01", "2025-12-02"], + y: [10, 15] + }); + expect(result[1]).toEqual({ + type: "scatter", + name: "Sessions B", + mode: "lines+markers", + marker: { color: "rgb(35, 195, 159)" }, + x: ["2025-12-01", "2025-12-02"], + y: [8, 12] + }); + }); + + it("concatenates when static has more data-carrying traces than dynamic", () => { + const staticData = JSON.stringify([ + { type: "scatter", x: [1, 2], y: [3, 4] }, + { type: "bar", x: [5, 6], y: [7, 8] } + ]); + const attributeData = JSON.stringify([{ type: "scatter", x: [10, 20], y: [30, 40] }]); + const result = parseData(staticData, attributeData); + // Index 0: both have data and share keys (x, y) → concatenate (2 traces) + // Index 1: only static has data → keep as-is (1 trace) + expect(result).toHaveLength(3); + expect(result[0]).toEqual({ type: "scatter", x: [1, 2], y: [3, 4] }); + expect(result[1]).toEqual({ type: "scatter", x: [10, 20], y: [30, 40] }); + expect(result[2]).toEqual({ type: "bar", x: [5, 6], y: [7, 8] }); + }); + describe("deep merge behavior", () => { it("deeply merges nested marker objects", () => { const staticData = JSON.stringify([ @@ -122,10 +193,13 @@ describe("parseData", () => { ]); }); - it("attribute arrays replace static arrays (not concatenate)", () => { + it("keeps traces separate when both have data arrays", () => { 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] }]); + const result = parseData(staticData, attributeData); + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ x: [1, 2, 3], y: [4, 5, 6] }); + expect(result[1]).toEqual({ x: [10, 20] }); }); it("deeply merges font and other nested layout-like properties in traces", () => { diff --git a/packages/pluggableWidgets/custom-chart-web/src/utils/utils.ts b/packages/pluggableWidgets/custom-chart-web/src/utils/utils.ts index 97b504607d..94936c0e4f 100644 --- a/packages/pluggableWidgets/custom-chart-web/src/utils/utils.ts +++ b/packages/pluggableWidgets/custom-chart-web/src/utils/utils.ts @@ -7,6 +7,31 @@ import { ChartProps } from "../components/PlotlyChart"; const deepmergePlotly = (target: T, source: T): T => deepmerge(target, source, { arrayMerge: (_target, src) => src }); +// Keys indicating a trace carries its own plottable data (not just styling/config) +const DATA_KEYS = new Set([ + "x", + "y", + "z", + "values", + "labels", + "lat", + "lon", + "r", + "theta", + "open", + "high", + "low", + "close", + "ids", + "parents" +]); + +function sharesDataKeys(a: Record, b: Record): boolean { + const aKeys = Object.keys(a); + const bKeys = new Set(Object.keys(b)); + return aKeys.some(key => DATA_KEYS.has(key) && bKeys.has(key)); +} + export function parseData(staticData?: string, attributeData?: string, sampleData?: string): Data[] { try { const staticTraces: Data[] = staticData ? JSON.parse(staticData) : []; @@ -19,9 +44,16 @@ export function parseData(staticData?: string, attributeData?: string, sampleDat 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)); + const staticTrace = staticTraces[i] as Record | undefined; + const dynamicTrace = dynamicTraces[i] as Record | undefined; + + if (staticTrace && dynamicTrace && sharesDataKeys(staticTrace, dynamicTrace)) { + // Both traces carry their own data — treat as independent traces + result.push(staticTrace as Data, dynamicTrace as Data); + } else { + // One is a template, the other provides data — merge them + result.push(deepmergePlotly((staticTrace ?? {}) as Data, (dynamicTrace ?? {}) as Data)); + } } return result;