Skip to content

Commit a623516

Browse files
committed
refactor(chart-playground-web): dedupe prettifyJson, memoize handler, single key source
1 parent 2ce0bd8 commit a623516

7 files changed

Lines changed: 139 additions & 29 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { prettifyJson } from "../prettifyJson";
2+
3+
describe("prettifyJson", () => {
4+
it("formats valid JSON with two-space indentation", () => {
5+
expect(prettifyJson('{"a":1}')).toBe('{\n "a": 1\n}');
6+
});
7+
8+
it("returns an error object string for invalid JSON", () => {
9+
expect(prettifyJson("{ not json")).toBe('{ "error": "invalid JSON" }');
10+
});
11+
});
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// shared-charts' main barrel imports plotly, which calls URL.createObjectURL at load time.
2+
// jsdom/happy-dom doesn't implement it. Import this module BEFORE the barrel to stub it.
3+
if (typeof window.URL.createObjectURL !== "function") {
4+
window.URL.createObjectURL = () => "";
5+
}
6+
7+
export {};
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import "./stubObjectURL";
2+
import { renderHook } from "@testing-library/react";
3+
import { PlaygroundDataV1 } from "@mendix/shared-charts/main";
4+
import { useComposedEditorController } from "../useComposedEditorController";
5+
6+
// The controller only reads `store.state` and calls `store.set`; a duck-typed store keeps the
7+
// fixture free of the plotly-heavy real EditorStore (which is a type-only public export anyway).
8+
function makeData(): PlaygroundDataV1 {
9+
const store = {
10+
state: { layout: '{"a":1}', config: '{"b":2}', data: ['{"name":"t0"}'] },
11+
set: () => undefined
12+
} as unknown as PlaygroundDataV1["store"];
13+
14+
return {
15+
store,
16+
plotData: [{ name: "t0" }],
17+
layoutOptions: {},
18+
configOptions: {}
19+
};
20+
}
21+
22+
describe("useComposedEditorController", () => {
23+
it("keeps onViewSelectChange identity stable across re-renders", () => {
24+
const data = makeData();
25+
const { result, rerender } = renderHook(() => useComposedEditorController(data));
26+
const first = result.current.onViewSelectChange;
27+
28+
rerender();
29+
30+
expect(result.current.onViewSelectChange).toBe(first);
31+
});
32+
});
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import "./stubObjectURL";
2+
import { renderHook, act } from "@testing-library/react";
3+
import { observable } from "mobx";
4+
import { EditableChartStore, EditableChartStoreProps, PlaygroundDataV2 } from "@mendix/shared-charts/main";
5+
import { SetupHost } from "@mendix/widget-plugin-mobx-kit/main";
6+
import { useV2EditorController } from "../useV2EditorController";
7+
8+
class TestHost extends SetupHost {}
9+
10+
function makeContext(initial: EditableChartStoreProps): { context: PlaygroundDataV2; dispose: () => void } {
11+
const props = observable.box<EditableChartStoreProps>(initial, { deep: false });
12+
const host = new TestHost();
13+
const store = new EditableChartStore(host, { get: () => props.get() });
14+
const dispose = host.setup();
15+
return {
16+
context: {
17+
type: "editor.data.v2",
18+
store,
19+
plotData: initial.data,
20+
layoutOptions: {},
21+
configOptions: {}
22+
},
23+
dispose
24+
};
25+
}
26+
27+
describe("useV2EditorController", () => {
28+
it("keeps onViewSelectChange identity stable across re-renders", () => {
29+
const { context, dispose } = makeContext({ layout: { a: 1 }, config: {}, data: [] });
30+
31+
const { result, rerender } = renderHook(() => useV2EditorController(context));
32+
const first = result.current.onViewSelectChange;
33+
34+
rerender();
35+
36+
expect(result.current.onViewSelectChange).toBe(first);
37+
38+
dispose();
39+
});
40+
41+
it("tracks the selected view without desyncing value and editor code", () => {
42+
const { context, dispose } = makeContext({
43+
layout: { a: 1 },
44+
config: { b: 2 },
45+
data: [{ name: "t0", x: [1] }]
46+
});
47+
48+
const { result } = renderHook(() => useV2EditorController(context));
49+
50+
expect(result.current.viewSelectValue).toBe("layout");
51+
expect(JSON.parse(result.current.value)).toEqual({ a: 1 });
52+
53+
act(() => result.current.onViewSelectChange("config"));
54+
expect(result.current.viewSelectValue).toBe("config");
55+
expect(JSON.parse(result.current.value)).toEqual({ b: 2 });
56+
57+
act(() => result.current.onViewSelectChange("0"));
58+
expect(result.current.viewSelectValue).toBe("0");
59+
expect(JSON.parse(result.current.value)).toEqual({ name: "t0", x: [1] });
60+
61+
dispose();
62+
});
63+
});
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export function prettifyJson(json: string): string {
2+
try {
3+
return JSON.stringify(JSON.parse(json), null, 2);
4+
} catch {
5+
return '{ "error": "invalid JSON" }';
6+
}
7+
}

packages/pluggableWidgets/chart-playground-web/src/helpers/useComposedEditorController.ts

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useCallback, useEffect, useMemo, useState } from "react";
22
import { fallback, PlaygroundDataV1 } from "@mendix/shared-charts/main";
3+
import { prettifyJson } from "./prettifyJson";
34
import { ComposedEditorProps } from "../components/ComposedEditor";
45
import { SelectOption } from "../components/Sidebar";
56

@@ -30,25 +31,18 @@ function getModelerCode(data: PlaygroundDataV1, key: ConfigKey): Partial<Data> |
3031
const entries = Object.entries(data.plotData.at(key) ?? {}).filter(([key]) => !irrelevantSeriesKeys.includes(key));
3132
return Object.fromEntries(entries) as Partial<Data>;
3233
}
33-
function prettifyJson(json: string): string {
34-
try {
35-
return JSON.stringify(JSON.parse(json), null, 2);
36-
} catch {
37-
return '{ "error": "invalid JSON" }';
38-
}
39-
}
4034

4135
export function useComposedEditorController(data: PlaygroundDataV1): ComposedEditorProps {
4236
const [key, setKey] = useState<ConfigKey>("layout");
4337

44-
const onViewSelectChange = (value: string): void => {
38+
const onViewSelectChange = useCallback((value: string): void => {
4539
if (value === "layout" || value === "config") {
4640
setKey(value);
4741
} else {
4842
const n = parseInt(value, 10);
4943
setKey(isNaN(n) ? "layout" : n);
5044
}
51-
};
45+
}, []);
5246

5347
const options: SelectOption[] = useMemo(() => {
5448
return [
@@ -78,6 +72,10 @@ export function useComposedEditorController(data: PlaygroundDataV1): ComposedEdi
7872
[store, key]
7973
);
8074

75+
// Syncs the editor input when the selected view's code changes externally (e.g. the store is
76+
// updated elsewhere, or the user switches view). Setting state from this effect is intentional:
77+
// it mirrors external code into local editor state. Accepted risk: an in-progress edit is
78+
// overwritten if `code` changes mid-edit, which only happens on an external/view change.
8179
useEffect(
8280
() =>
8381
// eslint-disable-next-line react-hooks/set-state-in-effect

packages/pluggableWidgets/chart-playground-web/src/helpers/useV2EditorController.ts

Lines changed: 12 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { observable, reaction, runInAction } from "mobx";
1+
import { reaction } from "mobx";
22
import { useCallback, useEffect, useMemo, useState } from "react";
33
import { PlaygroundDataV2 } from "@mendix/shared-charts/main";
4+
import { prettifyJson } from "./prettifyJson";
45
import { ComposedEditorProps } from "../components/ComposedEditor";
56
import { SelectOption } from "../components/Sidebar";
67

@@ -29,29 +30,17 @@ function getModelerCode(data: PlaygroundDataV2, key: ConfigKey): object {
2930
return Object.fromEntries(entries);
3031
}
3132

32-
function prettifyJson(json: string): string {
33-
try {
34-
return JSON.stringify(JSON.parse(json), null, 2);
35-
} catch {
36-
return '{ "error": "invalid JSON" }';
37-
}
38-
}
39-
4033
export function useV2EditorController(context: PlaygroundDataV2): ComposedEditorProps {
4134
const [key, setKey] = useState<ConfigKey>("layout");
42-
const keyBox = useState(() => observable.box<ConfigKey>(key))[0];
4335

44-
const onViewSelectChange = (value: string): void => {
45-
let newKey: ConfigKey;
36+
const onViewSelectChange = useCallback((value: string): void => {
4637
if (value === "layout" || value === "config") {
47-
newKey = value;
38+
setKey(value);
4839
} else {
4940
const n = parseInt(value, 10);
50-
newKey = isNaN(n) ? "layout" : n;
41+
setKey(isNaN(n) ? "layout" : n);
5142
}
52-
setKey(newKey);
53-
runInAction(() => keyBox.set(newKey));
54-
};
43+
}, []);
5544

5645
const store = context.store;
5746

@@ -88,13 +77,16 @@ export function useV2EditorController(context: PlaygroundDataV2): ComposedEditor
8877
[store, key]
8978
);
9079

80+
// Re-subscribes whenever the selected key changes; fireImmediately syncs the editor to the
81+
// current key's code on switch, then keeps tracking external store edits for that key.
9182
useEffect(
9283
() =>
9384
reaction(
94-
() => getEditorCode(store, keyBox.get()),
95-
code => setInput(prettifyJson(code))
85+
() => getEditorCode(store, key),
86+
code => setInput(prettifyJson(code)),
87+
{ fireImmediately: true }
9688
),
97-
[store, keyBox]
89+
[store, key]
9890
);
9991

10092
return {

0 commit comments

Comments
 (0)