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>
This commit is contained in:
John McLear 2026-05-15 10:41:37 +01:00 committed by GitHub
parent 7d537f3deb
commit ab60ed33c1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 3554 additions and 128 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,237 @@
# 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:
```ts
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.