* 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>
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.jsonas a tree of typed widgets, one per key. - Show each key's leading
/* */or//comment as inline help text. - Round-trip back to
settings.jsonpreserving 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.jsonare the help text; we don't ship a parallel schema. - Search / filter / collapse-all controls.
Constraints
settings.jsonis 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
settingsSocketand accepts a full-textsaveSettingsevent 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)→ ASTNodewith{ type, offset, length, children, value }.getNodePath(node)→(string|number)[]JSON pointer-ish path.modify(text, path, value, options)→ array ofEdit { 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 beforenode.offset, with at most one blank line allowed between the comment block and the key. Rendered as the help text underKeyLabel. - "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
settingsSocketemits the raw file text. Store setsstate.settings = text(this already happens today).FormViewcallsparseTree(state.settings). On any thrownSyntaxErrorit rendersParseErrorBannerand a "Switch to raw to fix" button instead of the tree.- Each leaf widget receives
(node, path)and anonChange(value)callback.onChangeruns:const edits = modify(state.settings, path, value, { formattingOptions: { tabSize: 2, insertSpaces: true } }); useStore.getState().setSettings(applyEdits(state.settings, edits)); - Re-render uses the new text. Successive edits stack naturally.
- Save:
isJSONClean(text)→socket.emit('saveSettings', text). - 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.settingsas it stands. The existingisJSONCleanvalidation 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=tablistwith arrow-key navigation. - Each form group has an
aria-labelledbypointing at its key label. EnvPillisrole=notewitharia-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 adbTyperow'saria-describedbyresolves to text containing the leading comment insettings.json.editing string preserves comments— changetitlevia the form input, save, reload; assert raw text contains both the new title and the original comment block above it.boolean toggle round-trips— togglerequireAuthentication, save, reload; assert raw text showstrue/falseliteral and surrounding comments unchanged.env pill is read-only— locate the SSOissuerrow, 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, assertParseErrorBanner, assert "Switch to raw" button works.- Existing
comments preserved after save round-trip,validate button toasts,restart worksspecs 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.