Skip to content

Commit 1843b44

Browse files
committed
feat: add chart store
1 parent b257ffe commit 1843b44

6 files changed

Lines changed: 364 additions & 5 deletions

File tree

packages/shared/charts/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,10 @@
3434
"test": "jest"
3535
},
3636
"dependencies": {
37+
"@types/jest": "^30.0.0",
3738
"classnames": "^2.5.1",
3839
"deepmerge": "^4.3.1",
40+
"mobx": "6.12.3",
3941
"plotly.js-dist-min": "^3.0.0",
4042
"react-plotly.js": "^2.6.0"
4143
},
@@ -45,6 +47,7 @@
4547
"@mendix/tsconfig-web-widgets": "workspace:^",
4648
"@mendix/widget-plugin-component-kit": "workspace:*",
4749
"@mendix/widget-plugin-hooks": "workspace:*",
50+
"@mendix/widget-plugin-mobx-kit": "workspace:*",
4851
"@mendix/widget-plugin-platform": "workspace:*",
4952
"@mendix/widget-plugin-test-utils": "workspace:*",
5053
"@rollup/plugin-commonjs": "^28.0.3",
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { autorun, makeAutoObservable, observable } from "mobx";
2+
import {
3+
SetupComponentHost,
4+
SetupComponent,
5+
DerivedPropsGate,
6+
disposeBatch
7+
} from "@mendix/widget-plugin-mobx-kit/main";
8+
9+
export type JSONString = string;
10+
11+
interface ChartDataStoreProps {
12+
layout: Record<string, unknown>;
13+
config: Record<string, unknown>;
14+
data: Array<Record<string, unknown>>;
15+
}
16+
17+
/**
18+
* ChartDataStore holds the current layout, configuration, and data for a chart.
19+
* These fields are made observable by MobX so any component relying on them
20+
* will automatically react to changes.
21+
*/
22+
export class ChartDataStore implements SetupComponent {
23+
/**
24+
* Current layout configuration.
25+
*/
26+
layout: Record<string, unknown> = {};
27+
28+
/**
29+
* Current configuration object.
30+
*/
31+
config: Record<string, unknown> = {};
32+
33+
/**
34+
* Array of data items.
35+
*/
36+
data: Array<Record<string, unknown>> = [];
37+
38+
constructor(
39+
host: SetupComponentHost,
40+
private gate: DerivedPropsGate<ChartDataStoreProps>
41+
) {
42+
host.add(this);
43+
makeAutoObservable(this, {
44+
layout: observable.ref,
45+
config: observable.ref,
46+
data: observable.ref
47+
});
48+
}
49+
50+
setup(): (() => void) | void {
51+
const [add, dispose] = disposeBatch();
52+
53+
autorun(() => {
54+
const props = this.props;
55+
this.setLayout(props.layout);
56+
this.setConfig(props.config);
57+
this.setData(props.data);
58+
});
59+
}
60+
61+
get props(): ChartDataStoreProps {
62+
return this.gate.props;
63+
}
64+
65+
/**
66+
* JSON string representation of the current layout.
67+
* @returns Stringified layout object.
68+
*/
69+
get layoutJson(): JSONString {
70+
return JSON.stringify(this.layout);
71+
}
72+
73+
/**
74+
* Replace the layout with a shallow copy of the provided object.
75+
* Null/undefined values are ignored to prevent accidental clearing.
76+
* @param layout - New layout object (will be shallow-copied)
77+
*/
78+
setLayout(layout: Record<string, unknown>): void {
79+
if (layout != null) {
80+
this.layout = { ...layout };
81+
}
82+
}
83+
84+
/**
85+
* JSON string representation of the current configuration.
86+
* @returns Stringified configuration object.
87+
*/
88+
get configJson(): JSONString {
89+
return JSON.stringify(this.config);
90+
}
91+
92+
/**
93+
* Replace the configuration with a shallow copy of the provided object.
94+
* @param config - New config object (will be shallow-copy)
95+
*/
96+
setConfig(config: Record<string, unknown>): void {
97+
if (config != null) {
98+
this.config = { ...config };
99+
}
100+
}
101+
102+
/**
103+
* JSON string representation of the current data array.
104+
* @returns Stringified data array.
105+
*/
106+
get dataJson(): JSONString {
107+
return JSON.stringify(this.data);
108+
}
109+
110+
/**
111+
* Replace the entire data array with a validated copy.
112+
* Throws if the input is not an array.
113+
* @param data - New data array to set
114+
*/
115+
setData(data: Array<Record<string, unknown>>): void {
116+
if (!Array.isArray(data)) {
117+
throw new Error(`setData expects an array, got ${typeof data}`);
118+
}
119+
this.data = [...data];
120+
}
121+
122+
/**
123+
* Parse a JSON string and replace the data item at the given index.
124+
* Performs error handling for invalid JSON or malformed objects.
125+
* @param index - Position in the data array to replace
126+
* @param jsonString - JSON string representing the new item
127+
*/
128+
setDataAt(index: number, jsonString: string): void {
129+
if (index < 0 || index >= this.data.length) {
130+
return;
131+
}
132+
133+
try {
134+
const parsed = JSON.parse(jsonString);
135+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
136+
const nextData = [...this.data];
137+
nextData[index] = parsed as Record<string, unknown>;
138+
this.data = nextData;
139+
}
140+
} catch (error) {
141+
console.warn(`Invalid JSON in setDataAt at index ${index}:`, jsonString, error);
142+
}
143+
}
144+
145+
/**
146+
* Reset the entire store with new layout, configuration, and data objects.
147+
* Each argument is shallow-copied into the corresponding field.
148+
* @param layout - New layout object
149+
* @param config - New config object
150+
* @param data - New data array
151+
*/
152+
reset(
153+
layout: Record<string, unknown>,
154+
config: Record<string, unknown>,
155+
data: Array<Record<string, unknown>>
156+
): void {
157+
this.setLayout(layout);
158+
this.setConfig(config);
159+
this.setData(data);
160+
}
161+
}
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { autorun } from "mobx";
2+
import { ChartDataStore } from "../ChartDataStore";
3+
4+
describe("initial state and JSON outputs", () => {
5+
it("should contain initial state and JSON outputs", () => {
6+
const store = new ChartDataStore();
7+
expect(store.layout).toEqual({});
8+
expect(store.config).toEqual({});
9+
expect(store.data).toEqual([]);
10+
expect(store.layoutJson).toBe("{}");
11+
expect(store.configJson).toBe("{}");
12+
expect(store.dataJson).toBe("[]");
13+
});
14+
});
15+
16+
describe("update layout/config/data and keep JSON in sync", () => {
17+
it("should update layout/config/data and keep JSON in sync", () => {
18+
const store = new ChartDataStore();
19+
store.setLayout({ title: "Test" });
20+
store.setConfig({ type: "bar" });
21+
store.setData([
22+
{ label: "A", value: 10 },
23+
{ label: "B", value: 20 }
24+
]);
25+
26+
expect(store.layoutJson).toBe('{"title":"Test"}');
27+
expect(store.configJson).toBe('{"type":"bar"}');
28+
expect(store.dataJson).toBe('[{"label":"A","value":10},{"label":"B","value":20}]');
29+
});
30+
31+
it("should parse JSON string and set data at index", () => {
32+
const store = new ChartDataStore();
33+
store.setData([{ id: 1, value: 10 }]);
34+
store.setDataAt(0, '{"id":2,"value":40}');
35+
expect(store.dataJson).toBe('[{"id":2,"value":40}]');
36+
});
37+
38+
it("should handle setDataAt with invalid JSON gracefully", () => {
39+
const store = new ChartDataStore();
40+
store.setData([{ id: 1, value: 10 }]);
41+
store.setDataAt(1, "invalid json");
42+
expect(store.dataJson).toBe('[{"id":1,"value":10}]');
43+
});
44+
45+
it("should ignore setDataAt if parsed result is an array", () => {
46+
const store = new ChartDataStore();
47+
store.setData([{ id: 1, value: 10 }]);
48+
store.setDataAt(1, '["not", "an", "object"]');
49+
expect(store.dataJson).toBe('[{"id":1,"value":10}]');
50+
});
51+
52+
it("should throw error for non-array data in setData", () => {
53+
const store = new ChartDataStore();
54+
expect(() => store.setData({ invalid: "data" } as any)).toThrow();
55+
});
56+
});
57+
58+
describe("JSON getters", () => {
59+
it("should return correct JSON strings for layout", () => {
60+
const store = new ChartDataStore();
61+
store.setLayout({ title: "Demo" });
62+
expect(store.layoutJson).toBe('{"title":"Demo"}');
63+
});
64+
65+
it("should return correct JSON strings for config", () => {
66+
const store = new ChartDataStore();
67+
store.setConfig({ type: "pie" });
68+
expect(store.configJson).toBe('{"type":"pie"}');
69+
});
70+
71+
it("should return correct JSON strings for data", () => {
72+
const store = new ChartDataStore();
73+
store.setData([
74+
{ label: "A", value: 10 },
75+
{ label: "B", value: 20 }
76+
]);
77+
expect(store.dataJson).toBe('[{"label":"A","value":10},{"label":"B","value":20}]');
78+
});
79+
});
80+
81+
describe("null/undefined handling", () => {
82+
it("should not set layout for null or undefined values", () => {
83+
const store = new ChartDataStore();
84+
store.setLayout({ initial: "true" });
85+
store.setLayout(null as any);
86+
expect(store.layoutJson).toBe('{"initial":"true"}');
87+
});
88+
89+
it("should not set config for null or undefined values", () => {
90+
const store = new ChartDataStore();
91+
store.setConfig({ mode: "dark" });
92+
store.setConfig(undefined as any);
93+
expect(store.configJson).toBe('{"mode":"dark"}');
94+
});
95+
});
96+
97+
describe("reset method", () => {
98+
it("should reset all fields using reset method", () => {
99+
const store = new ChartDataStore();
100+
store.setLayout({ old: "value" });
101+
store.setConfig({ old: "value" });
102+
store.setData([{ old: "value" }]);
103+
104+
store.reset({ new: "value" }, { new: "value" }, [{ new: "value" }]);
105+
106+
expect(store.layoutJson).toBe('{"new":"value"}');
107+
expect(store.configJson).toBe('{"new":"value"}');
108+
expect(store.dataJson).toBe('[{"new":"value"}]');
109+
});
110+
});
111+
112+
describe("reactive updates with MobX", () => {
113+
it("reactively updates layoutJson when layout changes", () => {
114+
const store = new ChartDataStore();
115+
const loggedValues: string[] = [];
116+
autorun(() => loggedValues.push(store.layoutJson));
117+
expect(loggedValues).toEqual(["{}"]);
118+
store.setLayout({ title: "First" });
119+
expect(loggedValues).toEqual(["{}", '{"title":"First"}']);
120+
store.setLayout({ title: "Updated" });
121+
expect(loggedValues).toEqual(["{}", '{"title":"First"}', '{"title":"Updated"}']);
122+
});
123+
124+
it("reactively updates dataJson when data changes", () => {
125+
const store = new ChartDataStore();
126+
const loggedValues: string[] = [];
127+
autorun(() => loggedValues.push(store.dataJson));
128+
expect(loggedValues).toEqual(["[]"]);
129+
store.setData([{ id: 1, value: 5 }]);
130+
expect(loggedValues.length).toBeGreaterThan(1);
131+
store.setData([
132+
{ id: 1, value: 5 },
133+
{ id: 2, value: 10 }
134+
]);
135+
expect(loggedValues.length).toBeGreaterThan(2);
136+
});
137+
138+
it("reactively updates configJson when config changes", () => {
139+
const store = new ChartDataStore();
140+
const loggedValues: string[] = [];
141+
autorun(() => loggedValues.push(store.configJson));
142+
expect(loggedValues).toEqual(["{}"]);
143+
store.setConfig({ mode: "dark" });
144+
expect(loggedValues).toEqual(["{}", '{"mode":"dark"}']);
145+
store.setConfig({ mode: "light" });
146+
expect(loggedValues).toEqual(["{}", '{"mode":"dark"}', '{"mode":"light"}']);
147+
});
148+
149+
it("reactively updates store after reset", () => {
150+
const store = new ChartDataStore();
151+
const logged: Array<Record<string, string>> = [];
152+
autorun(() => {
153+
logged.push({
154+
layout: store.layoutJson,
155+
config: store.configJson,
156+
data: store.dataJson
157+
});
158+
});
159+
expect(logged).toEqual([
160+
{
161+
layout: "{}",
162+
config: "{}",
163+
data: "[]"
164+
}
165+
]);
166+
store.setLayout({ initial: "true" });
167+
store.setConfig({ setting: "on" });
168+
store.setData([{ item: "one" }]);
169+
expect(logged.length).toBeGreaterThan(1);
170+
store.reset({ reset: "layout" }, { reset: "config" }, [{ reset: "data" }]);
171+
expect(logged.length).toBeGreaterThan(2);
172+
});
173+
});

packages/shared/charts/tsconfig.build.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
"outDir": "./dist",
77
"rootDir": "./src",
88
"jsx": "react-jsx",
9-
"jsxFactory": ""
9+
"jsxFactory": "",
10+
"types": ["jest"]
1011
},
1112
"references": [{ "path": "./rollup/tsconfig.json" }]
1213
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"root":["./src/main.ts","./src/preview.ts","./src/components/chart.tsx","./src/components/chartpreview.tsx","./src/components/chartview.tsx","./src/components/chartwidget.tsx","./src/components/types.ts","./src/helpers/editorstore.ts","./src/helpers/playground-context.ts","./src/helpers/usechartcontroller.ts","./src/helpers/useeditorstore.ts","./src/hooks/useplotchartdataseries.ts","./src/typings/declare-svg.d.ts","./src/typings/global.d.ts","./src/typings/json-source-map.d.ts","./src/utils/aggregations.ts","./src/utils/chartstyles.ts","./src/utils/compareattrvaluesasc.ts","./src/utils/configs.ts","./src/utils/equality.ts","./src/utils/json.ts","./src/utils/preview-utils.ts","./src/utils/setupbasicseries.ts","./src/utils/themefolderconfig.ts"],"version":"5.9.3"}
1+
{"root":["./src/main.ts","./src/preview.ts","./src/components/Chart.tsx","./src/components/ChartPreview.tsx","./src/components/ChartView.tsx","./src/components/ChartWidget.tsx","./src/components/types.ts","./src/helpers/ChartDataStore.ts","./src/helpers/EditorStore.ts","./src/helpers/playground-context.ts","./src/helpers/useChartController.ts","./src/helpers/useEditorStore.ts","./src/hooks/usePlotChartDataSeries.ts","./src/typings/declare-svg.d.ts","./src/typings/global.d.ts","./src/typings/json-source-map.d.ts","./src/utils/aggregations.ts","./src/utils/chartStyles.ts","./src/utils/compareAttrValuesAsc.ts","./src/utils/configs.ts","./src/utils/equality.ts","./src/utils/json.ts","./src/utils/preview-utils.ts","./src/utils/setupBasicSeries.ts","./src/utils/themeFolderConfig.ts"],"version":"5.9.3"}

0 commit comments

Comments
 (0)