From 9cf7016a1e26f3ff639b912e47d467a307e63510 Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Tue, 7 Jul 2026 18:02:35 +0200 Subject: [PATCH 1/9] chore(custom-chart-web): add openspec change for WC-3488 --- .../changes/fix-rework-cleanup/.openspec.yaml | 2 + .../changes/fix-rework-cleanup/design.md | 114 ++++++++++++++++++ .../changes/fix-rework-cleanup/proposal.md | 91 ++++++++++++++ .../changes/fix-rework-cleanup/tasks.md | 75 ++++++++++++ .../custom-chart-web/openspec/config.yaml | 1 + 5 files changed, 283 insertions(+) create mode 100644 packages/pluggableWidgets/custom-chart-web/openspec/changes/fix-rework-cleanup/.openspec.yaml create mode 100644 packages/pluggableWidgets/custom-chart-web/openspec/changes/fix-rework-cleanup/design.md create mode 100644 packages/pluggableWidgets/custom-chart-web/openspec/changes/fix-rework-cleanup/proposal.md create mode 100644 packages/pluggableWidgets/custom-chart-web/openspec/changes/fix-rework-cleanup/tasks.md create mode 100644 packages/pluggableWidgets/custom-chart-web/openspec/config.yaml diff --git a/packages/pluggableWidgets/custom-chart-web/openspec/changes/fix-rework-cleanup/.openspec.yaml b/packages/pluggableWidgets/custom-chart-web/openspec/changes/fix-rework-cleanup/.openspec.yaml new file mode 100644 index 0000000000..adefb283a0 --- /dev/null +++ b/packages/pluggableWidgets/custom-chart-web/openspec/changes/fix-rework-cleanup/.openspec.yaml @@ -0,0 +1,2 @@ +schema: tdd-refactor +created: 2026-07-07 diff --git a/packages/pluggableWidgets/custom-chart-web/openspec/changes/fix-rework-cleanup/design.md b/packages/pluggableWidgets/custom-chart-web/openspec/changes/fix-rework-cleanup/design.md new file mode 100644 index 0000000000..cc9b64625e --- /dev/null +++ b/packages/pluggableWidgets/custom-chart-web/openspec/changes/fix-rework-cleanup/design.md @@ -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. diff --git a/packages/pluggableWidgets/custom-chart-web/openspec/changes/fix-rework-cleanup/proposal.md b/packages/pluggableWidgets/custom-chart-web/openspec/changes/fix-rework-cleanup/proposal.md new file mode 100644 index 0000000000..56e9681d07 --- /dev/null +++ b/packages/pluggableWidgets/custom-chart-web/openspec/changes/fix-rework-cleanup/proposal.md @@ -0,0 +1,91 @@ +## Why + +The custom-chart / playground editor rework PR merged to `main` with an unresolved +code review from Leonardo de Souza. Eleven Critical/Important/Minor findings shipped +unfixed and are still live on `main` (re-verified 2026-07-06, WC-3488). The most +visible impact: + +- The playground sidebar's **"Modeler Layout"** and **"Modeler Configuration"** panels + render empty — they read `layoutOptions` / `configOptions`, which the custom-chart + widget hardcodes to `{}` (B3). +- A `computed(fn).get()` misuse allocates a throwaway MobX atom on every render, + defeating caching and providing no reactivity (B1). +- The store that parses user JSON (`EditableChartStore`) has **zero unit tests** after + a 402-line spec (`mergeChartProps.spec.ts`) was deleted with no replacement (B6). + +The rest are correctness/maintainability debt: an unsound cast that hides invalid user +JSON, a misplaced `@types/jest` runtime dependency, dead code, duplication, and +undocumented lint suppressions. + +## Root Cause + +Per finding (all confirmed by file:line inspection, lines current on branch off +`origin/main`): + +- **B1** `custom-chart-web/src/hooks/useCustomChart.ts:54-62` — a plain object is wrapped + in `computed((): PlaygroundData => ({...})).get()`, creating a new computed atom each + render. `CustomChart.tsx` is already `observer`-wrapped, so reactivity is handled there. +- **B2** `useCustomChart.ts:13-32,35,53,65` + `CustomChart.tsx:11` — `getContainerStyle` + and the returned `containerStyle` are dead; `CustomChart.tsx` destructures only + `{ playgroundData, ref }` and computes its own style via `constructWrapperStyle(props)`. +- **B3** `useCustomChart.ts:59-60` — `layoutOptions: {}` / `configOptions: {}` are hardcoded. + The controller host exposes `adapter: ChartPropsController` with `layout` / `config` + getters that hold the real parsed values. +- **B4** `custom-chart-web/src/controllers/CustomChartControllerHost.ts:34` — `store.data` + (typed `Array>`) is cast `as Data[]`, suppressing the mismatch + with Plotly's `Data` type. +- **B5** `shared/charts/package.json:38` + `shared/charts/tsconfig.build.json:10` — + `@types/jest` sits in `dependencies` and `"types": ["jest"]` is in the production build + tsconfig, shipping test types as a runtime dependency. +- **B6** `mergeChartProps.spec.ts` deleted (last seen in commit `e79bd67d9`); the only + remaining custom-chart-web test is `src/utils/utils.spec.ts`. `EditableChartStore` (which + handles user JSON edits) is untested. +- **B7** `chart-playground-web/src/helpers/useComposedEditorController.ts:83` — + `eslint-disable react-hooks/set-state-in-effect` with no explanation. +- **B9** duplicate `prettifyJson` in `useComposedEditorController.ts:33-39` and + `useV2EditorController.ts:32-38` (identical bodies). +- **B10** `onViewSelectChange` is not memoized while `onEditorChange` is `useCallback`-wrapped, + in both `useComposedEditorController.ts:44-51` and `useV2EditorController.ts:44-54`. +- **B11** `useV2EditorController.ts:41-42` — the active editor key lives in both a React + `useState` (`key`) and a MobX `observable.box` (`keyBox`): two sources of truth kept in + sync by hand. + +## What Changes + +- **B1** Replace the `computed(...).get()` wrapper in `useCustomChart.ts` with a plain + object literal. +- **B2** Delete `getContainerStyle`, the `containerStyle` field on `UseCustomChartReturn`, + and its return-value entry. +- **B3** Destructure `adapter` from the controller host and pass `adapter.layout` / + `adapter.config` as `layoutOptions` / `configOptions`. +- **B4** Route `store.data` → Plotly `Data[]` through a single named boundary helper + (e.g. `toPlotlyData`) instead of a bare `as Data[]`. No runtime validation added — the + store already guards on write (`setDataAt` parses, type-checks, and warns on invalid + JSON); Plotly's `Data` is a large union that is impractical to validate exhaustively. + This localizes and documents the unavoidable type conversion in one place. +- **B5** Move `@types/jest` to `devDependencies`; drop `"jest"` from `tsconfig.build.json`'s + `types`. +- **B6** Add a unit spec for `EditableChartStore` covering `setLayout` / `setConfig` / + `setDataAt` / `reset`, the JSON getters, and invalid/out-of-range input. +- **B7** Add a comment documenting why the `set-state-in-effect` suppression is safe. +- **B9** Extract `prettifyJson` to one shared helper; import in both controllers. +- **B10** Wrap `onViewSelectChange` in `useCallback` in both controllers. +- **B11** Collapse `key` to a single source of truth in `useV2EditorController`. + +## Impact + +- **In scope:** `custom-chart-web` (hooks, controllers, `CustomChart.tsx`), + `chart-playground-web` (helpers only — **not** `CodeEditor.tsx`), `shared/charts` + (`EditableChartStore`, `package.json`, `tsconfig.build.json`). +- **Out of scope (do not touch):** anything WC-3348 (PR #2310) delivered — `CodeEditor.tsx`, + the editor restore, editor-side JSON lint. The silent empty-JSON `catch {}` + (`useComposedEditorController.ts:75-76`, `useV2EditorController.ts:85-86`) is explicitly + deferred to WC-3348 per the ticket; **B8 is not fixed here**. +- **Not breaking.** No public widget prop, XML key, or MPK output changes. B3 restores an + intended behavior (populated Modeler panels); B1/B2/B4/B9/B10/B11 are internal; B5 is a + packaging fix; B6/B7 add tests/docs. +- **Must not break:** live chart rendering and click events (B1/B2/B4), the V1 and V2 + playground editors' data/layout/config round-trip (B3/B9/B10/B11), and the + `shared/charts` production build (B5). +- Internal-only changes → **no changelog entry** expected for any of the three packages + (confirm at PR time). Version bumps at release only. diff --git a/packages/pluggableWidgets/custom-chart-web/openspec/changes/fix-rework-cleanup/tasks.md b/packages/pluggableWidgets/custom-chart-web/openspec/changes/fix-rework-cleanup/tasks.md new file mode 100644 index 0000000000..115f922869 --- /dev/null +++ b/packages/pluggableWidgets/custom-chart-web/openspec/changes/fix-rework-cleanup/tasks.md @@ -0,0 +1,75 @@ +## 1. Test Setup + + + +- [x] 1.1 Add `EditableChart.store.spec.ts` (B6): `reset` loads props; `setDataAt` valid + index/valid JSON; out-of-range index no-op; invalid JSON swallowed + `console.warn`; + non-object JSON rejected; `setLayout`/`setConfig` null-guard + replace; JSON getters. + Find/reuse a `SetupComponentHost` + `ComputedAtom` harness from `widget-plugin-mobx-kit`. + → 8 tests, harness = `SetupHost` subclass + `{ get: () => box.get() }` atom. +- [x] 1.2 Add `useCustomChart.spec.ts` (B1/B2/B3): playgroundData has real `layoutOptions`/ + `configOptions` from adapter (not `{}`); return shape is exactly `{ playgroundData, ref }` + (no `containerStyle`); plotData stays reactive to `store.data` under an `observer`. + → reactivity asserted via an `observer` Probe + `waitFor` (setup autorun is post-render). + Added `^src/` moduleNameMapper to widget jest.config.js (source uses baseUrl imports). +- [x] 1.3 Add `prettifyJson.spec.ts` (B9): formats valid JSON 2-space; returns error object + string on invalid. +- [x] 1.4 Add controller tests (B4/B10/B11): `toPlotlyData` identity round-trip; + `onViewSelectChange` stable identity across renders (both controllers); V2 key has no + desync when switching layout→config→trace. → shared-charts barrel pulls plotly, so specs + import `./stubObjectURL` first to shim `URL.createObjectURL` in the test env. + +## 2. Implementation + + + +- [x] 2.1 **B6** — implement/confirm `EditableChartStore` behavior so store spec passes (store + already exists; adjust only if a test exposes a real defect — do not weaken tests). + → store behavior already correct; spec is characterization coverage, no source change. +- [x] 2.2 **B1** — replace `computed((): PlaygroundData => ({...})).get()` in `useCustomChart.ts` + with a plain object literal. +- [x] 2.3 **B3** — destructure `adapter` from `CustomChartControllerHost`; pass `adapter.layout` + / `adapter.config` as `layoutOptions` / `configOptions`. +- [x] 2.4 **B2** — delete `getContainerStyle`, the `containerStyle` field on + `UseCustomChartReturn`, and its return entry. → also dropped now-unused `CSSProperties` import. +- [x] 2.5 **B4** — add `toPlotlyData(data: Array>): Data[]` boundary + helper; use it in `CustomChartControllerHost.viewModelAtom` instead of `as Data[]`. +- [x] 2.6 **B9** — extract `prettifyJson` to one shared helper; import in both + `useComposedEditorController.ts` and `useV2EditorController.ts`; remove duplicates. +- [x] 2.7 **B10** — wrap `onViewSelectChange` in `useCallback` in both controllers. +- [x] 2.8 **B11** — collapse `key`/`keyBox` in `useV2EditorController` to a single source of + truth. → DEVIATION: kept the React `useState` `key` (it drives render; the box could not) + and removed `keyBox`. The MobX `reaction` now keys off `key` via the effect deps and uses + `{ fireImmediately: true }` to re-sync editor input on view switch. Removed `observable`/ + `runInAction` imports. + +## 3. Refactoring + + + +- [x] 3.1 **B5** — move `@types/jest` from `dependencies` to `devDependencies` in + `shared/charts/package.json`; remove `"jest"` from `types` in `tsconfig.build.json`. +- [x] 3.2 **B7** — add a comment above the `react-hooks/set-state-in-effect` disable in + `useComposedEditorController.ts` explaining why it's safe (syncs editor input to external + code changes; risk = overwriting in-progress edits, accepted). +- [x] 3.3 Confirm B8 left untouched (out of scope, WC-3348); no stray edits to `CodeEditor.tsx`. + +## 4. Verification + +- [x] 4.1 All new tests passing. +- [x] 4.2 Full test suite green for the three packages (custom-chart-web 33, chart-playground-web + 6, shared/charts 45) — no regressions. Lint clean on all changed files. +- [x] 4.3 shared-charts build succeeds after B5 tsconfig change (`tsc -p tsconfig.build.json` + `pnpm build` both clean). +- [ ] 4.4 Runtime check (optional, `/debug-widget`): playground Modeler Layout/Config panels + populate (B3) in the WC-3348 test project. +- [x] 4.5 Changelog: added a "Fixed" entry to custom-chart-web for the restored Modeler + Layout/Configuration panels (B3, user-visible). All other findings internal → no entry. + chart-playground-web + shared/charts: no changelog. Version bumps at release only. + +## Notes + + + +- Order rationale: B6 store spec first = safety net before touching the B1/B3/B4 cluster that + feeds the store into the chart/playground. +- If B10/B11 identity assertions prove brittle in RTL, switch to behavior assertions per design.md. diff --git a/packages/pluggableWidgets/custom-chart-web/openspec/config.yaml b/packages/pluggableWidgets/custom-chart-web/openspec/config.yaml new file mode 100644 index 0000000000..bd4abbf66f --- /dev/null +++ b/packages/pluggableWidgets/custom-chart-web/openspec/config.yaml @@ -0,0 +1 @@ +schema: tdd-refactor From f48e916b5c53c389387e4b17b7d7a1102329a9ab Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Tue, 7 Jul 2026 18:02:46 +0200 Subject: [PATCH 2/9] test(shared-charts): cover EditableChartStore JSON handling --- .../__tests__/EditableChart.store.spec.ts | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 packages/shared/charts/src/model/stores/__tests__/EditableChart.store.spec.ts diff --git a/packages/shared/charts/src/model/stores/__tests__/EditableChart.store.spec.ts b/packages/shared/charts/src/model/stores/__tests__/EditableChart.store.spec.ts new file mode 100644 index 0000000000..7ed8d81b0a --- /dev/null +++ b/packages/shared/charts/src/model/stores/__tests__/EditableChart.store.spec.ts @@ -0,0 +1,128 @@ +import { observable } from "mobx"; +import { SetupHost } from "@mendix/widget-plugin-mobx-kit/main"; +import { EditableChartStore, EditableChartStoreProps } from "../EditableChart.store"; + +class TestHost extends SetupHost {} + +function setupStore(initial: EditableChartStoreProps = { layout: {}, config: {}, data: [] }): { + store: EditableChartStore; + props: ReturnType>; + dispose: () => void; +} { + const props = observable.box(initial, { deep: false }); + const host = new TestHost(); + const store = new EditableChartStore(host, { get: () => props.get() }); + const dispose = host.setup(); + return { store, props, dispose }; +} + +describe("EditableChartStore", () => { + it("loads layout, config and data from props on setup", () => { + const { store, dispose } = setupStore({ + layout: { a: 1 }, + config: { b: 2 }, + data: [{ x: [1] }] + }); + + expect(store.layout).toEqual({ a: 1 }); + expect(store.config).toEqual({ b: 2 }); + expect(store.data).toEqual([{ x: [1] }]); + + dispose(); + }); + + it("setDataAt replaces the trace at a valid index with parsed JSON", () => { + const { store, dispose } = setupStore({ + layout: {}, + config: {}, + data: [{ x: [1] }, { x: [2] }] + }); + const before = store.data; + + store.setDataAt(1, '{"x":[9]}'); + + expect(store.data[0]).toEqual({ x: [1] }); + expect(store.data[1]).toEqual({ x: [9] }); + expect(store.data).not.toBe(before); + + dispose(); + }); + + it("setDataAt ignores an out-of-range index without throwing", () => { + const { store, dispose } = setupStore({ layout: {}, config: {}, data: [{ x: [1] }, { x: [2] }] }); + const before = store.data; + + store.setDataAt(5, '{"x":[9]}'); + store.setDataAt(-1, '{"x":[9]}'); + + expect(store.data).toBe(before); + + dispose(); + }); + + it("setDataAt swallows invalid JSON, keeps data, and warns", () => { + const { store, dispose } = setupStore({ layout: {}, config: {}, data: [{ x: [1] }] }); + const before = store.data; + const warn = jest.spyOn(console, "warn").mockImplementation(() => undefined); + + store.setDataAt(0, "{ not json "); + + expect(store.data).toBe(before); + expect(warn).toHaveBeenCalledTimes(1); + + warn.mockRestore(); + dispose(); + }); + + it("setDataAt rejects non-object JSON (array or primitive)", () => { + const { store, dispose } = setupStore({ layout: {}, config: {}, data: [{ x: [1] }] }); + const before = store.data; + + store.setDataAt(0, "[1,2,3]"); + store.setDataAt(0, "42"); + + expect(store.data).toBe(before); + + dispose(); + }); + + it("setLayout ignores null and replaces on a real object", () => { + const { store, dispose } = setupStore({ layout: { a: 1 }, config: {}, data: [] }); + + store.setLayout(null as unknown as Record); + expect(store.layout).toEqual({ a: 1 }); + + const before = store.layout; + store.setLayout({ c: 3 }); + expect(store.layout).toEqual({ c: 3 }); + expect(store.layout).not.toBe(before); + + dispose(); + }); + + it("setConfig ignores null and replaces on a real object", () => { + const { store, dispose } = setupStore({ layout: {}, config: { b: 2 }, data: [] }); + + store.setConfig(null as unknown as Record); + expect(store.config).toEqual({ b: 2 }); + + store.setConfig({ d: 4 }); + expect(store.config).toEqual({ d: 4 }); + + dispose(); + }); + + it("exposes layout, config and data as JSON strings", () => { + const { store, dispose } = setupStore({ + layout: { a: 1 }, + config: { b: 2 }, + data: [{ x: [1] }] + }); + + expect(store.layoutJson).toBe('{"a":1}'); + expect(store.configJson).toBe('{"b":2}'); + expect(store.dataJson).toEqual(['{"x":[1]}']); + + dispose(); + }); +}); From b213daa4de3c64a21597ae02b788f652bf47758b Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Tue, 7 Jul 2026 18:02:54 +0200 Subject: [PATCH 3/9] fix(custom-chart-web): send real layout/config to playground and drop dead style --- .../custom-chart-web/jest.config.js | 11 ++- .../hooks/__tests__/useCustomChart.spec.ts | 72 +++++++++++++++++++ .../src/hooks/useCustomChart.ts | 45 +++--------- 3 files changed, 90 insertions(+), 38 deletions(-) create mode 100644 packages/pluggableWidgets/custom-chart-web/src/hooks/__tests__/useCustomChart.spec.ts diff --git a/packages/pluggableWidgets/custom-chart-web/jest.config.js b/packages/pluggableWidgets/custom-chart-web/jest.config.js index 73427f83c9..ae91b01091 100644 --- a/packages/pluggableWidgets/custom-chart-web/jest.config.js +++ b/packages/pluggableWidgets/custom-chart-web/jest.config.js @@ -1,4 +1,11 @@ +const base = require("@mendix/pluggable-widgets-tools/test-config/jest.config.js"); + module.exports = { - ...require("@mendix/pluggable-widgets-tools/test-config/jest.config.js"), - testEnvironment: "@happy-dom/jest-environment" + ...base, + testEnvironment: "@happy-dom/jest-environment", + moduleNameMapper: { + ...base.moduleNameMapper, + // Source uses baseUrl-relative imports (e.g. `from "src/controllers/..."`); map them to rootDir. + "^src/(.*)$": "/$1" + } }; diff --git a/packages/pluggableWidgets/custom-chart-web/src/hooks/__tests__/useCustomChart.spec.ts b/packages/pluggableWidgets/custom-chart-web/src/hooks/__tests__/useCustomChart.spec.ts new file mode 100644 index 0000000000..63b3c501d6 --- /dev/null +++ b/packages/pluggableWidgets/custom-chart-web/src/hooks/__tests__/useCustomChart.spec.ts @@ -0,0 +1,72 @@ +import { render, renderHook, screen, waitFor } from "@testing-library/react"; +import { observer } from "mobx-react-lite"; +import { createElement, ReactElement } from "react"; +import { PlaygroundDataV2 } from "@mendix/shared-charts/main"; +import { CustomChartContainerProps } from "../../../typings/CustomChartProps"; +import { useCustomChart } from "../useCustomChart"; + +function props(overrides: Partial = {}): CustomChartContainerProps { + return { + name: "chart", + class: "", + tabIndex: 0, + dataStatic: "[]", + sampleData: "", + showPlaygroundSlot: true, + layoutStatic: "{}", + sampleLayout: "", + configurationOptions: "{}", + widthUnit: "percentage", + width: 100, + heightUnit: "percentageOfWidth", + height: 75, + minHeightUnit: "none", + minHeight: 0, + maxHeightUnit: "none", + maxHeight: 0, + OverflowY: "auto", + ...overrides + } as CustomChartContainerProps; +} + +describe("useCustomChart", () => { + it("passes the adapter's parsed layout and config into playgroundData (not empty objects)", () => { + const { result } = renderHook(() => + useCustomChart( + props({ + layoutStatic: JSON.stringify({ title: "X" }), + configurationOptions: JSON.stringify({ displaylogo: true }) + }) + ) + ); + + const data = result.current.playgroundData as PlaygroundDataV2; + + expect(data.layoutOptions).toMatchObject({ title: "X" }); + expect(data.configOptions).toMatchObject({ displaylogo: true }); + }); + + it("returns only playgroundData and ref (no dead containerStyle)", () => { + const { result } = renderHook(() => useCustomChart(props())); + + expect(Object.keys(result.current).sort()).toEqual(["playgroundData", "ref"]); + }); + + it("stays reactive to the store's parsed traces when rendered inside an observer", async () => { + // Mirrors CustomChart.tsx: the hook is consumed by an observer component, which + // re-renders once the store's setup autorun loads the parsed data. + const Probe = observer(function Probe(p: CustomChartContainerProps): ReactElement { + const { playgroundData } = useCustomChart(p); + const data = playgroundData as PlaygroundDataV2; + return createElement("output", {}, JSON.stringify({ type: data.type, plotData: data.plotData })); + }); + + render(createElement(Probe, props({ dataStatic: JSON.stringify([{ type: "bar", x: [1], y: [2] }]) }))); + + await waitFor(() => { + const parsed = JSON.parse(screen.getByRole("status").textContent ?? "{}"); + expect(parsed.type).toBe("editor.data.v2"); + expect(parsed.plotData).toEqual([{ type: "bar", x: [1], y: [2] }]); + }); + }); +}); diff --git a/packages/pluggableWidgets/custom-chart-web/src/hooks/useCustomChart.ts b/packages/pluggableWidgets/custom-chart-web/src/hooks/useCustomChart.ts index 67bfb3fd42..d3daf5f848 100644 --- a/packages/pluggableWidgets/custom-chart-web/src/hooks/useCustomChart.ts +++ b/packages/pluggableWidgets/custom-chart-web/src/hooks/useCustomChart.ts @@ -1,5 +1,4 @@ -import { computed } from "mobx"; -import { CSSProperties, Ref, RefCallback, useEffect } from "react"; +import { Ref, RefCallback, useEffect } from "react"; import { CustomChartControllerHost } from "src/controllers/CustomChartControllerHost"; import { mergeRefs } from "src/utils/mergeRefs"; import { PlaygroundData } from "@mendix/shared-charts/main"; @@ -9,30 +8,7 @@ import { useSetup } from "@mendix/widget-plugin-mobx-kit/react/useSetup"; import { CustomChartContainerProps } from "../../typings/CustomChartProps"; import { ControllerProps } from "../controllers/typings"; -// TODO: replace with get-dimensions from widget-plugin-platform -function getContainerStyle( - width: number, - widthUnit: CustomChartContainerProps["widthUnit"], - height: number, - heightUnit: CustomChartContainerProps["heightUnit"] -): CSSProperties { - const style: CSSProperties = { - width: widthUnit === "percentage" ? `${width}%` : `${width}px` - }; - - if (heightUnit === "percentageOfWidth") { - style.paddingBottom = widthUnit === "percentage" ? `${height}%` : `${width / 2}px`; - } else if (heightUnit === "pixels") { - style.height = `${height}px`; - } else if (heightUnit === "percentageOfParent") { - style.height = `${height}%`; - } - - return style; -} - interface UseCustomChartReturn { - containerStyle: CSSProperties; playgroundData: PlaygroundData; ref: Ref | RefCallback | undefined; } @@ -42,6 +18,7 @@ export function useCustomChart(props: CustomChartContainerProps): UseCustomChart const { store, + adapter, chartViewModel, resizeCtrl: resizeController } = useSetup(() => new CustomChartControllerHost(gateProvider.gate)); @@ -50,19 +27,15 @@ export function useCustomChart(props: CustomChartContainerProps): UseCustomChart gateProvider.setProps(props); }); - const containerStyle = getContainerStyle(props.width, props.widthUnit, props.height, props.heightUnit); - const playgroundData = computed( - (): PlaygroundData => ({ - type: "editor.data.v2", - store, - plotData: store.data, - layoutOptions: {}, - configOptions: {} - }) - ).get(); + const playgroundData: PlaygroundData = { + type: "editor.data.v2", + store, + plotData: store.data, + layoutOptions: adapter.layout, + configOptions: adapter.config + }; return { - containerStyle, playgroundData, ref: mergeRefs(resizeController.setTarget, chartViewModel.setChart) }; From f3a91e63c8e3b814bf40cac94f8aece9fc6d18d7 Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Tue, 7 Jul 2026 18:03:01 +0200 Subject: [PATCH 4/9] refactor(custom-chart-web): route store data through typed toPlotlyData helper --- .../src/controllers/CustomChartControllerHost.ts | 4 ++-- .../src/utils/__tests__/toPlotlyData.spec.ts | 16 ++++++++++++++++ .../custom-chart-web/src/utils/toPlotlyData.ts | 13 +++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 packages/pluggableWidgets/custom-chart-web/src/utils/__tests__/toPlotlyData.spec.ts create mode 100644 packages/pluggableWidgets/custom-chart-web/src/utils/toPlotlyData.ts diff --git a/packages/pluggableWidgets/custom-chart-web/src/controllers/CustomChartControllerHost.ts b/packages/pluggableWidgets/custom-chart-web/src/controllers/CustomChartControllerHost.ts index 9cc4eada46..309d0cd9c2 100644 --- a/packages/pluggableWidgets/custom-chart-web/src/controllers/CustomChartControllerHost.ts +++ b/packages/pluggableWidgets/custom-chart-web/src/controllers/CustomChartControllerHost.ts @@ -1,6 +1,5 @@ import { computed } from "mobx"; -import { Data } from "plotly.js-dist-min"; import { EditableChartStore, EditableChartStoreProps } from "@mendix/shared-charts/main"; import { ComputedAtom, DerivedPropsGate, SetupHost } from "@mendix/widget-plugin-mobx-kit/main"; import { ChartPropsController } from "./ChartPropsController"; @@ -8,6 +7,7 @@ import { PlotlyController } from "./PlotlyController"; import { ResizeController } from "./ResizeController"; import { ControllerProps } from "./typings"; import { PlotlyChartProps } from "../components/PlotlyChart"; +import { toPlotlyData } from "../utils/toPlotlyData"; export class CustomChartControllerHost extends SetupHost { resizeCtrl: ResizeController; @@ -31,7 +31,7 @@ export class CustomChartControllerHost extends SetupHost { function viewModelAtom(store: EditableChartStore, adapter: ChartPropsController): ComputedAtom { return computed(() => { return { - data: store.data as Data[], + data: toPlotlyData(store.data), layout: store.layout, config: store.config, onClick: adapter.chartOnClick diff --git a/packages/pluggableWidgets/custom-chart-web/src/utils/__tests__/toPlotlyData.spec.ts b/packages/pluggableWidgets/custom-chart-web/src/utils/__tests__/toPlotlyData.spec.ts new file mode 100644 index 0000000000..5c97d1f47d --- /dev/null +++ b/packages/pluggableWidgets/custom-chart-web/src/utils/__tests__/toPlotlyData.spec.ts @@ -0,0 +1,16 @@ +import { toPlotlyData } from "../toPlotlyData"; + +describe("toPlotlyData", () => { + it("maps stored records to Plotly traces without reshaping them", () => { + const stored = [ + { type: "bar", x: [1, 2], y: [3, 4] }, + { type: "scatter", x: [5], y: [6] } + ]; + + expect(toPlotlyData(stored)).toEqual(stored); + }); + + it("returns an empty array for empty data", () => { + expect(toPlotlyData([])).toEqual([]); + }); +}); diff --git a/packages/pluggableWidgets/custom-chart-web/src/utils/toPlotlyData.ts b/packages/pluggableWidgets/custom-chart-web/src/utils/toPlotlyData.ts new file mode 100644 index 0000000000..65d12301a4 --- /dev/null +++ b/packages/pluggableWidgets/custom-chart-web/src/utils/toPlotlyData.ts @@ -0,0 +1,13 @@ +import { Data } from "plotly.js-dist-min"; + +/** + * Boundary between the editable store and Plotly. + * + * The store keeps traces as `Record[]` because user JSON is validated + * on write (`EditableChartStore.setDataAt` parses, type-checks and warns on bad input) but + * cannot be narrowed to Plotly's large `Data` union at that point. This helper localizes the + * unavoidable conversion in one named, documented place instead of scattering `as Data[]` casts. + */ +export function toPlotlyData(data: Array>): Data[] { + return data as Data[]; +} From f33c0e209a2e1b69e1bda58f850d0a7889ff5cbc Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Tue, 7 Jul 2026 18:03:07 +0200 Subject: [PATCH 5/9] chore(shared-charts): move @types/jest to devDependencies --- packages/shared/charts/package.json | 2 +- packages/shared/charts/tsconfig.build.json | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/shared/charts/package.json b/packages/shared/charts/package.json index fb4fb7e762..ce50042a50 100644 --- a/packages/shared/charts/package.json +++ b/packages/shared/charts/package.json @@ -35,7 +35,6 @@ "test": "jest" }, "dependencies": { - "@types/jest": "^30.0.0", "classnames": "^2.5.1", "deepmerge": "^4.3.1", "mobx": "6.12.3", @@ -55,6 +54,7 @@ "@rollup/plugin-node-resolve": "^16.0.0", "@rollup/plugin-replace": "^6.0.2", "@rollup/plugin-terser": "^0.4.4", + "@types/jest": "^30.0.0", "@types/plotly.js-dist-min": "^2.3.4", "@types/react-plotly.js": "^2.6.3", "copy-and-watch": "^0.1.6", diff --git a/packages/shared/charts/tsconfig.build.json b/packages/shared/charts/tsconfig.build.json index 4b32025e6d..8b55c70b78 100644 --- a/packages/shared/charts/tsconfig.build.json +++ b/packages/shared/charts/tsconfig.build.json @@ -6,8 +6,7 @@ "outDir": "./dist", "rootDir": "./src", "jsx": "react-jsx", - "jsxFactory": "", - "types": ["jest"] + "jsxFactory": "" }, "references": [{ "path": "./rollup/tsconfig.json" }] } From 0f0a1e63e624925b13306ebe9b36b4ce8a929bc3 Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Tue, 7 Jul 2026 18:03:17 +0200 Subject: [PATCH 6/9] refactor(chart-playground-web): dedupe prettifyJson, memoize handler, single key source --- .../helpers/__tests__/prettifyJson.spec.ts | 11 ++++ .../src/helpers/__tests__/stubObjectURL.ts | 7 +++ .../useComposedEditorController.spec.ts | 32 ++++++++++ .../__tests__/useV2EditorController.spec.ts | 63 +++++++++++++++++++ .../src/helpers/prettifyJson.ts | 7 +++ .../helpers/useComposedEditorController.ts | 16 +++-- .../src/helpers/useV2EditorController.ts | 32 ++++------ 7 files changed, 139 insertions(+), 29 deletions(-) create mode 100644 packages/pluggableWidgets/chart-playground-web/src/helpers/__tests__/prettifyJson.spec.ts create mode 100644 packages/pluggableWidgets/chart-playground-web/src/helpers/__tests__/stubObjectURL.ts create mode 100644 packages/pluggableWidgets/chart-playground-web/src/helpers/__tests__/useComposedEditorController.spec.ts create mode 100644 packages/pluggableWidgets/chart-playground-web/src/helpers/__tests__/useV2EditorController.spec.ts create mode 100644 packages/pluggableWidgets/chart-playground-web/src/helpers/prettifyJson.ts diff --git a/packages/pluggableWidgets/chart-playground-web/src/helpers/__tests__/prettifyJson.spec.ts b/packages/pluggableWidgets/chart-playground-web/src/helpers/__tests__/prettifyJson.spec.ts new file mode 100644 index 0000000000..8923a2ccc1 --- /dev/null +++ b/packages/pluggableWidgets/chart-playground-web/src/helpers/__tests__/prettifyJson.spec.ts @@ -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" }'); + }); +}); diff --git a/packages/pluggableWidgets/chart-playground-web/src/helpers/__tests__/stubObjectURL.ts b/packages/pluggableWidgets/chart-playground-web/src/helpers/__tests__/stubObjectURL.ts new file mode 100644 index 0000000000..04548d8f1d --- /dev/null +++ b/packages/pluggableWidgets/chart-playground-web/src/helpers/__tests__/stubObjectURL.ts @@ -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 {}; diff --git a/packages/pluggableWidgets/chart-playground-web/src/helpers/__tests__/useComposedEditorController.spec.ts b/packages/pluggableWidgets/chart-playground-web/src/helpers/__tests__/useComposedEditorController.spec.ts new file mode 100644 index 0000000000..fbf05a2ed4 --- /dev/null +++ b/packages/pluggableWidgets/chart-playground-web/src/helpers/__tests__/useComposedEditorController.spec.ts @@ -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); + }); +}); diff --git a/packages/pluggableWidgets/chart-playground-web/src/helpers/__tests__/useV2EditorController.spec.ts b/packages/pluggableWidgets/chart-playground-web/src/helpers/__tests__/useV2EditorController.spec.ts new file mode 100644 index 0000000000..2f6b3b4f9a --- /dev/null +++ b/packages/pluggableWidgets/chart-playground-web/src/helpers/__tests__/useV2EditorController.spec.ts @@ -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(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(); + }); +}); diff --git a/packages/pluggableWidgets/chart-playground-web/src/helpers/prettifyJson.ts b/packages/pluggableWidgets/chart-playground-web/src/helpers/prettifyJson.ts new file mode 100644 index 0000000000..20ced8fee6 --- /dev/null +++ b/packages/pluggableWidgets/chart-playground-web/src/helpers/prettifyJson.ts @@ -0,0 +1,7 @@ +export function prettifyJson(json: string): string { + try { + return JSON.stringify(JSON.parse(json), null, 2); + } catch { + return '{ "error": "invalid JSON" }'; + } +} diff --git a/packages/pluggableWidgets/chart-playground-web/src/helpers/useComposedEditorController.ts b/packages/pluggableWidgets/chart-playground-web/src/helpers/useComposedEditorController.ts index 02367d1cc4..6348eb29c0 100644 --- a/packages/pluggableWidgets/chart-playground-web/src/helpers/useComposedEditorController.ts +++ b/packages/pluggableWidgets/chart-playground-web/src/helpers/useComposedEditorController.ts @@ -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"; @@ -30,25 +31,18 @@ function getModelerCode(data: PlaygroundDataV1, key: ConfigKey): Partial | const entries = Object.entries(data.plotData.at(key) ?? {}).filter(([key]) => !irrelevantSeriesKeys.includes(key)); return Object.fromEntries(entries) as Partial; } -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("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 [ @@ -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 diff --git a/packages/pluggableWidgets/chart-playground-web/src/helpers/useV2EditorController.ts b/packages/pluggableWidgets/chart-playground-web/src/helpers/useV2EditorController.ts index c208f5fa4b..d7070e7dce 100644 --- a/packages/pluggableWidgets/chart-playground-web/src/helpers/useV2EditorController.ts +++ b/packages/pluggableWidgets/chart-playground-web/src/helpers/useV2EditorController.ts @@ -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"; @@ -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("layout"); - const keyBox = useState(() => observable.box(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; @@ -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 { From f82254abdef54c8db217f8c7fb91edded17b8d1f Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Tue, 7 Jul 2026 18:03:23 +0200 Subject: [PATCH 7/9] docs(custom-chart-web): changelog for restored modeler panels --- packages/pluggableWidgets/custom-chart-web/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/pluggableWidgets/custom-chart-web/CHANGELOG.md b/packages/pluggableWidgets/custom-chart-web/CHANGELOG.md index b351ce1ec2..e1072b46e3 100644 --- a/packages/pluggableWidgets/custom-chart-web/CHANGELOG.md +++ b/packages/pluggableWidgets/custom-chart-web/CHANGELOG.md @@ -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 From 3c819105eb7c816d60880f8d33fd8bb5a82d3a93 Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Tue, 7 Jul 2026 18:11:33 +0200 Subject: [PATCH 8/9] chore(shared-charts): update lockfile for @types/jest move --- pnpm-lock.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 607db78a3f..718e36a143 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2640,9 +2640,6 @@ importers: packages/shared/charts: dependencies: - '@types/jest': - specifier: ^30.0.0 - version: 30.0.0 classnames: specifier: ^2.5.1 version: 2.5.1 @@ -2695,6 +2692,9 @@ importers: '@rollup/plugin-terser': specifier: ^0.4.4 version: 0.4.4(rollup@4.62.0) + '@types/jest': + specifier: ^30.0.0 + version: 30.0.0 '@types/plotly.js-dist-min': specifier: ^2.3.4 version: 2.3.4 From 6cda1309386573218cdecc8213f27bf88dcc98ac Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Wed, 8 Jul 2026 13:55:24 +0200 Subject: [PATCH 9/9] fix(custom-chart-web): make useCustomChart test resolve under CI jest config --- .../pluggableWidgets/custom-chart-web/jest.config.js | 11 ++--------- .../src/hooks/__tests__/stubObjectURL.ts | 8 ++++++++ .../src/hooks/__tests__/useCustomChart.spec.ts | 1 + .../custom-chart-web/src/hooks/useCustomChart.ts | 4 ++-- 4 files changed, 13 insertions(+), 11 deletions(-) create mode 100644 packages/pluggableWidgets/custom-chart-web/src/hooks/__tests__/stubObjectURL.ts diff --git a/packages/pluggableWidgets/custom-chart-web/jest.config.js b/packages/pluggableWidgets/custom-chart-web/jest.config.js index ae91b01091..73427f83c9 100644 --- a/packages/pluggableWidgets/custom-chart-web/jest.config.js +++ b/packages/pluggableWidgets/custom-chart-web/jest.config.js @@ -1,11 +1,4 @@ -const base = require("@mendix/pluggable-widgets-tools/test-config/jest.config.js"); - module.exports = { - ...base, - testEnvironment: "@happy-dom/jest-environment", - moduleNameMapper: { - ...base.moduleNameMapper, - // Source uses baseUrl-relative imports (e.g. `from "src/controllers/..."`); map them to rootDir. - "^src/(.*)$": "/$1" - } + ...require("@mendix/pluggable-widgets-tools/test-config/jest.config.js"), + testEnvironment: "@happy-dom/jest-environment" }; diff --git a/packages/pluggableWidgets/custom-chart-web/src/hooks/__tests__/stubObjectURL.ts b/packages/pluggableWidgets/custom-chart-web/src/hooks/__tests__/stubObjectURL.ts new file mode 100644 index 0000000000..2eff7f66bc --- /dev/null +++ b/packages/pluggableWidgets/custom-chart-web/src/hooks/__tests__/stubObjectURL.ts @@ -0,0 +1,8 @@ +// The chart controller imports plotly, which calls URL.createObjectURL at load time. +// The pluggable-widgets-tools jest env (jsdom) doesn't implement it. Import this module +// BEFORE anything that pulls in plotly to stub it. +if (typeof window.URL.createObjectURL !== "function") { + window.URL.createObjectURL = () => ""; +} + +export {}; diff --git a/packages/pluggableWidgets/custom-chart-web/src/hooks/__tests__/useCustomChart.spec.ts b/packages/pluggableWidgets/custom-chart-web/src/hooks/__tests__/useCustomChart.spec.ts index 63b3c501d6..e1ecd14ba2 100644 --- a/packages/pluggableWidgets/custom-chart-web/src/hooks/__tests__/useCustomChart.spec.ts +++ b/packages/pluggableWidgets/custom-chart-web/src/hooks/__tests__/useCustomChart.spec.ts @@ -1,3 +1,4 @@ +import "./stubObjectURL"; import { render, renderHook, screen, waitFor } from "@testing-library/react"; import { observer } from "mobx-react-lite"; import { createElement, ReactElement } from "react"; diff --git a/packages/pluggableWidgets/custom-chart-web/src/hooks/useCustomChart.ts b/packages/pluggableWidgets/custom-chart-web/src/hooks/useCustomChart.ts index d3daf5f848..908d4e109f 100644 --- a/packages/pluggableWidgets/custom-chart-web/src/hooks/useCustomChart.ts +++ b/packages/pluggableWidgets/custom-chart-web/src/hooks/useCustomChart.ts @@ -1,12 +1,12 @@ import { Ref, RefCallback, useEffect } from "react"; -import { CustomChartControllerHost } from "src/controllers/CustomChartControllerHost"; -import { mergeRefs } from "src/utils/mergeRefs"; import { PlaygroundData } from "@mendix/shared-charts/main"; import { GateProvider } from "@mendix/widget-plugin-mobx-kit/GateProvider"; import { useConst } from "@mendix/widget-plugin-mobx-kit/react/useConst"; import { useSetup } from "@mendix/widget-plugin-mobx-kit/react/useSetup"; import { CustomChartContainerProps } from "../../typings/CustomChartProps"; +import { CustomChartControllerHost } from "../controllers/CustomChartControllerHost"; import { ControllerProps } from "../controllers/typings"; +import { mergeRefs } from "../utils/mergeRefs"; interface UseCustomChartReturn { playgroundData: PlaygroundData;