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
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { prettifyJson } from "../prettifyJson";

describe("prettifyJson", () => {
it("formats valid JSON with two-space indentation", () => {
expect(prettifyJson('{"a":1}')).toBe('{\n "a": 1\n}');
});

it("returns an error object string for invalid JSON", () => {
expect(prettifyJson("{ not json")).toBe('{ "error": "invalid JSON" }');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// shared-charts' main barrel imports plotly, which calls URL.createObjectURL at load time.
// jsdom/happy-dom doesn't implement it. Import this module BEFORE the barrel to stub it.
if (typeof window.URL.createObjectURL !== "function") {
window.URL.createObjectURL = () => "";
}

export {};
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import "./stubObjectURL";
import { renderHook } from "@testing-library/react";
import { PlaygroundDataV1 } from "@mendix/shared-charts/main";
import { useComposedEditorController } from "../useComposedEditorController";

// The controller only reads `store.state` and calls `store.set`; a duck-typed store keeps the
// fixture free of the plotly-heavy real EditorStore (which is a type-only public export anyway).
function makeData(): PlaygroundDataV1 {
const store = {
state: { layout: '{"a":1}', config: '{"b":2}', data: ['{"name":"t0"}'] },
set: () => undefined
} as unknown as PlaygroundDataV1["store"];

return {
store,
plotData: [{ name: "t0" }],
layoutOptions: {},
configOptions: {}
};
}

describe("useComposedEditorController", () => {
it("keeps onViewSelectChange identity stable across re-renders", () => {
const data = makeData();
const { result, rerender } = renderHook(() => useComposedEditorController(data));
const first = result.current.onViewSelectChange;

rerender();

expect(result.current.onViewSelectChange).toBe(first);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import "./stubObjectURL";
import { renderHook, act } from "@testing-library/react";
import { observable } from "mobx";
import { EditableChartStore, EditableChartStoreProps, PlaygroundDataV2 } from "@mendix/shared-charts/main";
import { SetupHost } from "@mendix/widget-plugin-mobx-kit/main";
import { useV2EditorController } from "../useV2EditorController";

class TestHost extends SetupHost {}

function makeContext(initial: EditableChartStoreProps): { context: PlaygroundDataV2; dispose: () => void } {
const props = observable.box<EditableChartStoreProps>(initial, { deep: false });
const host = new TestHost();
const store = new EditableChartStore(host, { get: () => props.get() });
const dispose = host.setup();
return {
context: {
type: "editor.data.v2",
store,
plotData: initial.data,
layoutOptions: {},
configOptions: {}
},
dispose
};
}

describe("useV2EditorController", () => {
it("keeps onViewSelectChange identity stable across re-renders", () => {
const { context, dispose } = makeContext({ layout: { a: 1 }, config: {}, data: [] });

const { result, rerender } = renderHook(() => useV2EditorController(context));
const first = result.current.onViewSelectChange;

rerender();

expect(result.current.onViewSelectChange).toBe(first);

dispose();
});

it("tracks the selected view without desyncing value and editor code", () => {
const { context, dispose } = makeContext({
layout: { a: 1 },
config: { b: 2 },
data: [{ name: "t0", x: [1] }]
});

const { result } = renderHook(() => useV2EditorController(context));

expect(result.current.viewSelectValue).toBe("layout");
expect(JSON.parse(result.current.value)).toEqual({ a: 1 });

act(() => result.current.onViewSelectChange("config"));
expect(result.current.viewSelectValue).toBe("config");
expect(JSON.parse(result.current.value)).toEqual({ b: 2 });

act(() => result.current.onViewSelectChange("0"));
expect(result.current.viewSelectValue).toBe("0");
expect(JSON.parse(result.current.value)).toEqual({ name: "t0", x: [1] });

dispose();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export function prettifyJson(json: string): string {
try {
return JSON.stringify(JSON.parse(json), null, 2);
} catch {
return '{ "error": "invalid JSON" }';
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { fallback, PlaygroundDataV1 } from "@mendix/shared-charts/main";
import { prettifyJson } from "./prettifyJson";
import { ComposedEditorProps } from "../components/ComposedEditor";
import { SelectOption } from "../components/Sidebar";

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

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

const onViewSelectChange = (value: string): void => {
const onViewSelectChange = useCallback((value: string): void => {
if (value === "layout" || value === "config") {
setKey(value);
} else {
const n = parseInt(value, 10);
setKey(isNaN(n) ? "layout" : n);
}
};
}, []);

const options: SelectOption[] = useMemo(() => {
return [
Expand Down Expand Up @@ -78,6 +72,10 @@ export function useComposedEditorController(data: PlaygroundDataV1): ComposedEdi
[store, key]
);

// Syncs the editor input when the selected view's code changes externally (e.g. the store is
// updated elsewhere, or the user switches view). Setting state from this effect is intentional:
// it mirrors external code into local editor state. Accepted risk: an in-progress edit is
// overwritten if `code` changes mid-edit, which only happens on an external/view change.
useEffect(
() =>
// eslint-disable-next-line react-hooks/set-state-in-effect
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { observable, reaction, runInAction } from "mobx";
import { reaction } from "mobx";
import { useCallback, useEffect, useMemo, useState } from "react";
import { PlaygroundDataV2 } from "@mendix/shared-charts/main";
import { prettifyJson } from "./prettifyJson";
import { ComposedEditorProps } from "../components/ComposedEditor";
import { SelectOption } from "../components/Sidebar";

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

function prettifyJson(json: string): string {
try {
return JSON.stringify(JSON.parse(json), null, 2);
} catch {
return '{ "error": "invalid JSON" }';
}
}

export function useV2EditorController(context: PlaygroundDataV2): ComposedEditorProps {
const [key, setKey] = useState<ConfigKey>("layout");
const keyBox = useState(() => observable.box<ConfigKey>(key))[0];

const onViewSelectChange = (value: string): void => {
let newKey: ConfigKey;
const onViewSelectChange = useCallback((value: string): void => {
if (value === "layout" || value === "config") {
newKey = value;
setKey(value);
} else {
const n = parseInt(value, 10);
newKey = isNaN(n) ? "layout" : n;
setKey(isNaN(n) ? "layout" : n);
}
setKey(newKey);
runInAction(() => keyBox.set(newKey));
};
}, []);

const store = context.store;

Expand Down Expand Up @@ -88,13 +77,16 @@ export function useV2EditorController(context: PlaygroundDataV2): ComposedEditor
[store, key]
);

// Re-subscribes whenever the selected key changes; fireImmediately syncs the editor to the
// current key's code on switch, then keeps tracking external store edits for that key.
useEffect(
() =>
reaction(
() => getEditorCode(store, keyBox.get()),
code => setInput(prettifyJson(code))
() => getEditorCode(store, key),
code => setInput(prettifyJson(code)),
{ fireImmediately: true }
),
[store, keyBox]
[store, key]
);

return {
Expand Down
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 the "Modeler Layout" and "Modeler Configuration" panels in the chart playground were empty.

## [6.3.0] - 2026-02-17

### Breaking changes
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: tdd-refactor
created: 2026-07-07
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
## Test Cases

Most findings are internal cleanups; some are structurally verifiable by unit test,
others (B5 packaging, B7 comment) are verified by build/inspection and carry no test.
The central net-new coverage is **B6** (`EditableChartStore`), which also acts as the
regression net for **B4** (data round-trips through the store before the Plotly cast).

Test files:

- `packages/shared/charts/src/model/stores/__tests__/EditableChart.store.spec.ts` (new — B6)
- `packages/pluggableWidgets/custom-chart-web/src/hooks/__tests__/useCustomChart.spec.ts` (new — B1/B2/B3)
- `packages/pluggableWidgets/chart-playground-web/src/helpers/__tests__/prettifyJson.spec.ts` (new — B9)

### Reproduction Tests

- **B3 — playgroundData carries real layout/config options** (unit)
- **Given**: `useCustomChart` rendered with a `CustomChartContainerProps` whose adapter
parses a non-empty `layoutStatic` / `configurationOptions` (e.g. `{ "title": "X" }` /
`{ "displaylogo": true }`).
- **When**: the hook returns `playgroundData`.
- **Then**: `playgroundData.layoutOptions` equals the adapter's `layout` (contains
`title: "X"`) and `playgroundData.configOptions` equals the adapter's `config`
(contains `displaylogo: true`) — **not** `{}`.

- **B6 — store reset() loads props into layout/config/data** (unit)
- **Given**: a fresh `EditableChartStore` wired to a `ComputedAtom` whose props are
`{ layout: { a: 1 }, config: { b: 2 }, data: [{ x: [1] }] }`.
- **When**: `setup()` autorun runs (or `reset` is called directly).
- **Then**: `store.layout`, `store.config`, `store.data` equal those inputs.

### Edge Cases

- **B6 — setDataAt updates a valid index with valid JSON** (unit)
- **Given**: store with `data = [{ x: [1] }, { x: [2] }]`.
- **When**: `setDataAt(1, '{"x":[9]}')`.
- **Then**: `store.data[1]` deep-equals `{ x: [9] }`; index 0 unchanged; `data` is a new
array reference (observable.ref replaced, not mutated).

- **B6 — setDataAt ignores out-of-range index** (unit)
- **Given**: store with `data` of length 2.
- **When**: `setDataAt(5, '{"x":[9]}')` and `setDataAt(-1, '{"x":[9]}')`.
- **Then**: `store.data` is unchanged (no throw).

- **B6 — setDataAt swallows invalid JSON and warns** (unit)
- **Given**: store with a valid `data` array; `console.warn` spied.
- **When**: `setDataAt(0, "{ not json ")`.
- **Then**: `store.data` unchanged, no throw, `console.warn` called once.

- **B6 — setDataAt rejects non-object JSON (array / primitive)** (unit)
- **Given**: store with valid `data`.
- **When**: `setDataAt(0, "[1,2,3]")` and `setDataAt(0, "42")`.
- **Then**: `store.data` unchanged (guard requires a non-array object).

- **B6 — setLayout / setConfig ignore null, replace on object** (unit)
- **Given**: store with `layout = { a: 1 }`.
- **When**: `setLayout(null as any)` then `setLayout({ c: 3 })`.
- **Then**: after null, `layout` unchanged; after object, `layout` deep-equals `{ c: 3 }`
and is a fresh reference. Same for `setConfig`.

- **B6 — JSON getters serialize current state** (unit)
- **Given**: store with `layout = { a: 1 }`, `config = { b: 2 }`, `data = [{ x: [1] }]`.
- **When**: read `layoutJson`, `configJson`, `dataJson`.
- **Then**: `layoutJson === '{"a":1}'`, `configJson === '{"b":2}'`,
`dataJson` deep-equals `['{"x":[1]}']`.

- **B9 — shared prettifyJson formats valid and flags invalid** (unit)
- **Given**: the extracted `prettifyJson` helper.
- **When**: called with `'{"a":1}'` and with `"{ bad"`.
- **Then**: valid input returns 2-space-indented JSON (`'{\n "a": 1\n}'`); invalid input
returns `'{ "error": "invalid JSON" }'`.

### Regression Tests

- **B1 — playgroundData is a plain reactive object, no stale caching** (unit)
- **Given**: `useCustomChart` rendered inside an `observer` (mirrors `CustomChart.tsx`).
- **When**: the store's `data` changes (e.g. via `setDataAt`) and the component re-renders.
- **Then**: `playgroundData.plotData` reflects the new `store.data` (reactivity preserved
after removing the `computed(...).get()` wrapper). `playgroundData.type === "editor.data.v2"`.

- **B2 — hook return shape has no containerStyle** (unit)
- **Given**: `useCustomChart` rendered.
- **When**: inspect the returned object keys.
- **Then**: keys are exactly `{ playgroundData, ref }`; `containerStyle` absent. (Compile-time
guarantee via `UseCustomChartReturn`; asserted at runtime as regression guard.)

- **B4 — store data round-trips into Plotly Data via the boundary helper** (unit)
- **Given**: `store.data = [{ type: "bar", x: [1], y: [2] }]`.
- **When**: mapped through `toPlotlyData(store.data)`.
- **Then**: output deep-equals the input traces (identity mapping, correctly typed as
`Data[]`) — no data dropped or reshaped.

- **B10 — onViewSelectChange keeps a stable identity across renders** (unit)
- **Given**: `useV2EditorController` (and `useComposedEditorController`) rendered.
- **When**: the component re-renders without `store`/`key` changing.
- **Then**: the `onViewSelectChange` reference is unchanged between renders (memoized like
`onEditorChange`).

- **B11 — single source of truth for active key in V2 controller** (unit)
- **Given**: `useV2EditorController` rendered.
- **When**: `onViewSelectChange` selects `"config"`, then trace index `0`.
- **Then**: `viewSelectValue` and the editor code both follow the selected key with no
desync; changing the key updates the displayed code (the MobX reaction still fires from
the single retained key representation).

## Notes

- **B5** (`@types/jest` → devDependencies, drop from `tsconfig.build.json`) and **B7**
(document the `set-state-in-effect` suppression): no unit test — verified by
`pnpm --filter @mendix/shared-charts build` succeeding and code inspection. Track in tasks.md.
- **B8** (silent `catch {}` in the editor controllers): **out of scope**, deferred to WC-3348.
- **B10/B11**: if a memoization/identity assertion proves brittle in RTL, fall back to
asserting observable behavior (no desync, correct code shown) rather than reference equality.
- The `EditableChartStore` needs a `SetupComponentHost` + `ComputedAtom` test harness; check
`@mendix/widget-plugin-mobx-kit` for an existing test helper before hand-rolling one.
Loading
Loading