Skip to content

Commit 845440e

Browse files
JohnMcLearclaude
andcommitted
admin(settings): inline-edit ${VAR:default} env placeholders
Sam reported that on a Docker deploy (settings.json.docker, ~122 env placeholders) the new form view showed only labels and no editable controls — every value rendered as a read-only EnvPill, so editing was only possible via Raw mode. The frontend-admin-tests workflow uses settings.json.template (~28 env placeholders) and the keys it asserts against (title, requireAuthentication, sso.issuer) still rendered editable widgets there, so the regression was silent. EnvPill is now an inline <input> over the placeholder's default value. On change it emits "\${VAR:newDefault}" via jsoncEdit, so the surrounding placeholder syntax, key order, and comments are preserved end-to-end. Tests: - env-pill spec flipped from "read-only assertion" to an editing round-trip (raw shows \${SSO_ISSUER:edited}). - new docker-like spec seeds a settings.json where every value is a "\${VAR:default}" placeholder and asserts each visible env control is an <input> + editing one persists. Protects against future "form view degrades on env-heavy configs" regressions. i18n: tooltip and aria strings updated; new default_label and input_aria keys. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 424a067 commit 845440e

5 files changed

Lines changed: 151 additions & 23 deletions

File tree

admin/src/App.css

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,6 @@ textarea.settings:focus {
203203
border-radius: 12px;
204204
padding: 2px 10px;
205205
font-size: 13px;
206-
cursor: help;
207206
}
208207
.settings-widget-env-icon {
209208
font-style: normal;
@@ -213,10 +212,26 @@ textarea.settings:focus {
213212
font-family: "Fira Code", monospace;
214213
font-weight: 600;
215214
}
216-
.settings-widget-env code {
217-
background: transparent;
218-
color: #804;
215+
.settings-widget-env-default-label {
216+
color: #557;
217+
font-size: 12px;
218+
text-transform: lowercase;
219+
}
220+
.settings-widget-env-default-input {
221+
background: white;
222+
border: 1px solid #ccd;
223+
border-radius: 6px;
224+
padding: 1px 6px;
219225
font-family: "Fira Code", monospace;
226+
font-size: 13px;
227+
color: #804;
228+
min-width: 80px;
229+
max-width: 240px;
230+
width: auto;
231+
}
232+
.settings-widget-env-default-input:focus {
233+
outline: 2px solid var(--accent, #2b8a3e);
234+
outline-offset: 1px;
220235
}
221236

222237
/* Radix switch (boolean) */

admin/src/components/settings/JsoncNode.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,15 @@ const renderLeaf = (
4040
if (node.type === 'string') {
4141
const raw = text.slice(node.offset, node.offset + node.length);
4242
const env = matchEnvPlaceholder(raw);
43-
if (env) return <EnvPill placeholder={env} path={path} />;
43+
if (env) {
44+
return (
45+
<EnvPill
46+
placeholder={env}
47+
path={path}
48+
onChange={(d) => onEdit(path, `\${${env.variable}:${d}}`)}
49+
/>
50+
);
51+
}
4452
return (
4553
<StringInput
4654
value={String(node.value)}
Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,57 @@
1+
import { useEffect, useRef, useState } from 'react';
12
import { useTranslation } from 'react-i18next';
23
import type { JSONPath } from 'jsonc-parser';
34
import type { EnvPlaceholder } from '../envPill';
45

56
type Props = {
67
placeholder: EnvPlaceholder;
78
path: JSONPath;
9+
onChange: (newDefault: string) => void;
810
};
911

10-
export const EnvPill = ({ placeholder, path }: Props) => {
12+
const sanitize = (s: string) => s.replace(/[}]/g, '');
13+
14+
export const EnvPill = ({ placeholder, path, onChange }: Props) => {
1115
const { t } = useTranslation();
16+
const initial = placeholder.defaultValue ?? '';
17+
const [draft, setDraft] = useState(initial);
18+
const focused = useRef(false);
19+
20+
// Sync local draft from upstream (server canonicalisation, raw-mode edit)
21+
// only while the input isn't focused so we don't trample mid-typing.
22+
useEffect(() => {
23+
if (!focused.current) setDraft(initial);
24+
}, [initial]);
25+
26+
const id = `field-${path.join('.')}`;
27+
const testid = `env-${path.join('.')}`;
28+
1229
return (
1330
<span
1431
className="settings-widget settings-widget-env"
15-
role="note"
16-
title={t('admin_settings.env_pill.tooltip')}
17-
data-testid={`env-${path.join('.')}`}
32+
title={t('admin_settings.env_pill.tooltip', { variable: placeholder.variable })}
1833
>
1934
<span className="settings-widget-env-icon" aria-hidden></span>
2035
<span className="settings-widget-env-name">{placeholder.variable}</span>
21-
{placeholder.defaultValue !== null && (
22-
<span className="settings-widget-env-default">
23-
{' '}default: <code>{placeholder.defaultValue}</code>
24-
</span>
25-
)}
36+
<span className="settings-widget-env-default-label" aria-hidden>
37+
{t('admin_settings.env_pill.default_label')}
38+
</span>
39+
<input
40+
id={id}
41+
data-testid={testid}
42+
className="settings-widget-env-default-input"
43+
type="text"
44+
value={draft}
45+
spellCheck={false}
46+
aria-label={t('admin_settings.env_pill.input_aria', { variable: placeholder.variable })}
47+
onFocus={() => { focused.current = true; }}
48+
onBlur={() => { focused.current = false; }}
49+
onChange={e => {
50+
const v = sanitize(e.target.value);
51+
setDraft(v);
52+
onChange(v);
53+
}}
54+
/>
2655
</span>
2756
);
2857
};

src/locales/en.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,9 @@
4949
"admin_settings.section.general": "General",
5050
"admin_settings.parse_error.title": "Cannot parse settings.json",
5151
"admin_settings.parse_error.cta": "Switch to raw to edit",
52-
"admin_settings.env_pill.tooltip": "Environment variable. Edit in raw mode.",
52+
"admin_settings.env_pill.tooltip": "Reads from the {{variable}} environment variable. The value below is used when {{variable}} is unset.",
53+
"admin_settings.env_pill.default_label": "default",
54+
"admin_settings.env_pill.input_aria": "Default value for {{variable}}",
5355
"admin_settings.page-title": "Settings - Etherpad",
5456

5557
"update.banner.title": "Update available",

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

Lines changed: 82 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -232,16 +232,90 @@ test.describe('admin settings',()=> {
232232
await expect(page.locator('.ToastRootSuccess')).toBeVisible({timeout: 5000});
233233
});
234234

235-
test('env placeholder renders as read-only pill (no input)', async ({page}) => {
235+
test('env placeholder default value is editable inline and persists to raw', async ({page}) => {
236236
await page.goto('http://localhost:9001/admin/settings');
237-
// Form is the default; wait for settings to load before interacting.
238237
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
239-
// settings.json ships "${SSO_ISSUER:http://localhost:9001}" on sso.issuer.
240-
// Locate the sso group, open it, then find the env pill.
241-
const pill = page.getByTestId('env-sso.issuer').first();
242-
await expect(pill).toBeVisible({timeout: 10000});
243-
// No <input> exists for that path
244-
await expect(page.getByTestId('field-sso.issuer')).toHaveCount(0);
238+
239+
// Grab the original raw content so we can restore at the end.
240+
await page.getByTestId('mode-toggle-raw').click();
241+
const raw = page.getByTestId('settings-raw-textarea');
242+
await expect(raw).toBeVisible({timeout: 10000});
243+
const original = await raw.inputValue();
244+
245+
// Switch to form and edit sso.issuer's env-placeholder default inline.
246+
await page.getByTestId('mode-toggle-form').click();
247+
const input = page.getByTestId('env-sso.issuer').first();
248+
await expect(input).toBeVisible({timeout: 10000});
249+
// Sanity: this IS the editable <input> (env pill no longer read-only).
250+
expect(await input.evaluate((el) => el.tagName.toLowerCase())).toBe('input');
251+
await input.fill('http://edited.example:9001');
252+
await page.getByTestId('save-settings-button').click();
253+
await expect(page.locator('.ToastRootSuccess')).toBeVisible({timeout: 5000});
254+
255+
// Reload and confirm the raw JSON now embeds the new default.
256+
await page.reload();
257+
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
258+
await page.getByTestId('mode-toggle-raw').click();
259+
const after = await page.getByTestId('settings-raw-textarea').inputValue();
260+
expect(after).toContain('${SSO_ISSUER:http://edited.example:9001}');
261+
262+
// Restore.
263+
await page.getByTestId('settings-raw-textarea').fill(original);
264+
await page.getByTestId('save-settings-button').click();
265+
await expect(page.locator('.ToastRootSuccess')).toBeVisible({timeout: 5000});
266+
});
267+
268+
test('docker-like env-placeholder-heavy settings.json still has editable controls', async ({page}) => {
269+
// Regression: a settings.json where every value is "${VAR:default}"
270+
// (the shape of settings.json.docker) must NOT degrade the form into a
271+
// read-only viewer. Editing any env default round-trips through save.
272+
await page.goto('http://localhost:9001/admin/settings');
273+
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
274+
275+
await page.getByTestId('mode-toggle-raw').click();
276+
const raw = page.getByTestId('settings-raw-textarea');
277+
await expect(raw).toBeVisible({timeout: 10000});
278+
const original = await raw.inputValue();
279+
280+
// Replace with a minimal env-placeholder-heavy document.
281+
const envHeavy = JSON.stringify({
282+
title: '${TITLE:Etherpad-EnvHeavy}',
283+
port: '${PORT:9001}',
284+
ip: '${IP:0.0.0.0}',
285+
requireAuthentication: '${REQUIRE_AUTHENTICATION:false}',
286+
enableAdminUITests: true,
287+
users: {admin: {password: 'changeme1', is_admin: true}},
288+
});
289+
await raw.fill(envHeavy);
290+
await page.getByTestId('save-settings-button').click();
291+
await expect(page.locator('.ToastRootSuccess')).toBeVisible({timeout: 5000});
292+
293+
await page.reload();
294+
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
295+
296+
// Every visible env-pill row must expose an editable <input>, not a
297+
// read-only span.
298+
for (const id of ['env-title', 'env-port', 'env-ip', 'env-requireAuthentication']) {
299+
const el = page.getByTestId(id).first();
300+
await expect(el).toBeVisible({timeout: 10000});
301+
expect(await el.evaluate((n) => n.tagName.toLowerCase())).toBe('input');
302+
}
303+
304+
// Editing one of them round-trips.
305+
await page.getByTestId('env-title').fill('Etherpad-EnvEdit');
306+
await page.getByTestId('save-settings-button').click();
307+
await expect(page.locator('.ToastRootSuccess')).toBeVisible({timeout: 5000});
308+
309+
await page.reload();
310+
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
311+
await page.getByTestId('mode-toggle-raw').click();
312+
const after = await page.getByTestId('settings-raw-textarea').inputValue();
313+
expect(after).toContain('${TITLE:Etherpad-EnvEdit}');
314+
315+
// Restore original.
316+
await page.getByTestId('settings-raw-textarea').fill(original);
317+
await page.getByTestId('save-settings-button').click();
318+
await expect(page.locator('.ToastRootSuccess')).toBeVisible({timeout: 5000});
245319
});
246320

247321
test('toggling form on broken raw JSON shows parse error banner', async ({page}) => {

0 commit comments

Comments
 (0)