etherpad-lite/docs/superpowers/specs/2026-05-09-admin-settings-parsed-view-design.md
John McLear ab60ed33c1
admin: parsed JSONC settings editor (takes over #7666, closes #7603) (#7709)
* admin: parsed JSONC settings editor with form view (#7603, #7666)

Takes over #7666 / closes #7603. Squashed rebase of 32 commits onto
current develop (which has since absorbed admin design rework #7716
and admin i18n fixes #7736 — granular history preserved on
takeover/7666-admin-settings-editor before this squash, see PR
description for the original commit log).

Highlights:

- New parsed JSONC settings editor under
  admin/src/components/settings/ — FormView, ModeToggle,
  ParseErrorBanner, JsoncNode dispatcher, leaf widgets (string,
  number, bool, null, env pill), and pure helpers (comments,
  envPill, jsoncEdit, labels, templateComments).
- ${VAR:default} env placeholders render as editable inline inputs
  that round-trip through the raw textarea (env-pill spec asserts
  this; docker-template spec protects against form-view degradation
  on env-heavy configs).
- Schema-driven help text sourced from settings.json.template,
  inlined at build time via vite (drops the runtime fs.allow
  widening that earlier iterations needed).
- ModeToggle switches between FormView and raw textarea on
  /admin/settings; parse errors surface in a non-blocking banner.
- jsonc-parser dep added; pure helpers wrap modify() for stable
  edits that preserve key order and trailing comments (stops at
  end-of-line so trailing-comment trains don't bleed into the next
  property).
- i18n keys added for form mode, parse error, env pill,
  default_label, and input aria.
- Playwright specs cover form view, env pill, parse error banner,
  raw round-trip, and form-mode regressions called out in #7666
  review (stable React keys from AST offsets, save-toast on server
  ack only, NumberInput draft sync, parse-error flash during
  initial load, .settings CSS conflict resolution, focus retention
  via rAF, IconButton type defaulting to 'button').

Co-authored-by: Ayushi Gupta <ayushigupta36881@gmail.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(admin): stabilise React keys to prevent focus loss in settings editor

Switch React keys in JsoncNode and FormView from byte offsets to stable
JSON paths (`getNodePath(...).join('.')`). Byte offsets shift on every
keystroke because the edit changes the surrounding character count,
which forces React to remount inputs and lose focus mid-typing.

- Object children key on the property path.
- Array elements key on their JSON path index.
- Add a Playwright regression test pinning focus stability for array
  element edits.

Co-authored-by: John McLear <john@mclear.co.uk>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(admin): stop trailing /* */ comments from bleeding into next key's label (#7740)

In the parsed settings form view, each key's row was rendering its label
as the previous keys' source lines concatenated together. Root cause:
findLeading() in admin/src/components/settings/comments.ts treated any
line ending in `*/` as a comment continuation, so a JSON line like
  "altF9": true, /* focus on the File Menu and/or editbar */
was absorbed into the next sibling's leading comment block, and then
each subsequent key picked up an even longer accumulation.

- Tighten findLeading's isComment check to only match structural comment
  lines (`//`, `/*`, or a `*`-prefixed continuation/close), so JSON code
  with a trailing block comment no longer matches.
- Surface leading and trailing comments separately from the template
  map. Leaf rows with only a trailing same-line comment now render the
  humanized key as the row label and the comment as the help text below
  the control, matching settings.json.template's convention (and #7740's
  recommendation that "helper text should be below").
- Add unit tests pinning the regression and the JSDoc/`//` leading
  styles, plus a Playwright spec that asserts altC's row carries a
  clean label and the "focus on the Chat window" help text.

Closes #7740.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Ayushi Gupta <ayushigupta36881@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:41:37 +01:00

9.8 KiB

Admin /settings parsed view — design

Date: 2026-05-09 Status: draft, awaiting review Related: closes #7603, supersedes parts of #7666 / #7709

Problem

/admin/settings renders settings.json as a single textarea. Admins asked (#7603) for a parsed view that surfaces each key with the JSONC comment that documents it, so the file stops looking like an opaque blob.

The current PR (#7709) only restyles the textarea and adds Validate / Prettify / Restart buttons. The actual "parsed per-key with inline comments" feature is still missing.

Goals

  • Render settings.json as a tree of typed widgets, one per key.
  • Show each key's leading /* */ or // comment as inline help text.
  • Round-trip back to settings.json preserving comments, key order, whitespace, and ${ENV:default} placeholders byte-identically for keys the admin did not edit.
  • Keep a raw textarea fallback for power users and structural edits.

Non-goals (deferred)

  • Adding or removing keys from the form view.
  • Editing ${ENV:default} placeholders inline. They render as read-only pills; structural changes go through raw mode.
  • Curated, schema-driven help text. Comments in settings.json are the help text; we don't ship a parallel schema.
  • Search / filter / collapse-all controls.

Constraints

  • settings.json is JSONC: /* */ and // comments, trailing commas tolerated by the loader.
  • Values can contain ${VAR:default} placeholders that the server resolves at boot. The form must not rewrite or normalise these.
  • Server already ships the raw file text down settingsSocket and accepts a full-text saveSettings event back. We don't change the server contract.

Architecture

All parsing and editing run in the browser. The server-side path is unchanged.

load     server reads settings.json --raw text--> settingsSocket --> store.settings
edit     widget --modify(text, path, value)--> new text --> store.settings
save     store.settings --emit('saveSettings', text)--> server writes file

The store keeps a single source of truth: the file text. The AST is re-parsed from the text on each render of the form view; we don't keep a separate model and try to keep them in sync.

Library

Add jsonc-parser (MS, MIT) to admin/. We use four entry points:

  • parseTree(text) → AST Node with { type, offset, length, children, value }.
  • getNodePath(node)(string|number)[] JSON pointer-ish path.
  • modify(text, path, value, options) → array of Edit { offset, length, content }.
  • applyEdits(text, edits) → new text.

Comments are recoverable from the source text using node.offset; parseTree skips them but their byte ranges are deterministic.

Components

SettingsPage
├── ModeToggle                Form | Raw (segmented control)
│
├── FormView                  visible when mode === 'form'
│   ├── ParseErrorBanner      shown when parseTree fails
│   └── JsoncNode (recursive) one per AST node
│       ├── CommentLabel      leading /* */ or // text
│       ├── KeyLabel
│       ├── ValueWidget       dispatches on node.type
│       │   ├── StringInput
│       │   ├── NumberInput
│       │   ├── BooleanToggle
│       │   ├── NullChip
│       │   ├── EnvPill       read-only, when raw matches ${VAR:default}
│       │   ├── ObjectGroup   collapsible, recurses
│       │   └── ArrayGroup    collapsible, recurses
│       └── TrailingCommentBadge   `// inline` after the value
│
├── RawView                   visible when mode === 'raw'
│   └── <textarea className="settings"> (the existing editor)
│
└── ButtonBar
    ├── Save
    ├── Validate              dry-run JSON parse, toast result
    └── Restart               unchanged, keeps data-testid

exposeExperimental (Prettify) stays gated off, as in #7709.

Comment binding

For each property node "key": value:

  • "Leading comment" = the longest run of /* */ and // lines whose byte range ends at the line break immediately before node.offset, with at most one blank line allowed between the comment block and the key. Rendered as the help text under KeyLabel.
  • "Trailing comment" = a single // or /* */ on the same line as the value, after the trailing comma if any. Rendered as a small badge with tooltip.
  • Comments not adjacent to a key (file header, orphan blocks) render in raw mode only.

Env placeholder detection

A string node whose raw text slice (text.slice(offset, offset+length)) matches /^"\$\{[^}]+\}"$/ renders as EnvPill instead of StringInput. The pill shows the variable name and (if present) the default after :. It is read-only; tooltip explains "edit in raw mode".

Data flow

  1. settingsSocket emits the raw file text. Store sets state.settings = text (this already happens today).
  2. FormView calls parseTree(state.settings). On any thrown SyntaxError it renders ParseErrorBanner and a "Switch to raw to fix" button instead of the tree.
  3. Each leaf widget receives (node, path) and an onChange(value) callback. onChange runs:
    const edits = modify(state.settings, path, value, {
      formattingOptions: { tabSize: 2, insertSpaces: true }
    });
    useStore.getState().setSettings(applyEdits(state.settings, edits));
    
  4. Re-render uses the new text. Successive edits stack naturally.
  5. Save: isJSONClean(text)socket.emit('saveSettings', text).
  6. Mode toggle does not touch state.settings; both views share it.

Why no AST round-trip

We tried (in design) the alternative "parse → model → reserialize" and rejected it: even with jsonc-parser's Edit API at the bottom, an intermediate model loses information about whitespace runs and comment whitespace prefixes. Patching the original text with modify() is the only path that gives byte-identical output for untouched regions, which is the explicit requirement (admins watch settings.json in git).

Save semantics

  • Form mode: each widget edit produces a modify() patch against the current text. Untouched bytes — including comments, ordering, trailing commas, env placeholders — are preserved.
  • Raw mode: textarea writes the whole text back wholesale. New keys and structural reshuffles only happen here.
  • The save button itself is mode-agnostic: it sends state.settings as it stands. The existing isJSONClean validation gates it.
  • Toggling Form → Raw is always safe: text is unchanged.
  • Toggling Raw → Form may surface a parse error; banner explains and offers to switch back.

Error handling

Failure UX
Server hasn't sent settings yet Spinner (existing behavior preserved)
Socket disconnected during save Failure toast admin_settings.toast.disconnected
Invalid JSON at save time Failure toast admin_settings.toast.json_invalid, save blocked
Invalid JSON when toggling Raw → Form ParseErrorBanner with line/col, "Switch back to Raw" button
modify() returns no edits (no-op) Treat as successful; widget value reflects current text
Number widget non-finite/non-numeric Field-level inline error; file text is not updated until input parses; save uses last-valid value

i18n

All new strings go through react-i18next. New keys:

admin_settings.mode.form            "Form"
admin_settings.mode.raw             "Raw"
admin_settings.parse_error.title    "Cannot parse settings.json"
admin_settings.parse_error.cta      "Switch to raw to edit"
admin_settings.env_pill.tooltip     "Environment variable. Edit in raw mode."
admin_settings.add_key.disabled     "Add new keys in raw mode"

Plus the toast keys already added in #7709.

Accessibility

  • Mode toggle is a role=tablist with arrow-key navigation.
  • Each form group has an aria-labelledby pointing at its key label.
  • EnvPill is role=note with aria-label="environment variable …".
  • Collapsible groups use <details>/<summary> so keyboard and screen-reader behavior is native.

Test plan (Playwright, admin-spec/adminsettings.spec.ts)

Add specs:

  • form view renders comment as help text — assert that a dbType row's aria-describedby resolves to text containing the leading comment in settings.json.
  • editing string preserves comments — change title via the form input, save, reload; assert raw text contains both the new title and the original comment block above it.
  • boolean toggle round-trips — toggle requireAuthentication, save, reload; assert raw text shows true/false literal and surrounding comments unchanged.
  • env pill is read-only — locate the SSO issuer row, assert pill is rendered, assert no <input> accepts text in form mode.
  • raw toggle round-trip is lossless — Form → Raw → Form returns identical bytes when nothing was edited.
  • invalid raw JSON shows banner on toggle to form — paste broken JSON in raw, toggle to form, assert ParseErrorBanner, assert "Switch to raw" button works.
  • Existing comments preserved after save round-trip, validate button toasts, restart works specs continue to pass through the new helper testids.

Out of scope (future PRs)

  • Add-key / delete-key form UI.
  • Env-var inline editing widget.
  • Schema-driven help text overlay.
  • Sectioning / search / filter.

Rollout

Single PR replacing the current SettingsPage.tsx from #7709. The takeover branch (takeover/7666-admin-settings-editor on johnmclear/etherpad-lite) gets new commits on top; #7709 stays open and gets force-updated. No server-side migration.