Skip to content

admin: parsed JSONC settings editor (takes over #7666, closes #7603)#7709

Open
JohnMcLear wants to merge 31 commits intoether:developfrom
JohnMcLear:takeover/7666-admin-settings-editor
Open

admin: parsed JSONC settings editor (takes over #7666, closes #7603)#7709
JohnMcLear wants to merge 31 commits intoether:developfrom
JohnMcLear:takeover/7666-admin-settings-editor

Conversation

@JohnMcLear
Copy link
Copy Markdown
Member

@JohnMcLear JohnMcLear commented May 9, 2026

Replaces the textarea on /admin/settings with a parsed, sectioned form: each top-level object/array is rendered as its own card with a documentation heading, and primitive keys collect into a 'General' card. Labels and help text come from settings.json comments first, falling back to settings.json.template documentation when the live file has none. Round-trip is byte-identical for untouched regions: edits go through jsonc-parser's modify() so comments, key order, whitespace, and ${ENV:default} placeholders all survive.

A "Raw" mode toggle keeps the existing textarea editor behind it for power users and structural edits.

Takes over #7666 (original author AWOL). Closes #7603.

What it looks like

  • General card: title, favicon, skinName, ip, port, etc. Two-column label + control rows with help text below.
  • Database settings, Pad options, SSO, Users, etc.: one card per top-level group, with the comment's first sentence as the section heading and the rest as descriptive copy.
  • Boolean values render as Etherpad-green Radix switches; numbers use a draft-buffer input that won't poison the file with NaN; strings get a normal text input; nulls show as a chip; ${VAR:default} placeholders show as a read-only env pill (raw mode is the escape hatch for editing them).

What's in the diff

  • New dep: jsonc-parser@^3.3.1 in admin/.
  • New components under admin/src/components/settings/:
    • Pure helpers: envPill.ts, comments.ts (extract leading + trailing comments adjacent to a property node), jsoncEdit.ts (wraps modify() + applyEdits), templateComments.ts (build-time path → comment map from settings.json.template), labels.ts (first-sentence-as-label, humanized-key fallback).
    • Widgets: StringInput, NumberInput, BooleanToggle, NullChip, EnvPill.
    • Top-level: JsoncNode dispatcher, FormView (sectioned layout), ParseErrorBanner, ModeToggle.
  • SettingsPage becomes a Form/Raw toggle shell. Save / Validate / Restart wiring unchanged.
  • IconButton (earlier on this branch) forwards ButtonHTMLAttributes so data-testid/aria-* compile and render.
  • Playwright helper switched from .settingsButton.nth(1) to getByTestId('restart-etherpad-button').
  • All new strings go through react-i18next; new keys in src/locales/en.json.
  • vite.config.ts allows reading from the repo root so settings.json.template can be imported with ?raw.

Tests (src/tests/frontend-new/admin-spec/adminsettings.spec.ts)

All 10 specs pass locally (cd src && npx playwright test admin-spec/adminsettings.spec.ts --reporter=line):

  • Existing: settings visible / save round-trip; comments preserved across save; validate toasts success+failure and blocks save on invalid JSON; restart works.
  • New: form view splits comment into label + help text; template fallback supplies a label when the live file has no comment; editing title via the form input round-trips through save preserving structure; requireAuthentication boolean toggle round-trips; ${SSO_ISSUER:…} renders as a read-only env pill (no <input> for that path); breaking raw JSON and toggling to form shows a parse-error banner with a working "Switch to raw" CTA.

Spec / plan

  • Design: docs/superpowers/specs/2026-05-09-admin-settings-parsed-view-design.md
  • Plan: docs/superpowers/plans/2026-05-09-admin-settings-parsed-view.md

Out of scope (follow-ups)

  • Add/remove keys from the form (raw mode is the escape hatch).
  • Inline editing of \${VAR:default} placeholders.
  • Custom (per-section) Save buttons.
  • The pre-existing Are Settings visible… Playwright test wrecks the dev settings.json on each run by writing then "restoring" from in-memory; worth a separate fix to snapshot from disk instead.

Semver

patch — admin UI only, no API or settings-file format changes.

🤖 Generated with Claude Code

AyushiGupta160604 and others added 2 commits May 9, 2026 13:36
…ests

- IconButton: forward ButtonHTMLAttributes so data-testid/aria-* work,
  fixing TS build break and unblocking stable Playwright selectors.
- SettingsPage: move textarea presentation to .settings CSS so the
  :focus outline is no longer overridden, surface validation/save/socket
  errors via i18n toasts (Validate, Prettify, Save), and tag every
  button with data-testid for selector stability.
- Replace positional .settingsButton.nth(1) with getByTestId in
  adminhelper so Save/Test/Prettify/Restart can be reordered safely.
- Add regression specs: comments survive a save round-trip; Validate
  toasts success/failure; invalid JSON blocks Save with a failure toast.
- Switch wiki links to protocol-independent URLs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@qodo-code-review
Copy link
Copy Markdown

ⓘ You've reached your Qodo monthly free-tier limit. Reviews pause until next month — upgrade your plan to continue now, or link your paid account if you already have one.

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

Review Summary by Qodo

Admin settings editor with comment preservation and JSON validation

✨ Enhancement 🧪 Tests

Grey Divider

Walkthroughs

Description
• Replace plain textarea with dark monospaced editor preserving /* */ comments
• Add Validate JSON button with dry-run toasts; Save rejects invalid JSON
• Tab key inserts indentation; focus ring displays on textarea focus
• Stabilize Playwright selectors with data-testid attributes on all buttons
• Add regression tests for comment preservation and JSON validation
• Extend IconButton to forward ButtonHTMLAttributes for proper prop spreading
• Switch wiki links to protocol-independent URLs; add i18n toast strings
Diagram
flowchart LR
  A["Settings Textarea"] -->|"Dark theme + monospace"| B["Editor with Comments"]
  B -->|"Tab key"| C["Indentation Support"]
  B -->|"Focus"| D["Focus Ring"]
  E["Save Button"] -->|"Validate JSON"| F["Accept or Reject"]
  G["Validate Button"] -->|"Dry-run"| H["Toast Success/Failure"]
  I["IconButton"] -->|"Forward HTMLAttributes"| J["data-testid Support"]
  J -->|"Stable Selectors"| K["Playwright Tests"]
Loading

Grey Divider

File Changes

1. admin/src/pages/SettingsPage.tsx ✨ Enhancement +123/-49

Comment-preserving editor with validation and tab support

• Refactored to preserve comments by removing cleanComments() from display path
• Added handleKeyDown to insert tab indentation instead of focus navigation
• Implemented testJSON() for dry-run validation with i18n toasts
• Added prettifyJSON() with confirmation dialog (feature-flagged off)
• Enhanced handleSave() to validate JSON and check socket connection before emit
• Added data-testid attributes to all buttons for stable Playwright selectors
• Switched wiki links to protocol-independent URLs (//github.com/...)
• Integrated useTranslation() hook for i18n toast messages

admin/src/pages/SettingsPage.tsx


2. admin/src/components/IconButton.tsx 🐞 Bug fix +4/-7

Forward ButtonHTMLAttributes for proper prop spreading

• Changed IconButtonProps to extend ButtonHTMLAttributes<HTMLButtonElement> with Omit for
 title and onClick
• Removed individual prop declarations (style, className, disabled)
• Updated component to spread ...rest onto <button> element
• Enables data-testid, aria-*, and other HTML attributes to render correctly

admin/src/components/IconButton.tsx


3. admin/src/App.css ✨ Enhancement +34/-0

Dark theme editor styling with focus ring

• Added .settings class with dark theme (#1e1e1e background, #d4d4d4 text)
• Configured monospaced font stack and 500px height with vertical resize
• Added .settings:focus with blue outline (#007acc) for focus ring visibility
• Created .settings-button-bar flexbox layout with 10px gap
• Added .settings-links flexbox for wiki link layout with top border separator

admin/src/App.css


View more (3)
4. src/tests/frontend-new/admin-spec/adminsettings.spec.ts 🧪 Tests +45/-0

Regression tests for comments and JSON validation

• Added test preserves /* */ comments after save round-trip to verify comments survive save/reload
 cycle
• Added test validate button toasts success on valid JSON and failure on invalid covering Validate
 button and Save rejection of invalid JSON
• Tests use getByTestId() for stable button selectors
• Validates both success and failure toast visibility with 5-second timeout

src/tests/frontend-new/admin-spec/adminsettings.spec.ts


5. src/tests/frontend-new/helper/adminhelper.ts 🐞 Bug fix +2/-4

Stabilize Playwright selectors with data-testid

• Replaced page.locator('.settings-button-bar').locator('button').first() with
 page.getByTestId('save-settings-button')
• Replaced page.locator('.settings-button-bar').locator('.settingsButton').nth(1) with
 page.getByTestId('restart-etherpad-button')
• Eliminates brittle positional selectors that break when buttons are reordered

src/tests/frontend-new/helper/adminhelper.ts


6. src/locales/en.json 📝 Documentation +9/-0

i18n strings for validation and toast messages

• Added admin_settings.current_test.value for Validate JSON button label
• Added admin_settings.current_prettify.value for Prettify JSON button label
• Added toast message keys: saved, json_invalid, disconnected, validation_ok,
 validation_failed, prettify_failed
• Added admin_settings.prettify_confirm confirmation dialog message

src/locales/en.json


Grey Divider

Qodo Logo

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

qodo-free-for-open-source-projects Bot commented May 9, 2026

Code Review by Qodo

🐞 Bugs (6) 📘 Rule violations (3) 📎 Requirement gaps (0)

Grey Divider


Action required

1. Form editor enabled by default 📘 Rule violation ⚙ Maintainability
Description
The new parsed form editor is shipped as the default mode (useState('form')) and there is no
feature flag to disable it, changing the existing /admin/settings behavior by default. This
violates the requirement that new features must be gated and disabled by default to preserve prior
behavior unless explicitly enabled.
Code

admin/src/pages/SettingsPage.tsx[R17-67]

+  const [mode, setMode] = useState<Mode>('form');
+  const [exposeExperimental] = useState(false);
+
+  const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
+    if (e.key !== 'Tab') return;
+    e.preventDefault();
+    const target = e.currentTarget;
+    const { selectionStart, selectionEnd, value } = target;
+    const next = value.substring(0, selectionStart) + TAB_INDENT + value.substring(selectionEnd);
+    useStore.getState().setSettings(next);
+    requestAnimationFrame(() => {
+      target.selectionStart = target.selectionEnd = selectionStart + TAB_INDENT.length;
+    });
+  };
+
+  const showToast = (titleKey: string, success: boolean) => {
+    useStore.getState().setToastState({ open: true, title: t(titleKey), success });
+  };
+
+  const testJSON = () => {
+    if (isJSONClean(settings)) showToast('admin_settings.toast.validation_ok', true);
+    else showToast('admin_settings.toast.validation_failed', false);
+  };
+
+  const prettifyJSON = () => {
+    try {
+      const obj = JSON.parse(cleanComments(settings) ?? '');
+      if (window.confirm(t('admin_settings.prettify_confirm'))) {
+        useStore.getState().setSettings(JSON.stringify(obj, null, 2));
+      }
+    } catch {
+      showToast('admin_settings.toast.prettify_failed', false);
+    }
+  };
+
+  const handleSave = () => {
+    if (!isJSONClean(settings)) return showToast('admin_settings.toast.json_invalid', false);
+    if (!settingsSocket?.connected) return showToast('admin_settings.toast.disconnected', false);
+    settingsSocket.emit('saveSettings', settings);
+    showToast('admin_settings.toast.saved', true);
+  };
+
+  return (
+    <div className="settings-page">
+      <h1><Trans i18nKey="admin_settings.current" /></h1>
+
+      <ModeToggle mode={mode} onChange={setMode} />
+
+      {mode === 'form'
+        ? <FormView onSwitchToRaw={() => setMode('raw')} />
+        : (
Evidence
PR Compliance ID 8 requires new features to be behind a feature flag and disabled by default without
changing existing behavior. The updated SettingsPage initializes mode to form and renders
FormView by default, with only a UI toggle to reach the old raw textarea (no feature flag
present).

admin/src/pages/SettingsPage.tsx[17-67]
Best Practice: Repository guidelines

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new parsed form settings editor is enabled by default and is not controlled by a feature flag, which changes existing behavior when the PR is deployed.
## Issue Context
Compliance requires new features to be behind a feature flag and disabled by default so existing behavior remains unchanged unless explicitly enabled.
## Fix Focus Areas
- admin/src/pages/SettingsPage.tsx[17-77]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Index keys break widget state 🐞 Bug ≡ Correctness
Description
FormView and JsoncNode use array indices as React keys, so inserting/removing/reordering properties
(possible via Raw mode) can cause React to reuse the wrong component instances for different
settings paths. This can mis-associate local widget state (notably NumberInput’s draft/invalid
state) with the wrong setting.
Code

admin/src/components/settings/FormView.tsx[R88-99]

+      {generalProps.length > 0 && (
+        <Section title="General">
+          {generalProps.map((prop, i) => (
+            <JsoncNode
+              key={i}
+              node={prop.children![1]}
+              property={prop}
+              text={text}
+              onEdit={onEdit}
+            />
+          ))}
+        </Section>
Evidence
Both the top-level rendering and recursive rendering use key={i}. Some widgets (NumberInput) hold
local state, so key reuse across reorders can show stale state for a different JSON path after
structural edits in raw mode.

admin/src/components/settings/FormView.tsx[88-115]
admin/src/components/settings/JsoncNode.tsx[88-107]
admin/src/components/settings/widgets/NumberInput.tsx[10-33]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Using array indices as React keys can cause incorrect component reuse after list changes, leading to wrong widget state being displayed/edited.
## Issue Context
Raw mode is explicitly an escape hatch for structural edits, so reordering/inserting keys is realistic.
## Fix Focus Areas
- admin/src/components/settings/FormView.tsx[88-115]
- admin/src/components/settings/JsoncNode.tsx[88-107]
## Suggested fix
- In `FormView`, replace `key={i}` with a stable key derived from the property key (e.g., `propertyKey(prop)`) and/or node offset (`prop.offset`).
- In `JsoncNode`, replace `key={i}` with a stable key per child node:
- For object children: use the property key string (from `child.children[0].value`) or `child.offset`.
- For array children: use `child.offset` (or a combination of `offset/length`) rather than index.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Stale text drops edits 🐞 Bug ≡ Correctness
Description
FormView’s onEdit() computes edits against the render-time text, so a subsequent edit fired before
re-render can be applied to stale JSONC and overwrite a previous change. This can silently drop
settings updates when editing multiple fields quickly in form mode.
Code

admin/src/components/settings/FormView.tsx[R34-42]

+export const FormView = ({ onSwitchToRaw }: Props) => {
+  const text = useStore(s => s.settings) ?? '';
+
+  const errors: ParseError[] = [];
+  const tree = parseTree(text, errors, { allowTrailingComma: true });
+
+  const onEdit = (path: JSONPath, value: unknown) => {
+    useStore.getState().setSettings(editJsonc(text, path, value));
+  };
Evidence
editJsonc() produces a full updated settings string from the input text; if text is stale, the
resulting string won’t include any newer store updates and will overwrite them when setSettings() is
called.

admin/src/components/settings/FormView.tsx[34-46]
admin/src/components/settings/jsoncEdit.ts[1-11]
admin/src/store/store.ts[51-56]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`FormView`’s `onEdit` closes over `text` from the render, so edits are generated against a potentially stale string. If two different controls emit edits before `FormView` re-renders, the later edit can overwrite the earlier change.
### Issue Context
Edits are applied by `jsonc-parser.modify()` + `applyEdits()` to the provided input text; this must be the latest store value to preserve all prior edits.
### Fix Focus Areas
- admin/src/components/settings/FormView.tsx[34-42]
### Suggested change
Inside `onEdit`, read the current settings text from the store:
- `const current = useStore.getState().settings ?? ''`
- `setSettings(editJsonc(current, path, value))`
(Optionally keep `text` only for rendering/parsing, not as the source for mutation.)

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (2)
4. Settings still textarea blob ✓ Resolved 📎 Requirement gap ≡ Correctness
Description
The updated /admin/settings UI still renders the settings as a single `` value rather than a
parsed, structured representation with inline comments alongside relevant keys. This fails the
requirement to show settings.json in a structured, readable format with inline comments derived from
JSON comments.
Code

admin/src/pages/SettingsPage.tsx[R66-76]

+  return (
+    <div className="settings-page">
+      <h1><Trans i18nKey="admin_settings.current" /></h1>
+
+      <textarea
+        value={settings}
+        className="settings"
+        spellCheck={false}
+        onKeyDown={handleKeyDown}
+        onChange={v => useStore.getState().setSettings(v.target.value)}
+      />
Evidence
PR Compliance ID 1 requires a structured, parsed display with inline comments, but the changed code
continues to render settings via a plain `` without any structured JSON rendering or inline comment
presentation.

Admin /admin settings.json view should render parsed JSON with preserved formatting and inline comments
admin/src/pages/SettingsPage.tsx[66-76]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`/admin/settings` still uses a plain `<textarea>` and does not render settings as parsed/structured JSON with inline comments.
## Issue Context
Compliance requires a structured representation that preserves formatting and surfaces inline comments derived from JSON comments.
## Fix Focus Areas
- admin/src/pages/SettingsPage.tsx[66-76]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Validate JSON lacks feature flag 📘 Rule violation ☼ Reliability
Description
The new "Validate JSON" functionality is always enabled (new button + new behavior) and is not gated
behind a feature flag disabled by default. This violates the requirement that new features be
feature-flagged and off by default, preserving the prior behavior when disabled.
Code

admin/src/pages/SettingsPage.tsx[R34-93]

+  const testJSON = () => {
+    if (isJSONClean(settings)) {
+      showToast("admin_settings.toast.validation_ok", true);
+    } else {
+      showToast("admin_settings.toast.validation_failed", false);
+    }
+  };
+
+  const prettifyJSON = () => {
+    try {
+      const obj = JSON.parse(cleanComments(settings) ?? "");
+      if (window.confirm(t("admin_settings.prettify_confirm"))) {
+        useStore.getState().setSettings(JSON.stringify(obj, null, 2));
+      }
+    } catch {
+      showToast("admin_settings.toast.prettify_failed", false);
+    }
+  };
+
+  const handleSave = () => {
+    if (!isJSONClean(settings)) {
+      showToast("admin_settings.toast.json_invalid", false);
+      return;
+    }
+    if (!settingsSocket?.connected) {
+      showToast("admin_settings.toast.disconnected", false);
+      return;
+    }
+    settingsSocket.emit('saveSettings', settings);
+    showToast("admin_settings.toast.saved", true);
+  };
+
+  return (
+    <div className="settings-page">
+      <h1><Trans i18nKey="admin_settings.current" /></h1>
+
+      <textarea
+        value={settings}
+        className="settings"
+        spellCheck={false}
+        onKeyDown={handleKeyDown}
+        onChange={v => useStore.getState().setSettings(v.target.value)}
+      />
+
+      <div className="settings-button-bar">
+        <IconButton
+          className="settingsButton"
+          data-testid="save-settings-button"
+          icon={<Save />}
+          title={<Trans i18nKey="admin_settings.current_save.value" />}
+          onClick={handleSave}
+        />
+
+        <IconButton
+          className="settingsButton"
+          data-testid="test-settings-button"
+          icon={<ShieldCheck />}
+          title={<Trans i18nKey="admin_settings.current_test.value" />}
+          onClick={testJSON}
+        />
Evidence
PR Compliance ID 10 requires new features to be behind feature flags and disabled by default. The
diff adds testJSON() and an always-rendered validate button, while only the prettify feature is
gated via exposeExperimental.

admin/src/pages/SettingsPage.tsx[34-40]
admin/src/pages/SettingsPage.tsx[87-93]
Best Practice: Repository guidelines

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new "Validate JSON" feature is enabled unconditionally and is not behind a feature flag that is disabled by default.
## Issue Context
Compliance requires new features to be feature-flagged and off by default, with the old behavior preserved when the flag is off.
## Fix Focus Areas
- admin/src/pages/SettingsPage.tsx[34-40]
- admin/src/pages/SettingsPage.tsx[87-93]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

6. CSS rules conflict order ✓ Resolved 🐞 Bug ⚙ Maintainability
Description
Both App.css and index.css define .settings styling, but main.tsx imports App.tsx (and thus
App.css) before importing index.css, so the later index.css rules will override App.css for
equal-specificity selectors. This can prevent the intended raw editor styling from applying
consistently.
Code

admin/src/App.css[R1-16]

+/* Raw textarea (kept dark to signal "this is code") */
+.settings {
+  font-family: "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
+  font-size: 14px;
+  white-space: pre;
+  overflow-wrap: normal;
+  overflow-x: auto;
+  width: 100%;
+  height: 500px;
+  padding: 15px;
+  background-color: #1e1e1e;
+  color: #d4d4d4;
+  line-height: 1.5;
+  border: 1px solid #333;
+  resize: vertical;
+}
Evidence
App.tsx imports ./App.css, and main.tsx imports App before importing ./index.css. Because
index.css also defines .settings, its later rules win in the cascade.

admin/src/App.tsx[1-4]
admin/src/main.tsx[1-5]
admin/src/App.css[1-16]
admin/src/index.css[293-306]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`App.css` and `index.css` both style `.settings`, but import order makes `index.css` override `App.css`.
## Issue Context
This can silently nullify the new styling without any runtime errors.
## Fix Focus Areas
- admin/src/main.tsx[1-5]
- admin/src/App.tsx[1-4]
- admin/src/App.css[1-16]
- admin/src/index.css[293-306]
## Suggested fix
- Move the `App.css` import to `main.tsx` after `index.css` (and remove it from `App.tsx`), OR
- Remove/rename the `.settings` rules from one file, OR
- Increase selector specificity in `App.css` (less preferred) so it reliably overrides `index.css`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. Protocol-relative wiki links ✓ Resolved 🐞 Bug ⛨ Security
Description
SettingsPage uses protocol-relative GitHub URLs (//github.com/...), which can downgrade to
http:// when the admin UI is served over HTTP and may break or redirect unexpectedly. External
links should be explicit HTTPS.
Code

admin/src/pages/SettingsPage.tsx[R112-118]

+      <div className="settings-links">
+        <a rel="noopener noreferrer" target="_blank" href="//github.com/ether/etherpad/wiki/Example-Production-Settings.JSON">
+          <Trans i18nKey="admin_settings.current_example-prod" />
+        </a>
+        <a rel="noopener noreferrer" target="_blank" href="//github.com/ether/etherpad/wiki/Example-Development-Settings.JSON">
+          <Trans i18nKey="admin_settings.current_example-devel" />
+        </a>
Evidence
The anchor hrefs are protocol-relative, inheriting the page’s scheme instead of forcing HTTPS.

admin/src/pages/SettingsPage.tsx[112-118]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
External links are protocol-relative and can resolve to HTTP.
## Fix Focus Areas
- admin/src/pages/SettingsPage.tsx[112-118]
## Suggested fix
Change both hrefs to explicit `https://github.com/...`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


8. Parse error before load 🐞 Bug ☼ Reliability
Description
FormView treats settings === undefined as an empty string and renders ParseErrorBanner because
parseTree('') yields no tree, even though settings simply haven’t arrived yet. App hides the
loading overlay on socket connect (before the settings event), so users can briefly see a false
parse error on page load.
Code

admin/src/components/settings/FormView.tsx[R34-46]

+export const FormView = ({ onSwitchToRaw }: Props) => {
+  const text = useStore(s => s.settings) ?? '';
+
+  const errors: ParseError[] = [];
+  const tree = parseTree(text, errors, { allowTrailingComma: true });
+
+  const onEdit = (path: JSONPath, value: unknown) => {
+    useStore.getState().setSettings(editJsonc(text, path, value));
+  };
+
+  if (!tree || errors.length > 0) {
+    return <ParseErrorBanner message={formatErrors(errors)} onSwitchToRaw={onSwitchToRaw} />;
+  }
Evidence
The store initializes settings as undefined and only sets it on the socket settings event;
however, the loading state is cleared on connect, creating a window where FormView renders with
'' and shows the error banner.

admin/src/components/settings/FormView.tsx[34-46]
admin/src/store/store.ts[51-56]
admin/src/App.tsx[34-77]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`FormView` renders a parse error when `settings` hasn’t loaded yet (undefined → ''), which is misleading. Because the app hides the loading overlay on socket connect (before receiving settings), the parse error can flash on initial load.
### Issue Context
Empty string is not the same as “not yet loaded”. `parseTree('')` returns no tree and triggers the error banner.
### Fix Focus Areas
- admin/src/components/settings/FormView.tsx[34-46]
- admin/src/App.tsx[48-77]
### Suggested change
Choose one:
1) In `FormView`, read `settings` without `?? ''` and if `settings === undefined` return a loading placeholder (or `null`) instead of ParseErrorBanner.
2) Alternatively (or additionally), in `App.tsx` keep `showLoading` true until the `settings` event is received (move/duplicate `setShowLoading(false)` from `connect` to the `settings` handler).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (5)
9. Number input ignores props 🐞 Bug ≡ Correctness
Description
NumberInput renders from its internal draft state and never syncs it to the value prop after the
first render. When the underlying JSONC is updated (including by this same field via jsonc-parser
canonicalizing the number), the input can display a value that no longer matches the saved text.
Code

admin/src/components/settings/widgets/NumberInput.tsx[R10-30]

+export const NumberInput = ({ value, path, onChange }: Props) => {
+  const [draft, setDraft] = useState(String(value));
+  const [invalid, setInvalid] = useState(false);
+  return (
+    <input
+      type="text"
+      inputMode="decimal"
+      className={'settings-widget settings-widget-number' + (invalid ? ' invalid' : '')}
+      data-testid={`field-${path.join('.')}`}
+      value={draft}
+      onChange={e => {
+        const next = e.target.value;
+        setDraft(next);
+        const parsed = Number(next);
+        if (next.trim() !== '' && Number.isFinite(parsed)) {
+          setInvalid(false);
+          onChange(parsed);
+        } else {
+          setInvalid(true);
+        }
+      }}
Evidence
NumberInput initializes draft from value once but always renders value={draft}; it does not
update draft when value changes, so UI can diverge from the parsed tree value coming from the
source text.

admin/src/components/settings/widgets/NumberInput.tsx[10-32]
admin/src/components/settings/JsoncNode.tsx[97-106]
admin/src/components/settings/FormView.tsx[34-42]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`NumberInput` is effectively uncontrolled relative to its `value` prop after mount (it always displays `draft`). This can cause the displayed value to diverge from the JSONC text / AST value.
### Issue Context
`JsoncNode` passes `value={Number(node.value)}` from the parsed tree, but `NumberInput` never re-syncs `draft` from that prop.
### Fix Focus Areas
- admin/src/components/settings/widgets/NumberInput.tsx[10-32]
### Suggested change
- Add a `useEffect` that updates `draft` when `value` changes (optionally only when the input is not focused).
- After accepting a valid parse and calling `onChange(parsed)`, consider updating `draft` to `String(parsed)` so the UI reflects the canonical numeric representation written back into the text.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


10. aria-describedby points nowhere 🐞 Bug ⚙ Maintainability
Description
JsoncNode always sets aria-describedby to a comment id even when there are no adjacent comments
and CommentLabel returns null. This leaves aria-describedby referencing a non-existent element id.
Code

admin/src/components/settings/JsoncNode.tsx[R35-42]

+  const commentId = `settings-comment-${path.join('.') || 'root'}`;
+
+  const wrap = (label: React.ReactNode, control: React.ReactNode) => (
+    <div className="settings-row" aria-describedby={commentId}>
+      {label && <span className="settings-key">{label}</span>}
+      <span className="settings-value">{control}</span>
+      <CommentLabel leading={leading} trailing={trailing} htmlId={commentId} />
+    </div>
Evidence
CommentLabel returns null when both comment strings are empty, so no element with the referenced id
is rendered, but the parent still includes aria-describedby unconditionally.

admin/src/components/settings/JsoncNode.tsx[35-43]
admin/src/components/settings/CommentLabel.tsx[7-14]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`aria-describedby` should not reference an id that is not present in the DOM.
### Issue Context
`CommentLabel` returns `null` when there are no comments, so the `id` is absent.
### Fix Focus Areas
- admin/src/components/settings/JsoncNode.tsx[35-43]
- admin/src/components/settings/CommentLabel.tsx[7-14]
### Suggested change
In `wrap()`, set `aria-describedby={leading || trailing ? commentId : undefined}` (or render an always-present empty element with that id, but conditional aria-describedby is cleaner).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


11. handleKeyDown missing explanation comment 📘 Rule violation ⚙ Maintainability
Description
The new Tab key handling logic uses selection math and a requestAnimationFrame cursor reset
without any explanatory comment. This makes the behavior non-obvious to future maintainers and
violates the requirement to comment complex/unusual logic.
Code

admin/src/pages/SettingsPage.tsx[R18-28]

+  const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
+    if (e.key !== 'Tab') return;
+    e.preventDefault();
+    const target = e.currentTarget;
+    const { selectionStart, selectionEnd, value } = target;
+    const next = value.substring(0, selectionStart) + TAB_INDENT + value.substring(selectionEnd);
+    useStore.getState().setSettings(next);
+    requestAnimationFrame(() => {
+      target.selectionStart = target.selectionEnd = selectionStart + TAB_INDENT.length;
+    });
+  };
Evidence
PR Compliance ID 6 requires comments for complex or non-obvious code. The added Tab interception and
cursor management logic is non-trivial but is introduced without documenting intent/constraints
(e.g., why requestAnimationFrame is needed).

admin/src/pages/SettingsPage.tsx[18-28]
Best Practice: Repository guidelines

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`handleKeyDown` introduces non-obvious cursor/selection handling (including `requestAnimationFrame`) without explaining why.
## Issue Context
Maintainers need the rationale for the selection update and the animation-frame deferral to avoid regressions when refactoring.
## Fix Focus Areas
- admin/src/pages/SettingsPage.tsx[18-28]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


12. Save toast ignores ack ✓ Resolved 🐞 Bug ☼ Reliability
Description
SettingsPage.handleSave() shows a success toast immediately after emitting saveSettings, without
waiting for any server-confirmed outcome. Because the backend emits saveprogress: 'saved' even if
writeFile() throws, the UI can report a successful save even when nothing was written.
Code

admin/src/pages/SettingsPage.tsx[R53-64]

+  const handleSave = () => {
+    if (!isJSONClean(settings)) {
+      showToast("admin_settings.toast.json_invalid", false);
+      return;
+    }
+    if (!settingsSocket?.connected) {
+      showToast("admin_settings.toast.disconnected", false);
+      return;
+    }
+    settingsSocket.emit('saveSettings', settings);
+    showToast("admin_settings.toast.saved", true);
+  };
Evidence
The frontend unconditionally shows the saved toast right after emit(). The backend catches write
errors but still emits saveprogress with status saved, and the admin client currently only logs
that status, so there is no way for the UI to surface write failures accurately.

admin/src/pages/SettingsPage.tsx[53-64]
src/node/hooks/express/adminsettings.ts[42-50]
admin/src/App.tsx[80-82]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The settings UI shows a "saved" toast immediately after emitting the `saveSettings` socket event. The server currently emits `saveprogress: 'saved'` even if the underlying file write fails, so the UI can incorrectly report success.
## Issue Context
- Frontend currently does not wait for any ack to decide success/failure.
- Backend always emits `saveprogress: 'saved'` regardless of `writeFile()` outcome.
## Fix Focus Areas
- admin/src/pages/SettingsPage.tsx[53-64]
- admin/src/App.tsx[80-82]
- src/node/hooks/express/adminsettings.ts[42-50]
## Proposed fix
1. Backend: on `saveSettings`, emit `saveprogress: 'saved'` only on success; emit a distinct failure status (and optionally an error message) on catch.
2. Frontend: move the success toast to be driven by the `saveprogress` event; show a failure toast on `saveprogress: 'error'` (or equivalent).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


13. Toast wait can false-pass ✓ Resolved 🐞 Bug ☼ Reliability
Description
The Playwright saveSettings() helper waits for .ToastRootSuccess to exist, but the Toast
component assigns the success/failure class based on toastState.success even when the toast is
closed. A previously-closed success toast can therefore satisfy the selector without a new save
completing, making tests flaky and potentially masking failures.
Code

src/tests/frontend-new/helper/adminhelper.ts[R15-17]

+  await page.getByTestId('save-settings-button').click()
await page.waitForSelector('.ToastRootSuccess')
}
Evidence
ToastDialog always renders a Toast.Root and sets its class from toastState.success independent
of toastState.open. On close, only open is toggled (success is not reset), so
.ToastRootSuccess can remain in the DOM while the toast is closed;
waitForSelector('.ToastRootSuccess') does not guarantee a newly-opened toast.

src/tests/frontend-new/helper/adminhelper.ts[14-17]
admin/src/utils/Toast.tsx[5-23]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The Playwright helper waits for `.ToastRootSuccess`, which can exist even when the toast is closed, so the helper can proceed without confirming the new save finished.
## Issue Context
`ToastDialog` applies `.ToastRootSuccess` based on `toastState.success` even when `toastState.open` is false.
## Fix Focus Areas
- src/tests/frontend-new/helper/adminhelper.ts[14-17]
- admin/src/utils/Toast.tsx[5-23]
## Proposed fix
Option A (tests only): Update the helper to wait for an *open* toast, e.g. `await page.waitForSelector('.ToastRootSuccess[data-state="open"]')` (or assert visibility via `expect(...).toBeVisible()`), and optionally wait for the previous toast to close first.
Option B (app behavior): When closing the toast (onOpenChange), also reset `success` (and/or use a monotonically increasing toast id) so old classes don't persist when closed.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Advisory comments

14. Hard-coded UI strings 🐞 Bug ⚙ Maintainability
Description
Several new user-visible strings are hard-coded (e.g., "General", parse error detail messages, and
"Editor mode") rather than going through i18next, causing untranslated UI in non-English locales.
This contradicts the surrounding pattern where new strings use translation keys.
Code

admin/src/components/settings/FormView.tsx[R14-30]

+const ParseErrorMessage: Record<number, string> = {
+  1: 'Invalid symbol',
+  2: 'Invalid number format',
+  3: 'Property name expected',
+  4: 'Value expected',
+  5: 'Colon expected',
+  6: 'Comma expected',
+  7: 'Closing brace expected',
+  8: 'Closing bracket expected',
+  9: 'End of file expected',
+  16: 'Unexpected end of comment',
+  17: 'Unexpected end of string',
+  18: 'Unexpected end of number',
+  19: 'Invalid unicode',
+  20: 'Invalid escape character',
+  21: 'Invalid character',
+};
Evidence
FormView contains hard-coded section title and parse error messages, and ModeToggle contains a
hard-coded aria-label, while the rest of the page uses i18n keys via `/t()`.

admin/src/components/settings/FormView.tsx[14-30]
admin/src/components/settings/FormView.tsx[86-91]
admin/src/components/settings/ModeToggle.tsx[10-12]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
New visible strings are hard-coded and won’t be translated.
## Fix Focus Areas
- admin/src/components/settings/FormView.tsx[14-30]
- admin/src/components/settings/FormView.tsx[86-91]
- admin/src/components/settings/ModeToggle.tsx[10-12]
## Suggested fix
- Replace hard-coded strings with `<Trans i18nKey=.../>` or `t(...)` and add the keys to the locale files.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


15. IconButton missing type ✓ Resolved 🐞 Bug ≡ Correctness
Description
IconButton renders a ` without a default type, which defaults to submit inside a `. Reusing
IconButton within a form can therefore trigger unintended submissions/navigation.
Code

admin/src/components/IconButton.tsx[R9-13]

+export const IconButton:FC<IconButtonProps> = ({icon, className, onClick, title, ...rest})=>{
+  return <button {...rest} onClick={onClick} className={"icon-button "+ (className ?? "")}>
{icon}
<span>{title}</span>
</button>
Evidence
The shared IconButton component renders a raw `` element and is used across multiple admin pages.
Without an explicit type, HTML defaults to submit in forms, which can cause unexpected submit
behavior when this component is used in dialog/forms later.

admin/src/components/IconButton.tsx[1-13]
admin/src/pages/PadPage.tsx[185-196]
Best Practice: HTML button default type

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
IconButton does not set a safe default `type`, so it can submit forms unexpectedly.
## Issue Context
IconButton is a shared component used across admin pages. Adding a default `type="button"` prevents accidental submits while still allowing callers to override to `submit` when desired.
## Fix Focus Areas
- admin/src/components/IconButton.tsx[9-13]
## Proposed fix
Set a default type while still allowing override, for example:
- Destructure `type` with default: `({type = 'button', ...rest})` and render `<button type={type} ...>`
- Or render `<button type={rest.type ?? 'button'} ...>` and ensure spread order does not overwrite it unintentionally.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment thread admin/src/pages/SettingsPage.tsx Outdated
Comment thread admin/src/pages/SettingsPage.tsx
JohnMcLear and others added 12 commits May 9, 2026 13:54
Closes the design phase of ether#7603. Implementation will land in a
follow-up commit on this same branch.

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

Five new specs verifying: leading comment renders as help text; string
round-trips through save (compact-JSON spacing preserved); boolean toggle
round-trips; env placeholder renders as read-only pill with no input;
broken raw JSON shows parse-error banner with switch-to-raw CTA.

Also fix existing specs that regressed when form mode became the default
(Tasks 1-9): add waitForSelector('[data-testid="settings-form-view"]')
before switching to raw, and fix adminhelper.restartEtherpad to not
wait for the raw textarea which is hidden in form mode.

CSS bugfix in App.css/index.css: .settings-mode-toggle gained
flex-shrink:0 and .settings-page changed from height:100% to
min-height:100% to prevent flex layout from collapsing the mode toggle
to 2px when the settings tree is taller than the viewport.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@JohnMcLear
Copy link
Copy Markdown
Member Author

/review

@qodo-code-review
Copy link
Copy Markdown

ⓘ You've reached your Qodo monthly free-tier limit. Reviews pause until next month — upgrade your plan to continue now, or link your paid account if you already have one.

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

qodo-free-for-open-source-projects Bot commented May 9, 2026

Persistent review updated to latest commit 4d61291

@JohnMcLear JohnMcLear changed the title admin: improve settings editor UX, restore comments, address review (takes over #7666) admin: parsed JSONC settings editor (takes over #7666, closes #7603) May 9, 2026
Comment on lines +34 to +42
export const FormView = ({ onSwitchToRaw }: Props) => {
const text = useStore(s => s.settings) ?? '';

const errors: ParseError[] = [];
const tree = parseTree(text, errors, { allowTrailingComma: true });

const onEdit = (path: JSONPath, value: unknown) => {
useStore.getState().setSettings(editJsonc(text, path, value));
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Stale text drops edits 🐞 Bug ≡ Correctness

FormView’s onEdit() computes edits against the render-time text, so a subsequent edit fired before
re-render can be applied to stale JSONC and overwrite a previous change. This can silently drop
settings updates when editing multiple fields quickly in form mode.
Agent Prompt
### Issue description
`FormView`’s `onEdit` closes over `text` from the render, so edits are generated against a potentially stale string. If two different controls emit edits before `FormView` re-renders, the later edit can overwrite the earlier change.

### Issue Context
Edits are applied by `jsonc-parser.modify()` + `applyEdits()` to the provided input text; this must be the latest store value to preserve all prior edits.

### Fix Focus Areas
- admin/src/components/settings/FormView.tsx[34-42]

### Suggested change
Inside `onEdit`, read the current settings text from the store:
- `const current = useStore.getState().settings ?? ''`
- `setSettings(editJsonc(current, path, value))`

(Optionally keep `text` only for rendering/parsing, not as the source for mutation.)

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

JohnMcLear and others added 4 commits May 9, 2026 14:30
When settings.json is on a single line (the shipped/minified form),
findTrailing was matching `//` inside `https://etherpad.org` (in
defaultPadText) and rendering everything after it as the trailing
comment for whichever key came before. Bail out when the value has
no newline after it, since trailing comments can only exist on the
same line by definition.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The live settings.json is per-developer and often lacks comments (e.g.
when it was minified by an earlier admin save or test run). Without a
fallback, every key in form view shows up bare. settings.json.template
in the repo root is the authoritative source of per-key documentation,
so we read it once at admin-build time, build a path → comment map,
and use it as the fallback when the live file has no comment of its
own for that path.

Live-file comments still take precedence so admins who add their own
notes see them as written. Trailing comments still come from the live
file only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the dark IDE-tree look with a light, sectioned form that
matches the rest of /admin. Top-level primitive keys collect into a
'General' section; top-level objects/arrays each become their own
labelled section with the first sentence of the doc comment as the
heading and the rest as descriptive copy. Each leaf is a label/control
row with help text below. Switch labels to template-derived first-
sentence + humanized-key fallback. Remove the unused
CommentLabel/ObjectGroup/ArrayGroup components — sections and grid
layout replace them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two-column form derives the row label from the first sentence of a
key's comment and puts the rest in a help paragraph; specs now assert
both pieces render. Add a spec covering the template-fallback path
(live settings.json has no comment for `port`, but `port` shows a
non-empty label sourced from settings.json.template).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@JohnMcLear
Copy link
Copy Markdown
Member Author

/review

@qodo-code-review
Copy link
Copy Markdown

ⓘ You've reached your Qodo monthly free-tier limit. Reviews pause until next month — upgrade your plan to continue now, or link your paid account if you already have one.

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

qodo-free-for-open-source-projects Bot commented May 9, 2026

Persistent review updated to latest commit 81f7407

Comment on lines +17 to +67
const [mode, setMode] = useState<Mode>('form');
const [exposeExperimental] = useState(false);

const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key !== 'Tab') return;
e.preventDefault();
const target = e.currentTarget;
const { selectionStart, selectionEnd, value } = target;
const next = value.substring(0, selectionStart) + TAB_INDENT + value.substring(selectionEnd);
useStore.getState().setSettings(next);
requestAnimationFrame(() => {
target.selectionStart = target.selectionEnd = selectionStart + TAB_INDENT.length;
});
};

const showToast = (titleKey: string, success: boolean) => {
useStore.getState().setToastState({ open: true, title: t(titleKey), success });
};

const testJSON = () => {
if (isJSONClean(settings)) showToast('admin_settings.toast.validation_ok', true);
else showToast('admin_settings.toast.validation_failed', false);
};

const prettifyJSON = () => {
try {
const obj = JSON.parse(cleanComments(settings) ?? '');
if (window.confirm(t('admin_settings.prettify_confirm'))) {
useStore.getState().setSettings(JSON.stringify(obj, null, 2));
}
} catch {
showToast('admin_settings.toast.prettify_failed', false);
}
};

const handleSave = () => {
if (!isJSONClean(settings)) return showToast('admin_settings.toast.json_invalid', false);
if (!settingsSocket?.connected) return showToast('admin_settings.toast.disconnected', false);
settingsSocket.emit('saveSettings', settings);
showToast('admin_settings.toast.saved', true);
};

return (
<div className="settings-page">
<h1><Trans i18nKey="admin_settings.current" /></h1>

<ModeToggle mode={mode} onChange={setMode} />

{mode === 'form'
? <FormView onSwitchToRaw={() => setMode('raw')} />
: (
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Form editor enabled by default 📘 Rule violation ⚙ Maintainability

The new parsed form editor is shipped as the default mode (useState<Mode>('form')) and there is no
feature flag to disable it, changing the existing /admin/settings behavior by default. This
violates the requirement that new features must be gated and disabled by default to preserve prior
behavior unless explicitly enabled.
Agent Prompt
## Issue description
The new parsed form settings editor is enabled by default and is not controlled by a feature flag, which changes existing behavior when the PR is deployed.

## Issue Context
Compliance requires new features to be behind a feature flag and disabled by default so existing behavior remains unchanged unless explicitly enabled.

## Fix Focus Areas
- admin/src/pages/SettingsPage.tsx[17-77]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +88 to +99
{generalProps.length > 0 && (
<Section title="General">
{generalProps.map((prop, i) => (
<JsoncNode
key={i}
node={prop.children![1]}
property={prop}
text={text}
onEdit={onEdit}
/>
))}
</Section>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Index keys break widget state 🐞 Bug ≡ Correctness

FormView and JsoncNode use array indices as React keys, so inserting/removing/reordering properties
(possible via Raw mode) can cause React to reuse the wrong component instances for different
settings paths. This can mis-associate local widget state (notably NumberInput’s draft/invalid
state) with the wrong setting.
Agent Prompt
## Issue description
Using array indices as React keys can cause incorrect component reuse after list changes, leading to wrong widget state being displayed/edited.

## Issue Context
Raw mode is explicitly an escape hatch for structural edits, so reordering/inserting keys is realistic.

## Fix Focus Areas
- admin/src/components/settings/FormView.tsx[88-115]
- admin/src/components/settings/JsoncNode.tsx[88-107]

## Suggested fix
- In `FormView`, replace `key={i}` with a stable key derived from the property key (e.g., `propertyKey(prop)`) and/or node offset (`prop.offset`).
- In `JsoncNode`, replace `key={i}` with a stable key per child node:
  - For object children: use the property key string (from `child.children[0].value`) or `child.offset`.
  - For array children: use `child.offset` (or a combination of `offset/length`) rather than index.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

JohnMcLear and others added 12 commits May 9, 2026 15:15
… keys

Replace index-based key={i} with stable keys: property key string (or
byte offset fallback) for object children, and byte offset for array
elements. Same fix applied to generalProps/sectionProps maps in FormView.

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

onEdit previously closed over the render-time text snapshot; rapid edits
would clobber each other as each call re-applied its delta to the same
stale baseline. Now reads useStore.getState().settings on every call.

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

Both files defined .settings targeting the same textarea element with
conflicting declarations (resize: none vs resize: vertical, different
font stacks). Disambiguate App.css to textarea.settings and remove the
now-superseded legacy .settings block from index.css.

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

When settings is undefined (not yet loaded from server), render an empty
aria-busy placeholder div instead of treating undefined as empty string,
which previously caused a spurious parse-error banner flash on load.

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

Add a useEffect that updates the draft string when the value prop changes
(e.g. after a server save round-trip canonicalises the number), but only
when the input is not focused so in-progress user typing is never stomped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
JsoncNode.tsx defines helpId only when help text exists and uses it
solely as the p element's id — no aria-describedby ever points at it.
No dangling reference; issue was already clean before this PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Server now emits saveprogress:'saved' only on writeFile success and
saveprogress:'error' with payload.message on failure. The client
saveprogress listener in App.tsx drives the toast; handleSave no longer
fires a success toast eagerly. Adds admin_settings.toast.save_failed i18n key.

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

Radix Toast toggles data-state rather than removing the element, so the
old .ToastRootSuccess selector could match a stale open toast from a
previous save. Now waits for any existing open toast to close before
clicking, then requires data-state="open" on the new toast.

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

- 'General' section title → t('admin_settings.section.general')
- ModeToggle aria-label → t('admin_settings.mode.aria_label')
- ParseErrorMessage token labels intentionally kept in English (technical
  parser-error tokens, not user-facing prose; documented with comment)
- Adds admin_settings.section.general and admin_settings.mode.aria_label
  to src/locales/en.json

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

Without an explicit type, a <button> inside a <form> defaults to
type='submit'. Adding type='button' as the default prevents any
wrapping form from being submitted unintentionally.

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

Protocol-relative //github.com inherits the page protocol; explicit
https:// is safer and avoids mixed-content issues.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@JohnMcLear
Copy link
Copy Markdown
Member Author

@qodo addressing review on commits 5bcd3e3..bd84291. 12 of 14 issues fixed (#4 was already resolved by Qodo).

Pushing back on

  • Added Makefile #1 "Form editor enabled by default" and widget-ify for integration into e.g. express #5 "Validate JSON lacks feature flag": declining. The whole point of Parsing the settings.json in /admin could provide a better UX #7603 is to replace the textarea with a parsed UI; gating the new editor behind a default-off flag would mean the issue isn't actually fixed for anyone who doesn't go find the flag, while still shipping the maintenance cost of two code paths. Compliance ID 8/10 doesn't apply when the new feature is the bug fix the issue is asking for. The Raw mode toggle (still one click away, with Tab/JSON validation/Restart all working there) is the preserved-prior-behaviour path. Validate is a small dry-run button next to Save, not a behaviour change worth a flag.

Fixed

cd admin && npx tsc --noEmit clean. cd src && npx playwright test admin-spec/adminsettings.spec.ts 10/10 passing.

/review

Switching the previous `?raw` import to a Vite `define` removes the
need to widen the dev server's filesystem allowlist to the repo root.
The previous setting (server.fs.allow: ['..']) let the dev server
serve any file under the repo root — including settings.json and
credentials.json — to anything that could reach the dev server.
The build-time `define` produces an identical bundle without ever
exposing the filesystem at runtime.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Parsing the settings.json in /admin could provide a better UX

2 participants