Skip to content

Commit dd5ea5e

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

6 files changed

Lines changed: 104 additions & 33 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 "../editorJson";
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 {};

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

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
1-
jest.mock("plotly.js-dist-min", () => ({}));
2-
jest.mock("react-plotly.js", () => jest.fn(() => null));
3-
jest.mock("react-plotly.js/factory", () => jest.fn(() => jest.fn(() => null)));
4-
1+
import "./stubObjectURL";
52
import { act, renderHook } from "@testing-library/react";
63
import { EditorStore, PlaygroundDataV1 } from "@mendix/shared-charts/main";
74
import { useComposedEditorController } from "../useComposedEditorController";
@@ -33,4 +30,14 @@ describe("useComposedEditorController", () => {
3330
// not get replaced by the store's reformatted echo of that same edit.
3431
expect(result.current.value).toBe('{"a":1,"b":2}');
3532
});
33+
34+
it("keeps onViewSelectChange identity stable across re-renders", () => {
35+
const data = setupData();
36+
const { result, rerender } = renderHook(() => useComposedEditorController(data));
37+
const first = result.current.onViewSelectChange;
38+
39+
rerender();
40+
41+
expect(result.current.onViewSelectChange).toBe(first);
42+
});
3643
});
Lines changed: 59 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,33 @@
1-
jest.mock("plotly.js-dist-min", () => ({}));
2-
jest.mock("react-plotly.js", () => jest.fn(() => null));
3-
jest.mock("react-plotly.js/factory", () => jest.fn(() => jest.fn(() => null)));
4-
1+
import "./stubObjectURL";
52
import { act, renderHook } from "@testing-library/react";
6-
import { EditableChartStore, PlaygroundDataV2 } from "@mendix/shared-charts/main";
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";
76
import { useV2EditorController } from "../useV2EditorController";
87

9-
function setupData(): PlaygroundDataV2 {
10-
const host = { add: () => {} };
11-
const props = { get: () => ({ layout: {}, config: {}, data: [] }) };
12-
const store = new EditableChartStore(host as any, props as any);
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();
1315
return {
14-
type: "editor.data.v2",
15-
store,
16-
plotData: [],
17-
configOptions: {},
18-
layoutOptions: {}
16+
context: {
17+
type: "editor.data.v2",
18+
store,
19+
plotData: initial.data,
20+
layoutOptions: {},
21+
configOptions: {}
22+
},
23+
dispose
1924
};
2025
}
2126

2227
describe("useV2EditorController", () => {
2328
it("does not clobber the user's in-progress edit with the store's reformatted echo", () => {
24-
const data = setupData();
25-
const { result } = renderHook(() => useV2EditorController(data));
29+
const { context, dispose } = makeContext({ layout: { a: 1 }, config: {}, data: [] });
30+
const { result } = renderHook(() => useV2EditorController(context));
2631

2732
act(() => {
2833
// Compact, single-line input — the store round-trips this through
@@ -34,5 +39,43 @@ describe("useV2EditorController", () => {
3439
// The value shown to the user must remain exactly what they typed,
3540
// not get replaced by the store's reformatted echo of that same edit.
3641
expect(result.current.value).toBe('{"a":1,"b":2}');
42+
43+
dispose();
44+
});
45+
46+
it("keeps onViewSelectChange identity stable across re-renders", () => {
47+
const { context, dispose } = makeContext({ layout: { a: 1 }, config: {}, data: [] });
48+
49+
const { result, rerender } = renderHook(() => useV2EditorController(context));
50+
const first = result.current.onViewSelectChange;
51+
52+
rerender();
53+
54+
expect(result.current.onViewSelectChange).toBe(first);
55+
56+
dispose();
57+
});
58+
59+
it("tracks the selected view without desyncing value and editor code", () => {
60+
const { context, dispose } = makeContext({
61+
layout: { a: 1 },
62+
config: { b: 2 },
63+
data: [{ name: "t0", x: [1] }]
64+
});
65+
66+
const { result } = renderHook(() => useV2EditorController(context));
67+
68+
expect(result.current.viewSelectValue).toBe("layout");
69+
expect(JSON.parse(result.current.value)).toEqual({ a: 1 });
70+
71+
act(() => result.current.onViewSelectChange("config"));
72+
expect(result.current.viewSelectValue).toBe("config");
73+
expect(JSON.parse(result.current.value)).toEqual({ b: 2 });
74+
75+
act(() => result.current.onViewSelectChange("0"));
76+
expect(result.current.viewSelectValue).toBe("0");
77+
expect(JSON.parse(result.current.value)).toEqual({ name: "t0", x: [1] });
78+
79+
dispose();
3780
});
3881
});

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,14 @@ function getModelerCode(data: PlaygroundDataV1, key: ConfigKey): Partial<Data> |
3535
export function useComposedEditorController(data: PlaygroundDataV1): ComposedEditorProps {
3636
const [key, setKey] = useState<ConfigKey>("layout");
3737

38-
const onViewSelectChange = (value: string): void => {
38+
const onViewSelectChange = useCallback((value: string): void => {
3939
if (value === "layout" || value === "config") {
4040
setKey(value);
4141
} else {
4242
const n = parseInt(value, 10);
4343
setKey(isNaN(n) ? "layout" : n);
4444
}
45-
};
45+
}, []);
4646

4747
const options: SelectOption[] = useMemo(() => {
4848
return [

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

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -34,17 +34,20 @@ export function useV2EditorController(context: PlaygroundDataV2): ComposedEditor
3434
const [key, setKey] = useState<ConfigKey>("layout");
3535
const keyBox = useState(() => observable.box<ConfigKey>(key))[0];
3636

37-
const onViewSelectChange = (value: string): void => {
38-
let newKey: ConfigKey;
39-
if (value === "layout" || value === "config") {
40-
newKey = value;
41-
} else {
42-
const n = parseInt(value, 10);
43-
newKey = isNaN(n) ? "layout" : n;
44-
}
45-
setKey(newKey);
46-
runInAction(() => keyBox.set(newKey));
47-
};
37+
const onViewSelectChange = useCallback(
38+
(value: string): void => {
39+
let newKey: ConfigKey;
40+
if (value === "layout" || value === "config") {
41+
newKey = value;
42+
} else {
43+
const n = parseInt(value, 10);
44+
newKey = isNaN(n) ? "layout" : n;
45+
}
46+
setKey(newKey);
47+
runInAction(() => keyBox.set(newKey));
48+
},
49+
[keyBox]
50+
);
4851

4952
const store = context.store;
5053

0 commit comments

Comments
 (0)