Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/pluggableWidgets/custom-chart-web/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion packages/pluggableWidgets/custom-chart-web/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8" ?>
<package xmlns="http://www.mendix.com/package/1.0/">
<clientModule name="CustomChart" version="1.2.3" xmlns="http://www.mendix.com/clientModule/1.0/">
<clientModule name="CustomChart" version="1.2.4" xmlns="http://www.mendix.com/clientModule/1.0/">
<widgetFiles>
<widgetFile path="CustomChart.xml" />
</widgetFiles>
Expand Down
216 changes: 209 additions & 7 deletions packages/pluggableWidgets/custom-chart-web/src/utils/utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
}
}
]);
});
});
});

Expand All @@ -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", () => {
Expand Down
63 changes: 34 additions & 29 deletions packages/pluggableWidgets/custom-chart-web/src/utils/utils.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
const dynamicTrace = (dynamicTraces[i] ?? {}) as Record<string, unknown>;
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<Layout> {
let finalLayout: Partial<Layout> = {};

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<Config> {
Expand All @@ -51,16 +61,10 @@ export function parseConfig(configOptions?: string): Partial<Config> {
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;
Expand All @@ -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;
})
};
}
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading