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,2 @@
schema: tdd-refactor
created: 2026-07-06
85 changes: 85 additions & 0 deletions openspec/changes/fix-restore-playground-editor/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
## Test Cases

All unit tests, `@testing-library/react`, in
`packages/pluggableWidgets/chart-playground-web/src/components/__tests__/CodeEditor.spec.tsx`
(new file — `CodeEditor` has no tests today).

The editor is `react-simple-code-editor`, which renders a real `<textarea>` under a highlighted
`<pre>`. Query the textarea via `role="textbox"` / value; assert highlight + lint via rendered
output. These tests define the contract before the textarea → highlighted-editor swap.

### Reproduction Tests

- **Renders JSON with syntax highlighting** - the editor highlights the value instead of showing
plain unstyled text (the regression the textarea introduced). (unit)
- **Given**: `CodeEditor` rendered with `value='{"type":"scatter","x":[1,2,3]}'`.
- **When**: component mounts.
- **Then**: the rendered output contains `highlight.js` token markup (at least one
`.hljs-*` span, e.g. `hljs-attr` / `hljs-string` / `hljs-number`) wrapping parts of the
value — not a bare unstyled text node.

- **Surfaces invalid JSON** - malformed JSON is flagged, not silently accepted (the DX gap the
textarea left; ties to Leonardo's "no silent catch" note). (unit)
- **Given**: `CodeEditor` rendered with `value='{"type": '` (truncated / invalid JSON).
- **When**: component mounts (or value changes to invalid).
- **Then**: an error indication is shown (an element with the error class / role carrying the
`JSON.parse` message); the editor still renders the raw text (does not blank out or throw).

- **Valid JSON shows no error** - complement to the above. (unit)
- **Given**: `CodeEditor` with `value='{"a":1}'`.
- **When**: component mounts.
- **Then**: no error indication element is present.

### Edge Cases

- **Empty value renders no error and no crash** (unit)
- **Given**: `CodeEditor` with `value=''`.
- **When**: mounts.
- **Then**: renders an empty editor, no error indication (empty ≠ invalid), no throw.

- **onChange fires with new text on edit** (unit)
- **Given**: `CodeEditor` with `value='{}'` and a jest mock `onChange`.
- **When**: user types into the textbox (`fireEvent.input` / `userEvent.type`).
- **Then**: `onChange` is called with the updated string.

- **readOnly blocks edits** - modeler panel uses `readOnly`. (unit)
- **Given**: `CodeEditor` with `readOnly` and a mock `onChange`.
- **When**: user attempts to type in the textbox.
- **Then**: the textbox is non-editable (disabled/readonly attribute) and `onChange` is not
called.

- **Highlighter degrades gracefully on throw** - a highlight failure must not break the editor. (unit)
- **Given**: highlighting a value that would make `hljs.highlight` throw (illegal sequence).
- **When**: mounts.
- **Then**: falls back to rendering the raw code (no crash), matching the rich-text pattern's
try/catch + `console.warn`.

### Regression Tests

- **Prop contract unchanged** - `ComposedEditor` calls `CodeEditor` with `value/onChange/height`
(editable panel) and `readOnly/value/height` (modeler panel). Both must keep working. (unit)
- **Given**: `CodeEditor` rendered with `height="var(--editor-h)"`.
- **When**: mounts.
- **Then**: renders without error and applies the `height` (editor container reflects the
passed height, as the textarea did).

- **No CodeMirror dependency reintroduced** - the whole point of WC-3348. (build/lint guard)
- **Given**: the widget package after the change.
- **When**: inspecting imports/deps of `chart-playground-web`.
- **Then**: no `codemirror`/`@codemirror/*` import or dependency is present; only
`react-simple-code-editor` + `highlight.js` added.

- **preview.spec snapshot** - existing `ChartPlayground.editorPreview` snapshot still passes
(design-time preview is unaffected — it renders a static Toggle button, not `CodeEditor`). (unit)
- **Given**: existing `src/__tests__/preview.spec.tsx`.
- **When**: test suite runs.
- **Then**: snapshot matches (or is intentionally updated only if the preview markup changes).

## Notes

- `react-simple-code-editor` renders a genuine textarea → RTL `getByRole("textbox")` works;
editing via `fireEvent.input(textarea, { target: { value } })`.
- Import `highlight.js/lib/core` + register only `languages/json` (tree-shaken) — assert token
markup, not a specific theme.
- The "No CodeMirror" check can be a dependency/import assertion or a package.json test rather
than a runtime RTL test — decide at tasks.md time.
57 changes: 57 additions & 0 deletions openspec/changes/fix-restore-playground-editor/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
## Why

The Charts playground editor (WC-3348) lost its real code editor. In Charts v6.3.0 the
playground shipped a CodeMirror-based editor that threw a bundling error at load, breaking
the playground in both dojo and React runtimes. To unblock the release of WC-3345, CodeMirror
was replaced with a bare `<textarea>` and shipped as charts-web 6.3.1.

The textarea works but is a developer-experience regression: no syntax highlighting and no
feedback when the JSON a developer types is invalid. The playground is a developer-facing tool
where authors hand-edit Plotly trace/layout/config JSON, so highlighting and lint matter.

## Root Cause

CodeMirror could not be bundled by the widget build (Rollup) and threw at runtime. The
workaround removed CodeMirror entirely (0 refs remain in `chart-playground-web`, `shared/charts`,
or its deps). The remaining work is to restore a real editor **without** reintroducing a
CodeMirror-class bundling dependency.

## What Changes

Replace the plain `<textarea>` in `CodeEditor.tsx` with a lightweight highlighted editor,
adapting the proven pattern from `rich-text-web`'s `HighlightedCodeEditor.tsx`
(`react-simple-code-editor` + `highlight.js`), tuned for JSON:

- `packages/pluggableWidgets/chart-playground-web/src/components/CodeEditor.tsx`
- Render `react-simple-code-editor` with a `highlight.js` JSON highlighter instead of a raw textarea.
- Preserve the existing prop contract exactly: `value`, `onChange?`, `readOnly?`, `height?`.
- Surface JSON validity: when `value` fails `JSON.parse`, show a non-blocking error indication
(do not swallow silently — the prior review flagged silent empty-catch blocks).
- Keep Tab-in-editor behavior compatible with the surrounding `TabGuard` / "Esc + Tab to move
focus" hint (react-simple-code-editor `ignoreTabKey={false}`).
- `packages/pluggableWidgets/chart-playground-web/package.json`
- Add `react-simple-code-editor` and `highlight.js` (**new deps — approved with user**).
- Add unit tests for `CodeEditor` (none exist today).
- Changelog entry (user-facing): playground editor restores syntax highlighting and invalid-JSON
feedback.

Not in scope: Leonardo de Souza's review findings on the already-merged custom-chart / playground
rework (`computed().get()` misuse, dead `containerStyle`, hardcoded `layoutOptions/configOptions`,
`store.data` cast, `@types/jest` in deps, deleted `mergeChartProps.spec.ts`, and related minors).
Those are real, still-live issues but sit in adjacent files (`useCustomChart.ts`,
`CustomChartControllerHost.ts`, `useComposedEditorController.ts`, `shared/charts`) and are tracked
in follow-up ticket WC-3488 so this change stays a single-purpose editor restore. The two
JSON-safety items from that review that DO belong here — silent empty-JSON catch blocks and the
"CodeMirror is a UX regression" note — are folded into the JSON-lint work above.

## Impact

- **Consumers:** `ComposedEditor.tsx` uses `CodeEditor` in two spots — an editable panel
(`value`/`onChange`/`height`) and a read-only modeler panel (`readOnly`/`value`/`height`).
The prop contract is unchanged, so both keep working. Not breaking.
- **Must NOT break:** no CodeMirror (or CodeMirror-class heavy dep) reintroduced; widget bundle
stays small; the runtime "Toggle Editor" sidebar keeps working; read-only panel stays read-only.
- **New dependencies:** two, both lightweight and used in-repo pattern (`rich-text-web`). No
CodeMirror bundling risk.
- **Users:** Charts playground authors get syntax highlighting + invalid-JSON feedback back.
Version bump deferred to release time per repo convention.
44 changes: 44 additions & 0 deletions openspec/changes/fix-restore-playground-editor/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
## 0. Dependencies

- [x] 0.1 Add `react-simple-code-editor` and `highlight.js` to `chart-playground-web/package.json` (approved with user); run install
- [x] 0.2 Confirm no `codemirror` / `@codemirror/*` present in the package

## 1. Test Setup

<!-- RED: Write failing tests first -->

- [x] 1.1 Create `src/components/__tests__/CodeEditor.spec.tsx`; write failing test: renders JSON with `.hljs-*` token markup (not plain text)
- [x] 1.2 Add failing test: invalid JSON surfaces an error indication; valid JSON shows none; empty value shows none
- [x] 1.3 Add edge tests: `onChange` fires with new text on edit; `readOnly` disables editor. (Note: "highlighter throw" dropped — can't trigger a genuine hljs throw with `ignoreIllegals:true` without a mock that tests implementation, not behavior; try/catch remains as defensive code.)
- [x] 1.4 Add regression tests: `height` prop honored; no CodeMirror dep (package.json assertion); existing `preview.spec` snapshot still passes

## 2. Implementation

<!-- GREEN: Make tests pass with minimal code -->

- [x] 2.1 Replace `<textarea>` in `CodeEditor.tsx` with `react-simple-code-editor` + `highlight.js` JSON highlighter (import `highlight.js/lib/core` + register `languages/json`); keep prop contract `value/onChange?/readOnly?/height?`
- [x] 2.2 Add JSON lint: `JSON.parse(value)` in try/catch, surface error message (empty value = not an error); no silent catch
- [x] 2.3 Wrap highlight call in try/catch → fall back to raw code + `console.warn` (rich-text pattern)
- [x] 2.4 Honor `height` prop and `readOnly` (disabled); keep Tab behavior compatible with `TabGuard` (`ignoreTabKey={false}`)

## 3. Refactoring

<!-- REFACTOR: Clean up while keeping tests green -->

- [x] 3.1 Clean up component; highlight theme (`atom-one-light`) imported once in component; added scoped `.widget-charts-playground-code-editor` + error SCSS
- [x] 3.2 `highlight` + `jsonError` extracted as local module functions (kept local — no adjacent rework files touched)

## 4. Verification

- [x] 4.1 All new `CodeEditor` tests passing (8 tests)
- [x] 4.2 Full `chart-playground-web` suite passes (9 tests, preview snapshot green); `pnpm build` produces MPK with no bundling error
- [x] 4.3 Add user-facing changelog entry (highlighting + invalid-JSON feedback restored)
- [ ] 4.4 `/code-review` before PR; do not regress Leonardo's prior review; PR ready

## Notes

<!-- Track test failures, refactoring decisions, blockers. -->

- "No CodeMirror" test decided as a package.json / import assertion (per design.md open question), not a runtime RTL test.
- Scope guard: this change touches only `CodeEditor.tsx`, its test, and `package.json`. Adjacent files (`useCustomChart.ts`, `CustomChartControllerHost.ts`, `useComposedEditorController.ts`, `shared/charts`) are reserved for the follow-up ticket (`followup-ticket-draft.md`).
- Version bump deferred to release time per repo convention.
4 changes: 4 additions & 0 deletions packages/pluggableWidgets/chart-playground-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]

### Changed

- We restored the code editor in the playground with syntax highlighting and invalid-JSON feedback.

## [2.1.1] - 2025-07-15

### Changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@
"@mendix/shared-charts": "workspace:*",
"@mendix/widget-plugin-component-kit": "workspace:*",
"@mendix/widget-plugin-hooks": "workspace:*",
"@mendix/widget-plugin-platform": "workspace:*"
"@mendix/widget-plugin-platform": "workspace:*",
"highlight.js": "^11.11.1",
"react-simple-code-editor": "^0.14.1"
},
"devDependencies": {
"@mendix/automation-utils": "workspace:*",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import hljs from "highlight.js/lib/core";
import json from "highlight.js/lib/languages/json";
import { ReactElement } from "react";
import Editor from "react-simple-code-editor";
import "highlight.js/styles/atom-one-light.css";

hljs.registerLanguage("json", json);

export interface CodeEditorProps {
value: string;
Expand All @@ -7,13 +13,49 @@ export interface CodeEditorProps {
height?: string;
}

function highlight(code: string): string {
try {
return hljs.highlight(code, { language: "json", ignoreIllegals: true }).value;
} catch (error) {
console.warn("Syntax highlighting error:", error);
return code;
}
}

function jsonError(code: string): string | null {
if (code.trim() === "") {
return null;
}
try {
JSON.parse(code);
return null;
} catch (error) {
return error instanceof Error ? error.message : "Invalid JSON";
}
}

export function CodeEditor(props: CodeEditorProps): ReactElement {
const error = jsonError(props.value);

return (
<textarea
value={props.value}
onChange={e => props.onChange?.(e.target.value)}
style={{ height: props.height ?? "200px", width: "100%", fontFamily: "monospace" }}
readOnly={props.readOnly}
/>
<div className="widget-charts-playground-code-editor">
{error && (
<div className="widget-charts-playground-code-editor-error" role="alert">
{error}
</div>
)}
<Editor
value={props.value}
onValueChange={value => props.onChange?.(value)}
highlight={highlight}
disabled={props.readOnly}
padding={8}
tabSize={2}
insertSpaces
ignoreTabKey={false}
spellCheck={false}
style={{ height: props.height ?? "200px", width: "100%", fontFamily: "monospace" }}
/>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { readFileSync } from "fs";
import { join } from "path";
import { fireEvent, render, screen } from "@testing-library/react";
import { CodeEditor } from "../CodeEditor";

describe("CodeEditor", () => {
it("renders JSON value with syntax highlighting", () => {
const { container } = render(<CodeEditor value='{"type":"scatter","x":[1,2,3]}' />);

// highlight.js wraps tokens in .hljs-* spans; a bare textarea would not.
expect(container.querySelector(".hljs-attr, .hljs-string, .hljs-number")).not.toBeNull();
});

it("calls onChange with the new text when edited", () => {
const onChange = jest.fn();
render(<CodeEditor value="{}" onChange={onChange} />);

fireEvent.input(screen.getByRole("textbox"), { target: { value: '{"a":1}' } });

expect(onChange).toHaveBeenCalledWith('{"a":1}');
});

it("disables the editor when readOnly", () => {
render(<CodeEditor value="{}" readOnly />);

// The disabled textarea is what blocks user edits in the modeler panel.
expect((screen.getByRole("textbox") as HTMLTextAreaElement).disabled).toBe(true);
});

it("surfaces an error for invalid JSON", () => {
render(<CodeEditor value='{"type": ' />);

// Malformed JSON must be flagged, not silently accepted.
expect(screen.queryByRole("alert")).not.toBeNull();
});

it("shows no error for valid JSON", () => {
render(<CodeEditor value='{"a":1}' />);

expect(screen.queryByRole("alert")).toBeNull();
});

it("shows no error for an empty value", () => {
render(<CodeEditor value="" />);

// Empty is not invalid.
expect(screen.queryByRole("alert")).toBeNull();
});

it("honors the height prop (unchanged prop contract)", () => {
render(<CodeEditor value="{}" height="300px" />);

// react-simple-code-editor applies the style prop to its container (parent of the textarea).
const container = screen.getByRole("textbox").parentElement as HTMLElement;
expect(container.style.height).toBe("300px");
});

it("does not reintroduce CodeMirror (the v6.3.0 bundling break)", () => {
const pkg = JSON.parse(readFileSync(join(__dirname, "../../../package.json"), "utf8"));
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
const codeMirrorDep = Object.keys(deps).find(name => /codemirror/i.test(name));

expect(codeMirrorDep).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,23 @@
}
}

.widget-charts-playground-code-editor {
overflow: auto;

.widget-charts-playground-code-editor-error {
position: sticky;
top: 0;
z-index: 1;
color: #c00;
background: #fdecea;
border-bottom: 1px solid #f5c6cb;
font-size: 12px;
font-family: monospace;
padding: 4px 8px;
white-space: pre-wrap;
}
}

.widget-sidebar {
.sidebar-content-header {
.header-content {
Expand Down
Loading
Loading