Skip to content
Merged
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 "../editorJson";

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
@@ -1,7 +1,4 @@
jest.mock("plotly.js-dist-min", () => ({}));
jest.mock("react-plotly.js", () => jest.fn(() => null));
jest.mock("react-plotly.js/factory", () => jest.fn(() => jest.fn(() => null)));

import "./stubObjectURL";
import { act, renderHook } from "@testing-library/react";
import { EditorStore, PlaygroundDataV1 } from "@mendix/shared-charts/main";
import { useComposedEditorController } from "../useComposedEditorController";
Expand Down Expand Up @@ -33,4 +30,14 @@ describe("useComposedEditorController", () => {
// not get replaced by the store's reformatted echo of that same edit.
expect(result.current.value).toBe('{"a":1,"b":2}');
});

it("keeps onViewSelectChange identity stable across re-renders", () => {
const data = setupData();
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
@@ -1,28 +1,33 @@
jest.mock("plotly.js-dist-min", () => ({}));
jest.mock("react-plotly.js", () => jest.fn(() => null));
jest.mock("react-plotly.js/factory", () => jest.fn(() => jest.fn(() => null)));

import "./stubObjectURL";
import { act, renderHook } from "@testing-library/react";
import { EditableChartStore, PlaygroundDataV2 } from "@mendix/shared-charts/main";
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";

function setupData(): PlaygroundDataV2 {
const host = { add: () => {} };
const props = { get: () => ({ layout: {}, config: {}, data: [] }) };
const store = new EditableChartStore(host as any, props as any);
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 {
type: "editor.data.v2",
store,
plotData: [],
configOptions: {},
layoutOptions: {}
context: {
type: "editor.data.v2",
store,
plotData: initial.data,
layoutOptions: {},
configOptions: {}
},
dispose
};
}

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

act(() => {
// Compact, single-line input — the store round-trips this through
Expand All @@ -34,5 +39,43 @@ describe("useV2EditorController", () => {
// The value shown to the user must remain exactly what they typed,
// not get replaced by the store's reformatted echo of that same edit.
expect(result.current.value).toBe('{"a":1,"b":2}');

dispose();
});

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
Expand Up @@ -35,14 +35,14 @@ function getModelerCode(data: PlaygroundDataV1, key: ConfigKey): Partial<Data> |
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
Original file line number Diff line number Diff line change
Expand Up @@ -34,17 +34,20 @@ export function useV2EditorController(context: PlaygroundDataV2): ComposedEditor
const [key, setKey] = useState<ConfigKey>("layout");
const keyBox = useState(() => observable.box<ConfigKey>(key))[0];

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

const store = context.store;

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