mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-07-17 16:47:05 +00:00
* 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:
parent
7d537f3deb
commit
ab60ed33c1
31 changed files with 3554 additions and 128 deletions
|
|
@ -11,12 +11,13 @@
|
|||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"build-copy": "pnpm gen:api && tsc && vite build --outDir ../src/templates/admin --emptyOutDir",
|
||||
"preview": "vite preview",
|
||||
"test": "pnpm gen:api && tsx --test src/api/__tests__/client.test.ts"
|
||||
"test": "pnpm gen:api && tsx --test 'src/**/__tests__/*.test.ts'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@tanstack/react-query": "^5.100.10",
|
||||
"@tanstack/react-query-devtools": "^5.100.10",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"openapi-fetch": "^0.17.0",
|
||||
"openapi-react-query": "^0.5.4"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,290 @@
|
|||
/* Raw textarea (kept dark to signal "this is code") */
|
||||
textarea.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;
|
||||
}
|
||||
textarea.settings:focus {
|
||||
outline: 2px solid #007acc;
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.settings-button-bar {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.settings-links {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #ddd;
|
||||
}
|
||||
|
||||
/* --- mode toggle --- */
|
||||
.settings-mode-toggle {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 16px;
|
||||
background: #fff;
|
||||
}
|
||||
.settings-mode-toggle button {
|
||||
padding: 6px 14px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #555;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.settings-mode-toggle button.active {
|
||||
background: var(--etherpad-color, #0f775b);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* --- form (light, two-column) --- */
|
||||
.settings-form {
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
background: #fff;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 18px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.settings-section-header {
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid #eee;
|
||||
background: #fafafa;
|
||||
}
|
||||
.settings-section-header h2 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
letter-spacing: 0.01em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.settings-section-header p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.settings-section-body {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
/* Two-column row: label | control, with help below spanning column 2.
|
||||
* Single-column on narrow widths. */
|
||||
.settings-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 220px) minmax(0, 1fr);
|
||||
gap: 6px 18px;
|
||||
padding: 10px 18px;
|
||||
align-items: center;
|
||||
border-top: 1px solid #f4f4f4;
|
||||
}
|
||||
.settings-row:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
.settings-row-label {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
word-break: break-word;
|
||||
}
|
||||
.settings-row-control {
|
||||
min-width: 0;
|
||||
}
|
||||
.settings-row-help {
|
||||
grid-column: 2;
|
||||
margin: 2px 0 0;
|
||||
font-size: 12.5px;
|
||||
color: #666;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.settings-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.settings-row-help {
|
||||
grid-column: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- nested subsections (objects/arrays inside a section) --- */
|
||||
.settings-subsection {
|
||||
grid-column: 1 / -1;
|
||||
margin: 8px 18px;
|
||||
border-left: 3px solid #e2e2e2;
|
||||
padding-left: 14px;
|
||||
}
|
||||
.settings-subsection-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.settings-subsection-title {
|
||||
font-weight: 600;
|
||||
color: #444;
|
||||
font-size: 13.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.settings-subsection-help {
|
||||
color: #777;
|
||||
font-size: 12.5px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.settings-subsection-body .settings-row {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
/* --- leaf widgets (light) --- */
|
||||
.settings-widget-string,
|
||||
.settings-widget-number {
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
color: #222;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
padding: 6px 10px;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
.settings-widget-string:focus,
|
||||
.settings-widget-number:focus {
|
||||
outline: none;
|
||||
border-color: var(--etherpad-color, #0f775b);
|
||||
box-shadow: 0 0 0 3px rgba(15, 119, 91, 0.15);
|
||||
}
|
||||
.settings-widget-number.invalid {
|
||||
border-color: #ce5050;
|
||||
}
|
||||
.settings-widget-null {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: #f0f0f0;
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.settings-widget-env {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: #f4f8ff;
|
||||
color: #335;
|
||||
border: 1px dashed #88a;
|
||||
border-radius: 12px;
|
||||
padding: 2px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.settings-widget-env-icon {
|
||||
font-style: normal;
|
||||
color: #557;
|
||||
}
|
||||
.settings-widget-env-name {
|
||||
font-family: "Fira Code", monospace;
|
||||
font-weight: 600;
|
||||
}
|
||||
.settings-widget-env-default-label {
|
||||
color: #557;
|
||||
font-size: 12px;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
.settings-widget-env-default-input {
|
||||
background: white;
|
||||
border: 1px solid #ccd;
|
||||
border-radius: 6px;
|
||||
padding: 1px 6px;
|
||||
font-family: "Fira Code", monospace;
|
||||
font-size: 13px;
|
||||
color: #804;
|
||||
min-width: 80px;
|
||||
max-width: 240px;
|
||||
width: auto;
|
||||
}
|
||||
.settings-widget-env-default-input:focus {
|
||||
outline: 2px solid var(--accent, #2b8a3e);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* Radix switch (boolean) */
|
||||
.settings-widget-boolean {
|
||||
appearance: none;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
background: #ccc;
|
||||
border: 0;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease;
|
||||
padding: 0;
|
||||
}
|
||||
.settings-widget-boolean[data-state="checked"] {
|
||||
background: var(--etherpad-color, #0f775b);
|
||||
}
|
||||
.settings-widget-boolean-thumb {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
transform: translateX(2px);
|
||||
transition: transform 120ms ease;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.2);
|
||||
}
|
||||
.settings-widget-boolean[data-state="checked"] .settings-widget-boolean-thumb {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
/* --- parse error --- */
|
||||
.settings-parse-error {
|
||||
border: 1px solid #d99;
|
||||
background: #fff5f5;
|
||||
color: #842;
|
||||
padding: 14px 18px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.settings-parse-error-detail {
|
||||
margin: 8px 0;
|
||||
white-space: pre-wrap;
|
||||
font-family: "Fira Code", monospace;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.settings-parse-error button {
|
||||
margin-top: 4px;
|
||||
background: var(--etherpad-color, #0f775b);
|
||||
color: #fff;
|
||||
border: 0;
|
||||
padding: 6px 14px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
|
@ -63,7 +63,15 @@ export const App = () => {
|
|||
useStore.getState().setShowLoading(false);
|
||||
});
|
||||
|
||||
settingSocket.on('saveprogress', (status) => console.log(status))
|
||||
settingSocket.on('saveprogress', (status: string, payload?: {message?: string}) => {
|
||||
const {setToastState} = useStore.getState();
|
||||
if (status === 'saved') {
|
||||
setToastState({open: true, title: t('admin_settings.toast.saved'), success: true});
|
||||
} else {
|
||||
const detail = payload?.message ?? '';
|
||||
setToastState({open: true, title: t('admin_settings.toast.save_failed') + (detail ? ` (${detail})` : ''), success: false});
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
settingSocket.disconnect();
|
||||
|
|
|
|||
|
|
@ -1,17 +1,14 @@
|
|||
import {FC, JSX, ReactElement} from "react";
|
||||
import {ButtonHTMLAttributes, FC, JSX, ReactElement} from "react";
|
||||
|
||||
export type IconButtonProps = {
|
||||
style?: React.CSSProperties,
|
||||
export type IconButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'title' | 'onClick'> & {
|
||||
icon: JSX.Element,
|
||||
title: string|ReactElement,
|
||||
onClick: ()=>void,
|
||||
className?: string,
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export const IconButton:FC<IconButtonProps> = ({icon,className,onClick,title, disabled, style})=>{
|
||||
return <button style={style} onClick={onClick} className={"icon-button "+ className} disabled={disabled}>
|
||||
export const IconButton: FC<IconButtonProps> = ({icon, className, onClick, title, type = 'button', ...rest}) => (
|
||||
<button {...rest} type={type} onClick={onClick} className={"icon-button " + (className ?? "")}>
|
||||
{icon}
|
||||
<span>{title}</span>
|
||||
</button>
|
||||
}
|
||||
</button>
|
||||
);
|
||||
|
|
|
|||
144
admin/src/components/settings/FormView.tsx
Normal file
144
admin/src/components/settings/FormView.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import { parseTree, getNodePath, type JSONPath, type Node, type ParseError } from 'jsonc-parser';
|
||||
import { useStore } from '../../store/store';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { editJsonc } from './jsoncEdit';
|
||||
import { JsoncNode } from './JsoncNode';
|
||||
import { ParseErrorBanner } from './ParseErrorBanner';
|
||||
import { extractAdjacentComments } from './comments';
|
||||
import { lookupTemplateComment } from './templateComments';
|
||||
import { labelAndHelp } from './labels';
|
||||
|
||||
type Props = {
|
||||
onSwitchToRaw: () => void;
|
||||
};
|
||||
|
||||
// Parser-error token labels are kept in English — they are technical tokens
|
||||
// matching the jsonc-parser error enum, not user-facing prose.
|
||||
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',
|
||||
};
|
||||
|
||||
const formatErrors = (errors: ParseError[]): string =>
|
||||
errors.length === 0
|
||||
? ''
|
||||
: errors.map(e => `offset ${e.offset}: ${ParseErrorMessage[e.error] ?? 'parse error'}`).join('\n');
|
||||
|
||||
const Section = ({ title, description, children }: {
|
||||
title: string;
|
||||
description?: string;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<section className="settings-section">
|
||||
<header className="settings-section-header">
|
||||
<h2>{title}</h2>
|
||||
{description && <p>{description}</p>}
|
||||
</header>
|
||||
<div className="settings-section-body">{children}</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
const propertyKey = (prop: Node): string =>
|
||||
prop.type === 'property' && prop.children?.[0]?.type === 'string'
|
||||
? String(prop.children[0].value)
|
||||
: '';
|
||||
|
||||
const propertyComment = (prop: Node, text: string, key: string): string | null => {
|
||||
const valueNode = prop.children?.[1];
|
||||
if (!valueNode) return null;
|
||||
const live = extractAdjacentComments(text, prop.offset, valueNode.offset, valueNode.length);
|
||||
if (live.leading) return live.leading;
|
||||
// Section headings prefer the leading block comment; only fall back to the
|
||||
// trailing comment if no leading documentation exists at all.
|
||||
const tmpl = lookupTemplateComment([key]);
|
||||
if (!tmpl) return null;
|
||||
return tmpl.leading || tmpl.trailing || null;
|
||||
};
|
||||
|
||||
export const FormView = ({ onSwitchToRaw }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const rawText = useStore(s => s.settings);
|
||||
|
||||
// While settings haven't loaded yet, show an empty busy placeholder so we
|
||||
// don't flash a parse-error banner for the undefined→'' empty-string case.
|
||||
if (rawText === undefined) {
|
||||
return <div className="settings-form" data-testid="settings-form-view" aria-busy="true" />;
|
||||
}
|
||||
|
||||
const text = rawText;
|
||||
|
||||
const errors: ParseError[] = [];
|
||||
const tree = parseTree(text, errors, { allowTrailingComma: true });
|
||||
|
||||
// Always read the latest text from the store instead of closing over the
|
||||
// render-time snapshot, so rapid sequential edits don't clobber each other.
|
||||
const onEdit = (path: JSONPath, value: unknown) => {
|
||||
const current = useStore.getState().settings ?? '';
|
||||
useStore.getState().setSettings(editJsonc(current, path, value));
|
||||
};
|
||||
|
||||
if (!tree || errors.length > 0 || tree.type !== 'object') {
|
||||
return <ParseErrorBanner message={formatErrors(errors)} onSwitchToRaw={onSwitchToRaw} />;
|
||||
}
|
||||
|
||||
const generalProps: Node[] = [];
|
||||
const sectionProps: Node[] = [];
|
||||
for (const prop of tree.children ?? []) {
|
||||
if (prop.type !== 'property' || !prop.children?.[1]) continue;
|
||||
const valueType = prop.children[1].type;
|
||||
if (valueType === 'object' || valueType === 'array') sectionProps.push(prop);
|
||||
else generalProps.push(prop);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-form" data-testid="settings-form-view">
|
||||
{generalProps.length > 0 && (
|
||||
<Section title={t('admin_settings.section.general')}>
|
||||
{generalProps.map((prop) => {
|
||||
const propPath = getNodePath(prop);
|
||||
const propKey = propPath.join('.');
|
||||
return (
|
||||
<JsoncNode
|
||||
key={propKey}
|
||||
node={prop.children![1]}
|
||||
property={prop}
|
||||
text={text}
|
||||
onEdit={onEdit}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Section>
|
||||
)}
|
||||
{sectionProps.map((prop) => {
|
||||
const key = propertyKey(prop);
|
||||
const { label, help } = labelAndHelp(propertyComment(prop, text, key), key);
|
||||
const propPath = getNodePath(prop);
|
||||
const sectionKey = propPath.join('.');
|
||||
return (
|
||||
<Section key={sectionKey} title={label} description={help}>
|
||||
<JsoncNode
|
||||
node={prop.children![1]}
|
||||
property={prop}
|
||||
text={text}
|
||||
onEdit={onEdit}
|
||||
suppressOwnHeader
|
||||
/>
|
||||
</Section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
173
admin/src/components/settings/JsoncNode.tsx
Normal file
173
admin/src/components/settings/JsoncNode.tsx
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import type { JSONPath, Node } from 'jsonc-parser';
|
||||
import { getNodePath } from 'jsonc-parser';
|
||||
import { extractAdjacentComments } from './comments';
|
||||
import { matchEnvPlaceholder } from './envPill';
|
||||
import { lookupTemplateComment } from './templateComments';
|
||||
import { humanize, labelAndHelp } from './labels';
|
||||
import { StringInput } from './widgets/StringInput';
|
||||
import { NumberInput } from './widgets/NumberInput';
|
||||
import { BooleanToggle } from './widgets/BooleanToggle';
|
||||
import { NullChip } from './widgets/NullChip';
|
||||
import { EnvPill } from './widgets/EnvPill';
|
||||
|
||||
type Props = {
|
||||
/** The value node (not the property node). */
|
||||
node: Node;
|
||||
/** The property node, when this value is the value-side of `"key": value`. */
|
||||
property?: Node;
|
||||
text: string;
|
||||
onEdit: (path: JSONPath, value: unknown) => void;
|
||||
/**
|
||||
* When true, this group's own label/header is suppressed because a
|
||||
* containing Section already rendered it. The group's children still
|
||||
* render. Used for top-level object/array sections in FormView.
|
||||
*/
|
||||
suppressOwnHeader?: boolean;
|
||||
};
|
||||
|
||||
const propertyKey = (property: Node | undefined): string => {
|
||||
if (!property || property.type !== 'property') return '';
|
||||
const k = property.children?.[0];
|
||||
return k?.type === 'string' ? String(k.value) : '';
|
||||
};
|
||||
|
||||
const renderLeaf = (
|
||||
node: Node,
|
||||
path: JSONPath,
|
||||
text: string,
|
||||
onEdit: (path: JSONPath, value: unknown) => void,
|
||||
) => {
|
||||
if (node.type === 'string') {
|
||||
const raw = text.slice(node.offset, node.offset + node.length);
|
||||
const env = matchEnvPlaceholder(raw);
|
||||
if (env) {
|
||||
return (
|
||||
<EnvPill
|
||||
placeholder={env}
|
||||
path={path}
|
||||
onChange={(d) => onEdit(path, `\${${env.variable}:${d}}`)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<StringInput
|
||||
value={String(node.value)}
|
||||
path={path}
|
||||
onChange={v => onEdit(path, v)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (node.type === 'number') {
|
||||
return (
|
||||
<NumberInput
|
||||
value={Number(node.value)}
|
||||
path={path}
|
||||
onChange={v => onEdit(path, v)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (node.type === 'boolean') {
|
||||
return (
|
||||
<BooleanToggle
|
||||
value={Boolean(node.value)}
|
||||
path={path}
|
||||
onChange={v => onEdit(path, v)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (node.type === 'null') {
|
||||
return <NullChip path={path} />;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const JsoncNode = ({ node, property, text, onEdit, suppressOwnHeader }: Props) => {
|
||||
const path = getNodePath(node);
|
||||
const key = propertyKey(property);
|
||||
|
||||
const anchor = property ?? node;
|
||||
const fileComments = extractAdjacentComments(text, anchor.offset, node.offset, node.length);
|
||||
const tmpl = property ? lookupTemplateComment(path) : null;
|
||||
const leading = fileComments.leading || tmpl?.leading || '';
|
||||
const trailing = fileComments.trailing || tmpl?.trailing || '';
|
||||
|
||||
// Leading block comments (e.g. /* Description … */ above a key) carry the
|
||||
// descriptive label — use labelAndHelp's first-sentence split.
|
||||
// Trailing same-line comments (e.g. "altF9": true, /* focus on … */) are
|
||||
// brief per-key annotations: the key itself reads as the label, the comment
|
||||
// belongs in the help slot below the control. See #7740.
|
||||
let label: string;
|
||||
let help: string;
|
||||
if (leading) {
|
||||
const r = labelAndHelp(leading, key);
|
||||
label = r.label;
|
||||
help = [r.help, trailing].filter(Boolean).join(' ');
|
||||
} else if (trailing) {
|
||||
label = humanize(key);
|
||||
help = trailing;
|
||||
} else {
|
||||
label = humanize(key);
|
||||
help = '';
|
||||
}
|
||||
|
||||
const rowId = `settings-row-${path.join('.') || 'root'}`;
|
||||
const helpId = help ? `${rowId}-help` : undefined;
|
||||
|
||||
// ---- Object / array groups ----
|
||||
if (node.type === 'object' || node.type === 'array') {
|
||||
const children = (node.children ?? []).map((child) => {
|
||||
// For object: child is a property node, drill into its value node.
|
||||
// For array: child is a value node directly.
|
||||
if (node.type === 'object') {
|
||||
const valueNode = child.children?.[1];
|
||||
if (!valueNode) return null;
|
||||
const propPath = getNodePath(child);
|
||||
const propKey = propPath.join('.');
|
||||
return (
|
||||
<JsoncNode
|
||||
key={propKey}
|
||||
node={valueNode}
|
||||
property={child}
|
||||
text={text}
|
||||
onEdit={onEdit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// Array element: use stable JSON path as key.
|
||||
const childPath = getNodePath(child);
|
||||
return <JsoncNode key={childPath.join('.')} node={child} text={text} onEdit={onEdit} />;
|
||||
});
|
||||
|
||||
if (suppressOwnHeader || !property) {
|
||||
// Render children flat — the containing Section provides the label.
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// Nested group within a section: render as a sub-section with its own
|
||||
// heading, indented under its parent.
|
||||
return (
|
||||
<div className="settings-subsection" data-testid={`group-${path.join('.')}`}>
|
||||
<div className="settings-subsection-header">
|
||||
<span className="settings-subsection-title">{label}</span>
|
||||
{help && <span className="settings-subsection-help">{help}</span>}
|
||||
</div>
|
||||
<div className="settings-subsection-body">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Leaf row ----
|
||||
return (
|
||||
<div className="settings-row" id={rowId}>
|
||||
<label className="settings-row-label" htmlFor={`field-${path.join('.')}`}>
|
||||
{label}
|
||||
</label>
|
||||
<div className="settings-row-control">
|
||||
{renderLeaf(node, path, text, onEdit)}
|
||||
</div>
|
||||
{help && (
|
||||
<p className="settings-row-help" id={helpId}>{help}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
36
admin/src/components/settings/ModeToggle.tsx
Normal file
36
admin/src/components/settings/ModeToggle.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
export type Mode = 'form' | 'raw';
|
||||
|
||||
type Props = {
|
||||
mode: Mode;
|
||||
onChange: (mode: Mode) => void;
|
||||
};
|
||||
|
||||
export const ModeToggle = ({ mode, onChange }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="settings-mode-toggle" role="tablist" aria-label={t('admin_settings.mode.aria_label')}>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === 'form'}
|
||||
data-testid="mode-toggle-form"
|
||||
className={mode === 'form' ? 'active' : ''}
|
||||
onClick={() => onChange('form')}
|
||||
>
|
||||
<Trans i18nKey="admin_settings.mode.form" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === 'raw'}
|
||||
data-testid="mode-toggle-raw"
|
||||
className={mode === 'raw' ? 'active' : ''}
|
||||
onClick={() => onChange('raw')}
|
||||
>
|
||||
<Trans i18nKey="admin_settings.mode.raw" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
16
admin/src/components/settings/ParseErrorBanner.tsx
Normal file
16
admin/src/components/settings/ParseErrorBanner.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { Trans } from 'react-i18next';
|
||||
|
||||
type Props = {
|
||||
message: string;
|
||||
onSwitchToRaw: () => void;
|
||||
};
|
||||
|
||||
export const ParseErrorBanner = ({ message, onSwitchToRaw }: Props) => (
|
||||
<div className="settings-parse-error" role="alert" data-testid="parse-error-banner">
|
||||
<strong><Trans i18nKey="admin_settings.parse_error.title" /></strong>
|
||||
<pre className="settings-parse-error-detail">{message}</pre>
|
||||
<button type="button" onClick={onSwitchToRaw} data-testid="parse-error-switch-raw">
|
||||
<Trans i18nKey="admin_settings.parse_error.cta" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
93
admin/src/components/settings/__tests__/comments.test.ts
Normal file
93
admin/src/components/settings/__tests__/comments.test.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
// admin/src/components/settings/__tests__/comments.test.ts
|
||||
//
|
||||
// Regression coverage for https://github.com/ether/etherpad/issues/7740.
|
||||
// A previous version of findLeading treated any line ending in `*/` as a
|
||||
// comment continuation; a JSON line like
|
||||
// "altF9": true, /* focus on the File Menu and/or editbar */
|
||||
// then leaked into the next sibling's "leading comment", which the form
|
||||
// view rendered as the row label.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { extractAdjacentComments } from '../comments.ts';
|
||||
import { humanize, labelAndHelp } from '../labels.ts';
|
||||
|
||||
const padShortcutText = `{
|
||||
"padShortcutEnabled" : {
|
||||
"altF9": true, /* focus on the File Menu and/or editbar */
|
||||
"altC": true, /* focus on the Chat window */
|
||||
"cmdShift2": true, /* shows a gritter popup showing a line author */
|
||||
"delete": true,
|
||||
"return": true,
|
||||
"esc": true, /* in mozilla versions 14-19 avoid reconnecting pad */
|
||||
"cmdS": true /* save a revision */
|
||||
}
|
||||
}`;
|
||||
|
||||
const offsetsFor = (text: string, key: string) => {
|
||||
const keyOffset = text.indexOf(`"${key}"`);
|
||||
const valOffset = text.indexOf('true', keyOffset);
|
||||
return { keyOffset, valOffset, valLength: 4 };
|
||||
};
|
||||
|
||||
test('does not absorb prior JSON line with trailing comment as leading', () => {
|
||||
const { keyOffset, valOffset, valLength } = offsetsFor(padShortcutText, 'altC');
|
||||
const { leading, trailing } =
|
||||
extractAdjacentComments(padShortcutText, keyOffset, valOffset, valLength);
|
||||
assert.equal(leading, '');
|
||||
assert.equal(trailing, 'focus on the Chat window');
|
||||
});
|
||||
|
||||
test('does not accumulate multiple prior trailing-comment lines', () => {
|
||||
const { keyOffset, valOffset, valLength } = offsetsFor(padShortcutText, 'cmdShift2');
|
||||
const { leading } =
|
||||
extractAdjacentComments(padShortcutText, keyOffset, valOffset, valLength);
|
||||
assert.equal(leading, '');
|
||||
});
|
||||
|
||||
test('leading is empty when prior line is plain code (no trailing comment)', () => {
|
||||
const { keyOffset, valOffset, valLength } = offsetsFor(padShortcutText, 'return');
|
||||
const { leading } =
|
||||
extractAdjacentComments(padShortcutText, keyOffset, valOffset, valLength);
|
||||
assert.equal(leading, '');
|
||||
});
|
||||
|
||||
test('still recognises JSDoc-style leading block comments', () => {
|
||||
const text = `{
|
||||
/*
|
||||
* Pad Shortcut Keys
|
||||
*/
|
||||
"padShortcutEnabled" : {}
|
||||
}`;
|
||||
const keyOffset = text.indexOf('"padShortcutEnabled"');
|
||||
const valOffset = text.indexOf('{}');
|
||||
const { leading, trailing } = extractAdjacentComments(text, keyOffset, valOffset, 2);
|
||||
assert.equal(leading, 'Pad Shortcut Keys');
|
||||
assert.equal(trailing, '');
|
||||
});
|
||||
|
||||
test('still recognises single-line // leading comments', () => {
|
||||
const text = `{
|
||||
// Whether to enable the thing.
|
||||
"thing": true
|
||||
}`;
|
||||
const keyOffset = text.indexOf('"thing"');
|
||||
const valOffset = text.indexOf('true');
|
||||
const { leading } = extractAdjacentComments(text, keyOffset, valOffset, 4);
|
||||
assert.equal(leading, 'Whether to enable the thing.');
|
||||
});
|
||||
|
||||
test('humanize spaces camelCase and capitalises only the first word', () => {
|
||||
assert.equal(humanize('requireAuthentication'), 'Require authentication');
|
||||
assert.equal(humanize('altF9'), 'Alt f9');
|
||||
});
|
||||
|
||||
test('labelAndHelp splits a leading block at the first sentence boundary', () => {
|
||||
const { label, help } = labelAndHelp(
|
||||
'Name your instance! Optional context follows.',
|
||||
'title',
|
||||
);
|
||||
assert.equal(label, 'Name your instance!');
|
||||
assert.equal(help, 'Optional context follows.');
|
||||
});
|
||||
95
admin/src/components/settings/comments.ts
Normal file
95
admin/src/components/settings/comments.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
// admin/src/components/settings/comments.ts
|
||||
//
|
||||
// Given the source text and a property's `keyOffset` (jsonc-parser's
|
||||
// Node.offset for the property node), extract:
|
||||
// - `leading`: the contiguous run of `/* */` or `//` comments
|
||||
// immediately above the key. At most one blank line is allowed
|
||||
// between the comment block and the key.
|
||||
// - `trailing`: a single `// ...` or `/* ... */` on the same line
|
||||
// as the value, after any trailing comma.
|
||||
|
||||
export type AdjacentComments = {
|
||||
leading: string;
|
||||
trailing: string;
|
||||
};
|
||||
|
||||
const LINE_BREAK = /\r?\n/;
|
||||
|
||||
const stripCommentMarkers = (raw: string): string => {
|
||||
// raw is a concatenation of comment tokens separated by newlines.
|
||||
// Drop /* */ and // markers and trim each line.
|
||||
return raw
|
||||
.split(LINE_BREAK)
|
||||
.map(line => line
|
||||
.replace(/^\s*\/\*+/, '')
|
||||
.replace(/\*+\/\s*$/, '')
|
||||
.replace(/^\s*\*\s?/, '')
|
||||
.replace(/^\s*\/\/\s?/, '')
|
||||
.trim())
|
||||
.filter(line => line.length > 0)
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
const findLeading = (text: string, keyOffset: number): string => {
|
||||
// Walk backwards from keyOffset to the start of the line containing it.
|
||||
const lineStart = text.lastIndexOf('\n', keyOffset - 1) + 1;
|
||||
let cursor = lineStart;
|
||||
let blankLineSeen = false;
|
||||
const collected: string[] = [];
|
||||
|
||||
while (cursor > 0) {
|
||||
// Look at the previous line.
|
||||
const prevLineEnd = cursor - 1; // the '\n' before our cursor's line
|
||||
const prevLineStart = text.lastIndexOf('\n', prevLineEnd - 1) + 1;
|
||||
const line = text.slice(prevLineStart, prevLineEnd);
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (trimmed === '') {
|
||||
if (blankLineSeen) break;
|
||||
blankLineSeen = true;
|
||||
cursor = prevLineStart;
|
||||
continue;
|
||||
}
|
||||
|
||||
// A JSON line with a trailing `/* … */` comment (e.g.
|
||||
// "altF9": true, /* focus on the File Menu and/or editbar */
|
||||
// ) ends with `*/` but is NOT a comment continuation. Only treat a
|
||||
// previous line as part of the leading comment block if it structurally
|
||||
// opens (`/*`), continues (`*` — JSDoc style, covers ` */` close), or
|
||||
// is a single-line `//` comment. This matches the comment styles used
|
||||
// in settings.json.template; #7740.
|
||||
const isComment =
|
||||
trimmed.startsWith('//') ||
|
||||
trimmed.startsWith('/*') ||
|
||||
trimmed.startsWith('*');
|
||||
|
||||
if (!isComment) break;
|
||||
|
||||
collected.unshift(line);
|
||||
cursor = prevLineStart;
|
||||
}
|
||||
|
||||
return stripCommentMarkers(collected.join('\n'));
|
||||
};
|
||||
|
||||
const findTrailing = (text: string, valueOffset: number, valueLength: number): string => {
|
||||
// Trailing comments only exist on the same line as the value. If there's
|
||||
// no newline after the value the file has no line structure (e.g. minified
|
||||
// settings.json) and `//` inside any later string literal would otherwise
|
||||
// be matched as a comment.
|
||||
const lineEnd = text.indexOf('\n', valueOffset + valueLength);
|
||||
if (lineEnd === -1) return '';
|
||||
const slice = text.slice(valueOffset + valueLength, lineEnd);
|
||||
const m = /,?\s*(\/\/.*|\/\*.*?\*\/)\s*$/.exec(slice);
|
||||
return m ? stripCommentMarkers(m[1]) : '';
|
||||
};
|
||||
|
||||
export const extractAdjacentComments = (
|
||||
text: string,
|
||||
keyOffset: number,
|
||||
valueOffset: number,
|
||||
valueLength: number,
|
||||
): AdjacentComments => ({
|
||||
leading: findLeading(text, keyOffset),
|
||||
trailing: findTrailing(text, valueOffset, valueLength),
|
||||
});
|
||||
21
admin/src/components/settings/envPill.ts
Normal file
21
admin/src/components/settings/envPill.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// admin/src/components/settings/envPill.ts
|
||||
//
|
||||
// Detect `"${VAR}"` and `"${VAR:default}"` placeholders inside the raw
|
||||
// slice of a string node. The slice INCLUDES the surrounding quotes,
|
||||
// because jsonc-parser exposes node.offset/length over the whole literal.
|
||||
|
||||
export type EnvPlaceholder = {
|
||||
variable: string;
|
||||
defaultValue: string | null;
|
||||
};
|
||||
|
||||
const RE = /^"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::([^}]*))?\}"$/;
|
||||
|
||||
export const matchEnvPlaceholder = (rawSlice: string): EnvPlaceholder | null => {
|
||||
const m = RE.exec(rawSlice);
|
||||
if (!m) return null;
|
||||
return {
|
||||
variable: m[1],
|
||||
defaultValue: m[2] ?? null,
|
||||
};
|
||||
};
|
||||
11
admin/src/components/settings/jsoncEdit.ts
Normal file
11
admin/src/components/settings/jsoncEdit.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// admin/src/components/settings/jsoncEdit.ts
|
||||
import { applyEdits, modify, type JSONPath } from 'jsonc-parser';
|
||||
|
||||
const FORMATTING = {
|
||||
formattingOptions: { tabSize: 2, insertSpaces: true, eol: '\n' as const },
|
||||
};
|
||||
|
||||
export const editJsonc = (text: string, path: JSONPath, value: unknown): string => {
|
||||
const edits = modify(text, path, value, FORMATTING);
|
||||
return edits.length === 0 ? text : applyEdits(text, edits);
|
||||
};
|
||||
46
admin/src/components/settings/labels.ts
Normal file
46
admin/src/components/settings/labels.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// Pretty-label derivation. The first sentence of a key's documentation
|
||||
// comment is its label; the rest stays in the help-text slot. When no
|
||||
// comment exists, fall back to a humanized key name (camelCase → "Camel
|
||||
// case").
|
||||
|
||||
const SENTENCE_END = /[.!?](\s|$)/;
|
||||
|
||||
export const humanize = (key: string): string => {
|
||||
if (!key) return key;
|
||||
// Split camelCase / PascalCase / snake_case / kebab-case
|
||||
const words = key
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.split(/\s+/);
|
||||
if (words.length === 0) return key;
|
||||
return words[0].charAt(0).toUpperCase() + words[0].slice(1) +
|
||||
(words.length > 1 ? ' ' + words.slice(1).join(' ') : '');
|
||||
};
|
||||
|
||||
const splitFirstSentence = (text: string): { head: string; rest: string } => {
|
||||
const trimmed = text.trim();
|
||||
const m = SENTENCE_END.exec(trimmed);
|
||||
if (!m) return { head: trimmed, rest: '' };
|
||||
const cut = m.index + 1; // include the punctuation
|
||||
return {
|
||||
head: trimmed.slice(0, cut).trim(),
|
||||
rest: trimmed.slice(cut).trim(),
|
||||
};
|
||||
};
|
||||
|
||||
export const labelAndHelp = (
|
||||
comment: string | null | undefined,
|
||||
key: string,
|
||||
): { label: string; help: string } => {
|
||||
if (!comment || !comment.trim()) {
|
||||
return { label: humanize(key), help: '' };
|
||||
}
|
||||
const { head, rest } = splitFirstSentence(comment);
|
||||
return {
|
||||
label: head || humanize(key),
|
||||
help: rest,
|
||||
};
|
||||
};
|
||||
50
admin/src/components/settings/templateComments.ts
Normal file
50
admin/src/components/settings/templateComments.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
// Build a fallback path → comment map from `settings.json.template`. The live
|
||||
// settings.json is per-developer and often lacks comments; the template is the
|
||||
// authoritative source of per-key documentation.
|
||||
|
||||
import { parseTree, type JSONPath, type Node } from 'jsonc-parser';
|
||||
import { extractAdjacentComments, type AdjacentComments } from './comments';
|
||||
|
||||
// Injected by Vite at build time from settings.json.template (see vite.config.ts).
|
||||
// Inlining at config time avoids widening the dev server's filesystem allowlist
|
||||
// to the repo root, which would expose settings.json/credentials.json over the
|
||||
// dev server.
|
||||
declare const __SETTINGS_TEMPLATE__: string;
|
||||
const templateText: string = __SETTINGS_TEMPLATE__;
|
||||
|
||||
const pathKey = (path: JSONPath): string => path.map(String).join('.');
|
||||
|
||||
const buildMap = (text: string): Map<string, AdjacentComments> => {
|
||||
const map = new Map<string, AdjacentComments>();
|
||||
const tree = parseTree(text, [], { allowTrailingComma: true });
|
||||
if (!tree) return map;
|
||||
|
||||
const walk = (node: Node, path: JSONPath) => {
|
||||
if (node.type === 'object') {
|
||||
for (const prop of node.children ?? []) {
|
||||
if (prop.type !== 'property' || !prop.children || prop.children.length < 2) continue;
|
||||
const keyNode = prop.children[0];
|
||||
const valueNode = prop.children[1];
|
||||
if (keyNode.type !== 'string') continue;
|
||||
const childPath = [...path, String(keyNode.value)];
|
||||
const adjacent = extractAdjacentComments(
|
||||
text, prop.offset, valueNode.offset, valueNode.length,
|
||||
);
|
||||
if (adjacent.leading || adjacent.trailing) {
|
||||
map.set(pathKey(childPath), adjacent);
|
||||
}
|
||||
walk(valueNode, childPath);
|
||||
}
|
||||
} else if (node.type === 'array') {
|
||||
(node.children ?? []).forEach((child, i) => walk(child, [...path, i]));
|
||||
}
|
||||
};
|
||||
|
||||
walk(tree, []);
|
||||
return map;
|
||||
};
|
||||
|
||||
const templateMap = buildMap(templateText);
|
||||
|
||||
export const lookupTemplateComment = (path: JSONPath): AdjacentComments | null =>
|
||||
templateMap.get(pathKey(path)) ?? null;
|
||||
20
admin/src/components/settings/widgets/BooleanToggle.tsx
Normal file
20
admin/src/components/settings/widgets/BooleanToggle.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import * as Switch from '@radix-ui/react-switch';
|
||||
import type { JSONPath } from 'jsonc-parser';
|
||||
|
||||
type Props = {
|
||||
value: boolean;
|
||||
path: JSONPath;
|
||||
onChange: (next: boolean) => void;
|
||||
};
|
||||
|
||||
export const BooleanToggle = ({ value, path, onChange }: Props) => (
|
||||
<Switch.Root
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
id={`field-${path.join('.')}`}
|
||||
className="settings-widget settings-widget-boolean"
|
||||
data-testid={`field-${path.join('.')}`}
|
||||
>
|
||||
<Switch.Thumb className="settings-widget-boolean-thumb" />
|
||||
</Switch.Root>
|
||||
);
|
||||
57
admin/src/components/settings/widgets/EnvPill.tsx
Normal file
57
admin/src/components/settings/widgets/EnvPill.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { JSONPath } from 'jsonc-parser';
|
||||
import type { EnvPlaceholder } from '../envPill';
|
||||
|
||||
type Props = {
|
||||
placeholder: EnvPlaceholder;
|
||||
path: JSONPath;
|
||||
onChange: (newDefault: string) => void;
|
||||
};
|
||||
|
||||
const sanitize = (s: string) => s.replace(/[}]/g, '');
|
||||
|
||||
export const EnvPill = ({ placeholder, path, onChange }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const initial = placeholder.defaultValue ?? '';
|
||||
const [draft, setDraft] = useState(initial);
|
||||
const focused = useRef(false);
|
||||
|
||||
// Sync local draft from upstream (server canonicalisation, raw-mode edit)
|
||||
// only while the input isn't focused so we don't trample mid-typing.
|
||||
useEffect(() => {
|
||||
if (!focused.current) setDraft(initial);
|
||||
}, [initial]);
|
||||
|
||||
const id = `field-${path.join('.')}`;
|
||||
const testid = `env-${path.join('.')}`;
|
||||
|
||||
return (
|
||||
<span
|
||||
className="settings-widget settings-widget-env"
|
||||
title={t('admin_settings.env_pill.tooltip', { variable: placeholder.variable })}
|
||||
>
|
||||
<span className="settings-widget-env-icon" aria-hidden>ⓔ</span>
|
||||
<span className="settings-widget-env-name">{placeholder.variable}</span>
|
||||
<span className="settings-widget-env-default-label" aria-hidden>
|
||||
{t('admin_settings.env_pill.default_label')}
|
||||
</span>
|
||||
<input
|
||||
id={id}
|
||||
data-testid={testid}
|
||||
className="settings-widget-env-default-input"
|
||||
type="text"
|
||||
value={draft}
|
||||
spellCheck={false}
|
||||
aria-label={t('admin_settings.env_pill.input_aria', { variable: placeholder.variable })}
|
||||
onFocus={() => { focused.current = true; }}
|
||||
onBlur={() => { focused.current = false; }}
|
||||
onChange={e => {
|
||||
const v = sanitize(e.target.value);
|
||||
setDraft(v);
|
||||
onChange(v);
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
10
admin/src/components/settings/widgets/NullChip.tsx
Normal file
10
admin/src/components/settings/widgets/NullChip.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import type { JSONPath } from 'jsonc-parser';
|
||||
|
||||
type Props = { path: JSONPath };
|
||||
|
||||
export const NullChip = ({ path }: Props) => (
|
||||
<span
|
||||
className="settings-widget settings-widget-null"
|
||||
data-testid={`field-${path.join('.')}`}
|
||||
>null</span>
|
||||
);
|
||||
48
admin/src/components/settings/widgets/NumberInput.tsx
Normal file
48
admin/src/components/settings/widgets/NumberInput.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { JSONPath } from 'jsonc-parser';
|
||||
|
||||
type Props = {
|
||||
value: number;
|
||||
path: JSONPath;
|
||||
onChange: (next: number) => void;
|
||||
};
|
||||
|
||||
export const NumberInput = ({ value, path, onChange }: Props) => {
|
||||
const [draft, setDraft] = useState(String(value));
|
||||
const [invalid, setInvalid] = useState(false);
|
||||
const focusedRef = useRef(false);
|
||||
|
||||
// Sync draft when the prop value changes (e.g. after a server round-trip
|
||||
// canonicalises the number) — but only when the input is not focused so we
|
||||
// don't stomp on the user while they are typing.
|
||||
useEffect(() => {
|
||||
if (!focusedRef.current) {
|
||||
setDraft(String(value));
|
||||
setInvalid(false);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
id={`field-${path.join('.')}`}
|
||||
className={'settings-widget settings-widget-number' + (invalid ? ' invalid' : '')}
|
||||
data-testid={`field-${path.join('.')}`}
|
||||
value={draft}
|
||||
onFocus={() => { focusedRef.current = true; }}
|
||||
onBlur={() => { focusedRef.current = false; }}
|
||||
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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
19
admin/src/components/settings/widgets/StringInput.tsx
Normal file
19
admin/src/components/settings/widgets/StringInput.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { JSONPath } from 'jsonc-parser';
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
path: JSONPath;
|
||||
onChange: (next: string) => void;
|
||||
};
|
||||
|
||||
export const StringInput = ({ value, path, onChange }: Props) => (
|
||||
<input
|
||||
type="text"
|
||||
id={`field-${path.join('.')}`}
|
||||
className="settings-widget settings-widget-string"
|
||||
data-testid={`field-${path.join('.')}`}
|
||||
value={value}
|
||||
spellCheck={false}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
|
|
@ -272,16 +272,9 @@ td, th {
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.settings {
|
||||
flex-grow: max(1, 1);
|
||||
outline: none;
|
||||
width: 100%;
|
||||
resize: none;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
#response {
|
||||
display: inline;
|
||||
|
|
|
|||
|
|
@ -1,51 +1,123 @@
|
|||
import {useStore} from "../store/store.ts";
|
||||
import {isJSONClean, cleanComments} from "../utils/utils.ts";
|
||||
import {Trans, useTranslation} from "react-i18next";
|
||||
import {IconButton} from "../components/IconButton.tsx";
|
||||
import {RotateCw, Save} from "lucide-react";
|
||||
import React, { useState } from 'react';
|
||||
import { useStore } from '../store/store';
|
||||
import { isJSONClean, cleanComments } from '../utils/utils';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { IconButton } from '../components/IconButton';
|
||||
import { RotateCw, Save, AlignLeft, ShieldCheck } from 'lucide-react';
|
||||
import { FormView } from '../components/settings/FormView';
|
||||
import { ModeToggle, type Mode } from '../components/settings/ModeToggle';
|
||||
|
||||
export const SettingsPage = ()=>{
|
||||
const settingsSocket = useStore(state=>state.settingsSocket)
|
||||
const settings = cleanComments(useStore(state=>state.settings))
|
||||
const {t} = useTranslation()
|
||||
const TAB_INDENT = ' ';
|
||||
|
||||
return <div className="settings-page">
|
||||
<h1><Trans i18nKey="admin_settings.current"/></h1>
|
||||
<textarea value={settings} className="settings" onChange={v => {
|
||||
useStore.getState().setSettings(v.target.value)
|
||||
}}/>
|
||||
<div className="settings-button-bar">
|
||||
<IconButton className="settingsButton" icon={<Save/>}
|
||||
title={<Trans i18nKey="admin_settings.current_save.value"/>} onClick={() => {
|
||||
if (isJSONClean(settings!)) {
|
||||
// JSON is clean so emit it to the server
|
||||
settingsSocket!.emit('saveSettings', settings!);
|
||||
useStore.getState().setToastState({
|
||||
open: true,
|
||||
title: t('admin_settings.saved_success'),
|
||||
success: true
|
||||
})
|
||||
} else {
|
||||
useStore.getState().setToastState({
|
||||
open: true,
|
||||
title: t('admin_settings.save_error'),
|
||||
success: false
|
||||
})
|
||||
}
|
||||
}}/>
|
||||
<IconButton className="settingsButton" icon={<RotateCw/>}
|
||||
title={<Trans i18nKey="admin_settings.current_restart.value"/>} onClick={() => {
|
||||
settingsSocket!.emit('restartServer');
|
||||
}}/>
|
||||
export const SettingsPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const settingsSocket = useStore(state => state.settingsSocket);
|
||||
const settings = useStore(state => state.settings) ?? '';
|
||||
|
||||
const [mode, setMode] = useState<Mode>('form');
|
||||
const [exposeExperimental] = useState(false);
|
||||
|
||||
// Tab in textarea inserts two spaces instead of moving focus; rAF restores caret position after React re-renders.
|
||||
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);
|
||||
// Toast is shown by the saveprogress socket listener in App.tsx on server ack.
|
||||
settingsSocket.emit('saveSettings', settings);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="settings-page">
|
||||
<h1><Trans i18nKey="admin_settings.current" /></h1>
|
||||
|
||||
<ModeToggle mode={mode} onChange={setMode} />
|
||||
|
||||
{mode === 'form'
|
||||
? <FormView onSwitchToRaw={() => setMode('raw')} />
|
||||
: (
|
||||
<textarea
|
||||
value={settings}
|
||||
className="settings"
|
||||
data-testid="settings-raw-textarea"
|
||||
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}
|
||||
/>
|
||||
{exposeExperimental && (
|
||||
<IconButton
|
||||
className="settingsButton"
|
||||
data-testid="prettify-settings-button"
|
||||
icon={<AlignLeft />}
|
||||
title={<Trans i18nKey="admin_settings.current_prettify.value" />}
|
||||
onClick={prettifyJSON}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
className="settingsButton"
|
||||
data-testid="restart-etherpad-button"
|
||||
icon={<RotateCw />}
|
||||
title={<Trans i18nKey="admin_settings.current_restart.value" />}
|
||||
onClick={() => settingsSocket?.emit('restartServer')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="settings-links">
|
||||
<a rel="noopener noreferrer" target="_blank" href="https://github.com/ether/etherpad/wiki/Example-Production-Settings.JSON">
|
||||
<Trans i18nKey="admin_settings.current_example-prod" />
|
||||
</a>
|
||||
<a rel="noopener noreferrer" target="_blank" href="https://github.com/ether/etherpad/wiki/Example-Development-Settings.JSON">
|
||||
<Trans i18nKey="admin_settings.current_example-devel" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="separator"/>
|
||||
<div className="settings-button-bar">
|
||||
<a rel="noopener noreferrer" target="_blank"
|
||||
href="https://github.com/ether/etherpad-lite/wiki/Example-Production-Settings.JSON"><Trans
|
||||
i18nKey="admin_settings.current_example-prod"/></a>
|
||||
<a rel="noopener noreferrer" target="_blank"
|
||||
href="https://github.com/ether/etherpad-lite/wiki/Example-Development-Settings.JSON"><Trans
|
||||
i18nKey="admin_settings.current_example-devel"/></a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,17 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
// Inline `settings.json.template` at config time so the bundle has the
|
||||
// per-key documentation without expanding the dev server's filesystem
|
||||
// allowlist (which would otherwise serve every file in the repo root,
|
||||
// including settings.json and credentials.json, to anything that can
|
||||
// reach the dev server).
|
||||
const settingsTemplate = readFileSync(
|
||||
resolve(__dirname, '..', 'settings.json.template'),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
|
|
@ -11,6 +23,9 @@ export default defineConfig({
|
|||
}),
|
||||
],
|
||||
base: '/admin',
|
||||
define: {
|
||||
__SETTINGS_TEMPLATE__: JSON.stringify(settingsTemplate),
|
||||
},
|
||||
build: {
|
||||
outDir: '../src/templates/admin',
|
||||
emptyOutDir: true,
|
||||
|
|
|
|||
1353
docs/superpowers/plans/2026-05-09-admin-settings-parsed-view.md
Normal file
1353
docs/superpowers/plans/2026-05-09-admin-settings-parsed-view.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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.
|
||||
355
pnpm-lock.yaml
generated
355
pnpm-lock.yaml
generated
|
|
@ -52,6 +52,9 @@ importers:
|
|||
'@tanstack/react-query-devtools':
|
||||
specifier: ^5.100.10
|
||||
version: 5.100.10(@tanstack/react-query@5.100.10(react@19.2.6))(react@19.2.6)
|
||||
jsonc-parser:
|
||||
specifier: ^3.3.1
|
||||
version: 3.3.1
|
||||
openapi-fetch:
|
||||
specifier: ^0.17.0
|
||||
version: 0.17.0
|
||||
|
|
@ -1400,6 +1403,9 @@ packages:
|
|||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@oxc-project/types@0.128.0':
|
||||
resolution: {integrity: sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==}
|
||||
|
||||
'@oxc-project/types@0.129.0':
|
||||
resolution: {integrity: sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==}
|
||||
|
||||
|
|
@ -1708,30 +1714,60 @@ packages:
|
|||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@rolldown/binding-android-arm64@1.0.0-rc.18':
|
||||
resolution: {integrity: sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@rolldown/binding-darwin-arm64@1.0.0':
|
||||
resolution: {integrity: sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@rolldown/binding-darwin-arm64@1.0.0-rc.18':
|
||||
resolution: {integrity: sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@rolldown/binding-darwin-x64@1.0.0':
|
||||
resolution: {integrity: sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@rolldown/binding-darwin-x64@1.0.0-rc.18':
|
||||
resolution: {integrity: sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@rolldown/binding-freebsd-x64@1.0.0':
|
||||
resolution: {integrity: sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rolldown/binding-freebsd-x64@1.0.0-rc.18':
|
||||
resolution: {integrity: sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rolldown/binding-linux-arm-gnueabihf@1.0.0':
|
||||
resolution: {integrity: sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.18':
|
||||
resolution: {integrity: sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@rolldown/binding-linux-arm64-gnu@1.0.0':
|
||||
resolution: {integrity: sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
|
|
@ -1739,6 +1775,13 @@ packages:
|
|||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-arm64-gnu@1.0.0-rc.18':
|
||||
resolution: {integrity: sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-arm64-musl@1.0.0':
|
||||
resolution: {integrity: sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
|
|
@ -1746,6 +1789,13 @@ packages:
|
|||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.18':
|
||||
resolution: {integrity: sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-linux-ppc64-gnu@1.0.0':
|
||||
resolution: {integrity: sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
|
|
@ -1753,6 +1803,13 @@ packages:
|
|||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.18':
|
||||
resolution: {integrity: sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-s390x-gnu@1.0.0':
|
||||
resolution: {integrity: sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
|
|
@ -1760,6 +1817,13 @@ packages:
|
|||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.18':
|
||||
resolution: {integrity: sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-gnu@1.0.0':
|
||||
resolution: {integrity: sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
|
|
@ -1767,6 +1831,13 @@ packages:
|
|||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.18':
|
||||
resolution: {integrity: sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-musl@1.0.0':
|
||||
resolution: {integrity: sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
|
|
@ -1774,32 +1845,65 @@ packages:
|
|||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-linux-x64-musl@1.0.0-rc.18':
|
||||
resolution: {integrity: sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-openharmony-arm64@1.0.0':
|
||||
resolution: {integrity: sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@rolldown/binding-openharmony-arm64@1.0.0-rc.18':
|
||||
resolution: {integrity: sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@rolldown/binding-wasm32-wasi@1.0.0':
|
||||
resolution: {integrity: sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [wasm32]
|
||||
|
||||
'@rolldown/binding-wasm32-wasi@1.0.0-rc.18':
|
||||
resolution: {integrity: sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [wasm32]
|
||||
|
||||
'@rolldown/binding-win32-arm64-msvc@1.0.0':
|
||||
resolution: {integrity: sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@rolldown/binding-win32-arm64-msvc@1.0.0-rc.18':
|
||||
resolution: {integrity: sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@rolldown/binding-win32-x64-msvc@1.0.0':
|
||||
resolution: {integrity: sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@rolldown/binding-win32-x64-msvc@1.0.0-rc.18':
|
||||
resolution: {integrity: sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@rolldown/pluginutils@1.0.0':
|
||||
resolution: {integrity: sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==}
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-rc.18':
|
||||
resolution: {integrity: sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==}
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-rc.2':
|
||||
resolution: {integrity: sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==}
|
||||
|
||||
|
|
@ -1949,9 +2053,6 @@ packages:
|
|||
'@types/estree@1.0.8':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
'@types/estree@1.0.9':
|
||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||
|
||||
'@types/express-serve-static-core@5.1.0':
|
||||
resolution: {integrity: sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==}
|
||||
|
||||
|
|
@ -2042,8 +2143,11 @@ packages:
|
|||
'@types/node@18.19.130':
|
||||
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
|
||||
|
||||
'@types/node@24.12.3':
|
||||
resolution: {integrity: sha512-8oljBDGun9cIsZRJR6fkihn0TSXJI0UDOOhncYaERq6M0JMDoPLxyscwruJcb4GKS6dvK/d8xebYBg27h/duaQ==}
|
||||
'@types/node@24.12.2':
|
||||
resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==}
|
||||
|
||||
'@types/node@25.6.2':
|
||||
resolution: {integrity: sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==}
|
||||
|
||||
'@types/node@25.7.0':
|
||||
resolution: {integrity: sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==}
|
||||
|
|
@ -3205,8 +3309,8 @@ packages:
|
|||
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-module-lexer@2.1.0:
|
||||
resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==}
|
||||
es-module-lexer@2.0.0:
|
||||
resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==}
|
||||
|
||||
es-object-atoms@1.1.1:
|
||||
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
|
||||
|
|
@ -4122,6 +4226,9 @@ packages:
|
|||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
jsonc-parser@3.3.1:
|
||||
resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==}
|
||||
|
||||
jsonfile@4.0.0:
|
||||
resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==}
|
||||
|
||||
|
|
@ -5113,6 +5220,11 @@ packages:
|
|||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
|
||||
rolldown@1.0.0-rc.18:
|
||||
resolution: {integrity: sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
|
||||
router@2.2.0:
|
||||
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
|
||||
engines: {node: '>= 18'}
|
||||
|
|
@ -5536,8 +5648,8 @@ packages:
|
|||
tinycon@0.6.8:
|
||||
resolution: {integrity: sha512-bF8Lxm4JUXF6Cw0XlZdugJ44GV575OinZ0Pt8vQPr8ooNqd2yyNkoFdCHzmdpHlgoqfSLfcyk4HDP1EyllT+ug==}
|
||||
|
||||
tinyexec@1.1.2:
|
||||
resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==}
|
||||
tinyexec@1.1.1:
|
||||
resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
tinyglobby@0.2.16:
|
||||
|
|
@ -5686,11 +5798,14 @@ packages:
|
|||
undici-types@7.16.0:
|
||||
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
|
||||
|
||||
undici-types@7.19.2:
|
||||
resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==}
|
||||
|
||||
undici-types@7.21.0:
|
||||
resolution: {integrity: sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==}
|
||||
|
||||
undici-types@7.25.0:
|
||||
resolution: {integrity: sha512-AXNgS1Byr27fTI+2bsPEkV9CxkT8H6xNyRI68b3TatlZo3RkzlqQBLL+w7SmGPVpokjHbcuNVQUWE7FRTg+LRA==}
|
||||
undici-types@7.24.5:
|
||||
resolution: {integrity: sha512-kNh333UBSbgK35OIW7FwJTr9tTfVIG51Fm1tSVT7m8foPHfDVjsb7OIee/q/rs3bB2aV/3qOPgG5mHNWl1odiA==}
|
||||
|
||||
undici@7.25.0:
|
||||
resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==}
|
||||
|
|
@ -5812,6 +5927,49 @@ packages:
|
|||
'@babel/core': ^7.0.0
|
||||
vite: '>=7.3.2'
|
||||
|
||||
vite@8.0.11:
|
||||
resolution: {integrity: sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@types/node': ^20.19.0 || >=22.12.0
|
||||
'@vitejs/devtools': ^0.1.18
|
||||
esbuild: ^0.27.0 || ^0.28.0
|
||||
jiti: '>=1.21.0'
|
||||
less: ^4.0.0
|
||||
sass: ^1.70.0
|
||||
sass-embedded: ^1.70.0
|
||||
stylus: '>=0.54.8'
|
||||
sugarss: ^5.0.0
|
||||
terser: ^5.16.0
|
||||
tsx: ^4.8.1
|
||||
yaml: ^2.4.2
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
'@vitejs/devtools':
|
||||
optional: true
|
||||
esbuild:
|
||||
optional: true
|
||||
jiti:
|
||||
optional: true
|
||||
less:
|
||||
optional: true
|
||||
sass:
|
||||
optional: true
|
||||
sass-embedded:
|
||||
optional: true
|
||||
stylus:
|
||||
optional: true
|
||||
sugarss:
|
||||
optional: true
|
||||
terser:
|
||||
optional: true
|
||||
tsx:
|
||||
optional: true
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
vite@8.0.12:
|
||||
resolution: {integrity: sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
|
|
@ -6907,6 +7065,8 @@ snapshots:
|
|||
'@oxc-minify/binding-win32-x64-msvc@0.130.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-project/types@0.128.0': {}
|
||||
|
||||
'@oxc-project/types@0.129.0': {}
|
||||
|
||||
'@paralleldrive/cuid2@2.2.2':
|
||||
|
|
@ -7180,39 +7340,75 @@ snapshots:
|
|||
'@rolldown/binding-android-arm64@1.0.0':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-android-arm64@1.0.0-rc.18':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-darwin-arm64@1.0.0':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-darwin-arm64@1.0.0-rc.18':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-darwin-x64@1.0.0':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-darwin-x64@1.0.0-rc.18':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-freebsd-x64@1.0.0':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-freebsd-x64@1.0.0-rc.18':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-arm-gnueabihf@1.0.0':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.18':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-arm64-gnu@1.0.0':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-arm64-gnu@1.0.0-rc.18':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-arm64-musl@1.0.0':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.18':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-ppc64-gnu@1.0.0':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.18':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-s390x-gnu@1.0.0':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.18':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-x64-gnu@1.0.0':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.18':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-x64-musl@1.0.0':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-x64-musl@1.0.0-rc.18':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-openharmony-arm64@1.0.0':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-openharmony-arm64@1.0.0-rc.18':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-wasm32-wasi@1.0.0':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.10.0
|
||||
|
|
@ -7220,14 +7416,29 @@ snapshots:
|
|||
'@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-wasm32-wasi@1.0.0-rc.18':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.10.0
|
||||
'@emnapi/runtime': 1.10.0
|
||||
'@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-win32-arm64-msvc@1.0.0':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-win32-arm64-msvc@1.0.0-rc.18':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-win32-x64-msvc@1.0.0':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-win32-x64-msvc@1.0.0-rc.18':
|
||||
optional: true
|
||||
|
||||
'@rolldown/pluginutils@1.0.0': {}
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-rc.18': {}
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-rc.2': {}
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-rc.7': {}
|
||||
|
|
@ -7331,14 +7542,14 @@ snapshots:
|
|||
|
||||
'@types/accepts@1.3.7':
|
||||
dependencies:
|
||||
'@types/node': 25.7.0
|
||||
'@types/node': 25.6.2
|
||||
|
||||
'@types/async@3.2.25': {}
|
||||
|
||||
'@types/body-parser@1.19.6':
|
||||
dependencies:
|
||||
'@types/connect': 3.4.38
|
||||
'@types/node': 25.7.0
|
||||
'@types/node': 25.6.2
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
dependencies:
|
||||
|
|
@ -7351,7 +7562,7 @@ snapshots:
|
|||
|
||||
'@types/connect@3.4.38':
|
||||
dependencies:
|
||||
'@types/node': 25.7.0
|
||||
'@types/node': 25.6.2
|
||||
|
||||
'@types/content-disposition@0.5.9': {}
|
||||
|
||||
|
|
@ -7366,15 +7577,15 @@ snapshots:
|
|||
'@types/connect': 3.4.38
|
||||
'@types/express': 5.0.6
|
||||
'@types/keygrip': 1.0.6
|
||||
'@types/node': 25.7.0
|
||||
'@types/node': 25.6.2
|
||||
|
||||
'@types/cors@2.8.19':
|
||||
dependencies:
|
||||
'@types/node': 25.7.0
|
||||
'@types/node': 25.6.2
|
||||
|
||||
'@types/cross-spawn@6.0.6':
|
||||
dependencies:
|
||||
'@types/node': 25.7.0
|
||||
'@types/node': 25.6.2
|
||||
|
||||
'@types/debug@4.1.12':
|
||||
dependencies:
|
||||
|
|
@ -7388,11 +7599,9 @@ snapshots:
|
|||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
'@types/estree@1.0.9': {}
|
||||
|
||||
'@types/express-serve-static-core@5.1.0':
|
||||
dependencies:
|
||||
'@types/node': 25.7.0
|
||||
'@types/node': 25.6.2
|
||||
'@types/qs': 6.14.0
|
||||
'@types/range-parser': 1.2.7
|
||||
'@types/send': 1.2.1
|
||||
|
|
@ -7409,11 +7618,11 @@ snapshots:
|
|||
|
||||
'@types/formidable@3.5.1':
|
||||
dependencies:
|
||||
'@types/node': 25.7.0
|
||||
'@types/node': 25.6.2
|
||||
|
||||
'@types/fs-extra@9.0.13':
|
||||
dependencies:
|
||||
'@types/node': 25.7.0
|
||||
'@types/node': 25.6.2
|
||||
|
||||
'@types/hast@3.0.4':
|
||||
dependencies:
|
||||
|
|
@ -7429,10 +7638,10 @@ snapshots:
|
|||
|
||||
'@types/jsdom@28.0.3':
|
||||
dependencies:
|
||||
'@types/node': 25.7.0
|
||||
'@types/node': 25.6.2
|
||||
'@types/tough-cookie': 4.0.5
|
||||
parse5: 8.0.1
|
||||
undici-types: 7.25.0
|
||||
undici-types: 7.24.5
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
|
||||
|
|
@ -7443,7 +7652,7 @@ snapshots:
|
|||
'@types/jsonwebtoken@9.0.10':
|
||||
dependencies:
|
||||
'@types/ms': 2.1.0
|
||||
'@types/node': 25.7.0
|
||||
'@types/node': 25.6.2
|
||||
|
||||
'@types/keygrip@1.0.6': {}
|
||||
|
||||
|
|
@ -7460,7 +7669,7 @@ snapshots:
|
|||
'@types/http-errors': 2.0.5
|
||||
'@types/keygrip': 1.0.6
|
||||
'@types/koa-compose': 3.2.8
|
||||
'@types/node': 25.7.0
|
||||
'@types/node': 25.6.2
|
||||
|
||||
'@types/linkify-it@5.0.0': {}
|
||||
|
||||
|
|
@ -7489,17 +7698,21 @@ snapshots:
|
|||
|
||||
'@types/node-fetch@2.6.12':
|
||||
dependencies:
|
||||
'@types/node': 25.7.0
|
||||
'@types/node': 25.6.2
|
||||
form-data: 4.0.5
|
||||
|
||||
'@types/node@18.19.130':
|
||||
dependencies:
|
||||
undici-types: 5.26.5
|
||||
|
||||
'@types/node@24.12.3':
|
||||
'@types/node@24.12.2':
|
||||
dependencies:
|
||||
undici-types: 7.16.0
|
||||
|
||||
'@types/node@25.6.2':
|
||||
dependencies:
|
||||
undici-types: 7.19.2
|
||||
|
||||
'@types/node@25.7.0':
|
||||
dependencies:
|
||||
undici-types: 7.21.0
|
||||
|
|
@ -7508,11 +7721,11 @@ snapshots:
|
|||
dependencies:
|
||||
'@types/keygrip': 1.0.6
|
||||
'@types/koa': 3.0.0
|
||||
'@types/node': 25.7.0
|
||||
'@types/node': 25.6.2
|
||||
|
||||
'@types/pdfkit@0.17.6':
|
||||
dependencies:
|
||||
'@types/node': 25.7.0
|
||||
'@types/node': 25.6.2
|
||||
|
||||
'@types/qs@6.14.0': {}
|
||||
|
||||
|
|
@ -7528,29 +7741,29 @@ snapshots:
|
|||
|
||||
'@types/readable-stream@4.0.23':
|
||||
dependencies:
|
||||
'@types/node': 25.7.0
|
||||
'@types/node': 25.6.2
|
||||
|
||||
'@types/semver@7.7.1': {}
|
||||
|
||||
'@types/send@0.17.4':
|
||||
dependencies:
|
||||
'@types/mime': 1.3.5
|
||||
'@types/node': 25.7.0
|
||||
'@types/node': 25.6.2
|
||||
|
||||
'@types/send@1.2.1':
|
||||
dependencies:
|
||||
'@types/node': 25.7.0
|
||||
'@types/node': 25.6.2
|
||||
|
||||
'@types/serve-static@1.15.7':
|
||||
dependencies:
|
||||
'@types/http-errors': 2.0.5
|
||||
'@types/node': 25.7.0
|
||||
'@types/node': 25.6.2
|
||||
'@types/send': 0.17.4
|
||||
|
||||
'@types/serve-static@2.2.0':
|
||||
dependencies:
|
||||
'@types/http-errors': 2.0.5
|
||||
'@types/node': 25.7.0
|
||||
'@types/node': 25.6.2
|
||||
|
||||
'@types/sinon@21.0.1':
|
||||
dependencies:
|
||||
|
|
@ -7562,7 +7775,7 @@ snapshots:
|
|||
dependencies:
|
||||
'@types/cookiejar': 2.1.5
|
||||
'@types/methods': 1.1.4
|
||||
'@types/node': 25.7.0
|
||||
'@types/node': 25.6.2
|
||||
form-data: 4.0.5
|
||||
|
||||
'@types/supertest@7.2.0':
|
||||
|
|
@ -7577,7 +7790,7 @@ snapshots:
|
|||
|
||||
'@types/tar@6.1.13':
|
||||
dependencies:
|
||||
'@types/node': 25.7.0
|
||||
'@types/node': 25.6.2
|
||||
minipass: 4.2.8
|
||||
|
||||
'@types/tough-cookie@4.0.5': {}
|
||||
|
|
@ -7715,7 +7928,7 @@ snapshots:
|
|||
debug: 4.4.3(supports-color@8.1.1)
|
||||
globby: 11.1.0
|
||||
is-glob: 4.0.3
|
||||
minimatch: 10.2.5
|
||||
minimatch: 9.0.9
|
||||
semver: 7.8.0
|
||||
ts-api-utils: 1.4.3(typescript@6.0.3)
|
||||
optionalDependencies:
|
||||
|
|
@ -7834,10 +8047,10 @@ snapshots:
|
|||
optionalDependencies:
|
||||
babel-plugin-react-compiler: 19.1.0-rc.3
|
||||
|
||||
'@vitejs/plugin-vue@6.0.5(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(tsx@4.21.0))(vue@3.5.30(typescript@6.0.3))':
|
||||
'@vitejs/plugin-vue@6.0.5(vite@8.0.11(@types/node@25.7.0)(esbuild@0.28.0)(tsx@4.21.0))(vue@3.5.30(typescript@6.0.3))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.0-rc.2
|
||||
vite: 8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(tsx@4.21.0)
|
||||
vite: 8.0.11(@types/node@25.7.0)(esbuild@0.28.0)(tsx@4.21.0)
|
||||
vue: 3.5.30(typescript@6.0.3)
|
||||
|
||||
'@vitest/expect@4.1.6':
|
||||
|
|
@ -8034,7 +8247,7 @@ snapshots:
|
|||
'@swc/helpers': 0.5.21
|
||||
'@types/command-line-args': 5.2.3
|
||||
'@types/command-line-usage': 5.0.4
|
||||
'@types/node': 24.12.3
|
||||
'@types/node': 24.12.2
|
||||
command-line-args: 6.0.2
|
||||
command-line-usage: 7.0.4
|
||||
flatbuffers: 25.9.23
|
||||
|
|
@ -8619,7 +8832,7 @@ snapshots:
|
|||
engine.io@6.6.5:
|
||||
dependencies:
|
||||
'@types/cors': 2.8.19
|
||||
'@types/node': 25.7.0
|
||||
'@types/node': 25.6.2
|
||||
accepts: 1.3.8
|
||||
base64id: 2.0.0
|
||||
cookie: 0.7.2
|
||||
|
|
@ -8721,7 +8934,7 @@ snapshots:
|
|||
|
||||
es-errors@1.3.0: {}
|
||||
|
||||
es-module-lexer@2.1.0: {}
|
||||
es-module-lexer@2.0.0: {}
|
||||
|
||||
es-object-atoms@1.1.1:
|
||||
dependencies:
|
||||
|
|
@ -9049,7 +9262,7 @@ snapshots:
|
|||
|
||||
estree-walker@3.0.3:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.9
|
||||
'@types/estree': 1.0.8
|
||||
|
||||
esutils@2.0.3: {}
|
||||
|
||||
|
|
@ -9850,6 +10063,8 @@ snapshots:
|
|||
|
||||
json5@2.2.3: {}
|
||||
|
||||
jsonc-parser@3.3.1: {}
|
||||
|
||||
jsonfile@4.0.0:
|
||||
optionalDependencies:
|
||||
graceful-fs: 4.2.11
|
||||
|
|
@ -10700,7 +10915,7 @@ snapshots:
|
|||
proxy-agent@6.5.0:
|
||||
dependencies:
|
||||
agent-base: 7.1.3
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.1
|
||||
http-proxy-agent: 7.0.2
|
||||
https-proxy-agent: 7.0.6
|
||||
lru-cache: 7.18.3
|
||||
|
|
@ -10952,6 +11167,27 @@ snapshots:
|
|||
'@rolldown/binding-win32-arm64-msvc': 1.0.0
|
||||
'@rolldown/binding-win32-x64-msvc': 1.0.0
|
||||
|
||||
rolldown@1.0.0-rc.18:
|
||||
dependencies:
|
||||
'@oxc-project/types': 0.128.0
|
||||
'@rolldown/pluginutils': 1.0.0-rc.18
|
||||
optionalDependencies:
|
||||
'@rolldown/binding-android-arm64': 1.0.0-rc.18
|
||||
'@rolldown/binding-darwin-arm64': 1.0.0-rc.18
|
||||
'@rolldown/binding-darwin-x64': 1.0.0-rc.18
|
||||
'@rolldown/binding-freebsd-x64': 1.0.0-rc.18
|
||||
'@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.18
|
||||
'@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.18
|
||||
'@rolldown/binding-linux-arm64-musl': 1.0.0-rc.18
|
||||
'@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.18
|
||||
'@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.18
|
||||
'@rolldown/binding-linux-x64-gnu': 1.0.0-rc.18
|
||||
'@rolldown/binding-linux-x64-musl': 1.0.0-rc.18
|
||||
'@rolldown/binding-openharmony-arm64': 1.0.0-rc.18
|
||||
'@rolldown/binding-wasm32-wasi': 1.0.0-rc.18
|
||||
'@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.18
|
||||
'@rolldown/binding-win32-x64-msvc': 1.0.0-rc.18
|
||||
|
||||
router@2.2.0:
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
|
|
@ -11442,7 +11678,7 @@ snapshots:
|
|||
'@azure/identity': 4.13.1
|
||||
'@azure/keyvault-keys': 4.10.0(@azure/core-client@1.10.1)
|
||||
'@js-joda/core': 5.7.0
|
||||
'@types/node': 25.7.0
|
||||
'@types/node': 25.6.2
|
||||
bl: 6.1.6
|
||||
iconv-lite: 0.7.2
|
||||
js-md4: 0.3.2
|
||||
|
|
@ -11458,7 +11694,7 @@ snapshots:
|
|||
|
||||
tinycon@0.6.8: {}
|
||||
|
||||
tinyexec@1.1.2: {}
|
||||
tinyexec@1.1.1: {}
|
||||
|
||||
tinyglobby@0.2.16:
|
||||
dependencies:
|
||||
|
|
@ -11634,9 +11870,11 @@ snapshots:
|
|||
|
||||
undici-types@7.16.0: {}
|
||||
|
||||
undici-types@7.19.2: {}
|
||||
|
||||
undici-types@7.21.0: {}
|
||||
|
||||
undici-types@7.25.0: {}
|
||||
undici-types@7.24.5: {}
|
||||
|
||||
undici@7.25.0: {}
|
||||
|
||||
|
|
@ -11767,6 +12005,19 @@ snapshots:
|
|||
'@babel/core': 7.29.0
|
||||
vite: 8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(tsx@4.21.0)
|
||||
|
||||
vite@8.0.11(@types/node@25.7.0)(esbuild@0.28.0)(tsx@4.21.0):
|
||||
dependencies:
|
||||
lightningcss: 1.32.0
|
||||
picomatch: 4.0.4
|
||||
postcss: 8.5.14
|
||||
rolldown: 1.0.0-rc.18
|
||||
tinyglobby: 0.2.16
|
||||
optionalDependencies:
|
||||
'@types/node': 25.7.0
|
||||
esbuild: 0.28.0
|
||||
fsevents: 2.3.3
|
||||
tsx: 4.21.0
|
||||
|
||||
vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(tsx@4.21.0):
|
||||
dependencies:
|
||||
lightningcss: 1.32.0
|
||||
|
|
@ -11790,7 +12041,7 @@ snapshots:
|
|||
'@shikijs/transformers': 3.23.0
|
||||
'@shikijs/types': 3.23.0
|
||||
'@types/markdown-it': 14.1.2
|
||||
'@vitejs/plugin-vue': 6.0.5(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(tsx@4.21.0))(vue@3.5.30(typescript@6.0.3))
|
||||
'@vitejs/plugin-vue': 6.0.5(vite@8.0.11(@types/node@25.7.0)(esbuild@0.28.0)(tsx@4.21.0))(vue@3.5.30(typescript@6.0.3))
|
||||
'@vue/devtools-api': 8.1.0
|
||||
'@vue/shared': 3.5.30
|
||||
'@vueuse/core': 14.2.1(vue@3.5.30(typescript@6.0.3))
|
||||
|
|
@ -11799,7 +12050,7 @@ snapshots:
|
|||
mark.js: 8.11.1
|
||||
minisearch: 7.2.0
|
||||
shiki: 3.23.0
|
||||
vite: 8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(tsx@4.21.0)
|
||||
vite: 8.0.11(@types/node@25.7.0)(esbuild@0.28.0)(tsx@4.21.0)
|
||||
vue: 3.5.30(typescript@6.0.3)
|
||||
optionalDependencies:
|
||||
oxc-minify: 0.130.0
|
||||
|
|
@ -11839,7 +12090,7 @@ snapshots:
|
|||
'@vitest/snapshot': 4.1.6
|
||||
'@vitest/spy': 4.1.6
|
||||
'@vitest/utils': 4.1.6
|
||||
es-module-lexer: 2.1.0
|
||||
es-module-lexer: 2.0.0
|
||||
expect-type: 1.3.0
|
||||
magic-string: 0.30.21
|
||||
obug: 2.1.1
|
||||
|
|
@ -11847,7 +12098,7 @@ snapshots:
|
|||
picomatch: 4.0.4
|
||||
std-env: 4.1.0
|
||||
tinybench: 2.9.0
|
||||
tinyexec: 1.1.2
|
||||
tinyexec: 1.1.1
|
||||
tinyglobby: 0.2.16
|
||||
tinyrainbow: 3.1.0
|
||||
vite: 8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(tsx@4.21.0)
|
||||
|
|
|
|||
|
|
@ -117,6 +117,25 @@
|
|||
"admin_settings.current_restart.value": "Restart Etherpad",
|
||||
"admin_settings.current_save.value": "Save Settings",
|
||||
"admin_settings.invalid_json": "Invalid JSON",
|
||||
"admin_settings.current_test.value": "Validate JSON",
|
||||
"admin_settings.current_prettify.value": "Prettify JSON",
|
||||
"admin_settings.toast.saved": "Settings saved successfully.",
|
||||
"admin_settings.toast.save_failed": "Save failed: settings.json could not be written.",
|
||||
"admin_settings.toast.json_invalid": "Syntax error: check commas, braces, and quotes.",
|
||||
"admin_settings.toast.disconnected": "Cannot save: not connected to server.",
|
||||
"admin_settings.toast.validation_ok": "JSON is valid.",
|
||||
"admin_settings.toast.validation_failed": "JSON is invalid: please fix syntax errors.",
|
||||
"admin_settings.toast.prettify_failed": "Cannot prettify: please fix syntax errors first.",
|
||||
"admin_settings.prettify_confirm": "Prettifying will remove all comments. Continue?",
|
||||
"admin_settings.mode.form": "Form",
|
||||
"admin_settings.mode.raw": "Raw",
|
||||
"admin_settings.mode.aria_label": "Editor mode",
|
||||
"admin_settings.section.general": "General",
|
||||
"admin_settings.parse_error.title": "Cannot parse settings.json",
|
||||
"admin_settings.parse_error.cta": "Switch to raw to edit",
|
||||
"admin_settings.env_pill.tooltip": "Reads from the {{variable}} environment variable. The value below is used when {{variable}} is unset.",
|
||||
"admin_settings.env_pill.default_label": "default",
|
||||
"admin_settings.env_pill.input_aria": "Default value for {{variable}}",
|
||||
"admin_settings.page-title": "Settings - Etherpad",
|
||||
"admin_settings.save_error": "Error saving settings",
|
||||
"admin_settings.saved_success": "Successfully saved settings",
|
||||
|
|
|
|||
|
|
@ -46,10 +46,11 @@ exports.socketio = (hookName: string, {io}: any) => {
|
|||
logger.info('Admin request to save settings through a socket on /admin/settings');
|
||||
try {
|
||||
await fsp.writeFile(settings.settingsFilename, newSettings);
|
||||
socket.emit('saveprogress', 'saved');
|
||||
} catch (err) {
|
||||
logger.error(`Error saving settings: ${err}`);
|
||||
socket.emit('saveprogress', 'error', {message: String(err)});
|
||||
}
|
||||
socket.emit('saveprogress', 'saved');
|
||||
});
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ const ADMIN_URL = 'http://localhost:9001/admin';
|
|||
|
||||
const setErasureFlag = async (page: any, enabled: boolean) => {
|
||||
await page.goto(`${ADMIN_URL}/settings`);
|
||||
// /admin/settings now defaults to the parsed form view; the raw
|
||||
// textarea (.settings) is only mounted after switching modes.
|
||||
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
|
||||
await page.getByTestId('mode-toggle-raw').click();
|
||||
await page.waitForSelector('.settings');
|
||||
const settings = page.locator('.settings');
|
||||
await expect(settings).not.toHaveValue('', {timeout: 30000});
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ test.describe('admin settings',()=> {
|
|||
|
||||
test('Are Settings visible, populated, does save work', async ({page}) => {
|
||||
await page.goto('http://localhost:9001/admin/settings');
|
||||
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
|
||||
await page.getByTestId('mode-toggle-raw').click();
|
||||
await page.waitForSelector('.settings');
|
||||
const settings = page.locator('.settings');
|
||||
await expect(settings).not.toHaveValue('', {timeout: 30000});
|
||||
|
|
@ -29,6 +31,8 @@ test.describe('admin settings',()=> {
|
|||
|
||||
// Check if the changes were actually saved
|
||||
await page.reload()
|
||||
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
|
||||
await page.getByTestId('mode-toggle-raw').click();
|
||||
await page.waitForSelector('.settings');
|
||||
await expect(settings).not.toHaveValue('', {timeout: 30000});
|
||||
|
||||
|
|
@ -43,6 +47,8 @@ test.describe('admin settings',()=> {
|
|||
await saveSettings(page)
|
||||
|
||||
await page.reload()
|
||||
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
|
||||
await page.getByTestId('mode-toggle-raw').click();
|
||||
await page.waitForSelector('.settings');
|
||||
await expect(settings).not.toHaveValue('', {timeout: 30000});
|
||||
const oldSettings = page.locator('.settings');
|
||||
|
|
@ -51,15 +57,312 @@ test.describe('admin settings',()=> {
|
|||
expect(oldSettingsVal.length).toEqual(settingsLength)
|
||||
})
|
||||
|
||||
test('preserves /* */ comments after save round-trip', async ({page}) => {
|
||||
await page.goto('http://localhost:9001/admin/settings');
|
||||
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
|
||||
await page.getByTestId('mode-toggle-raw').click();
|
||||
await page.waitForSelector('.settings');
|
||||
const settings = page.locator('.settings');
|
||||
await expect(settings).not.toHaveValue('', {timeout: 30000});
|
||||
|
||||
const original = await settings.inputValue();
|
||||
const withComment = `/* takeover-pr-comment-marker */\n${original}`;
|
||||
await settings.fill(withComment);
|
||||
await saveSettings(page);
|
||||
|
||||
await page.reload();
|
||||
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
|
||||
await page.getByTestId('mode-toggle-raw').click();
|
||||
await page.waitForSelector('.settings');
|
||||
await expect(settings).not.toHaveValue('', {timeout: 30000});
|
||||
expect(await settings.inputValue()).toContain('takeover-pr-comment-marker');
|
||||
|
||||
// Restore original
|
||||
await settings.fill(original);
|
||||
await saveSettings(page);
|
||||
});
|
||||
|
||||
test('validate button toasts success on valid JSON and failure on invalid', async ({page}) => {
|
||||
await page.goto('http://localhost:9001/admin/settings');
|
||||
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
|
||||
await page.getByTestId('mode-toggle-raw').click();
|
||||
await page.waitForSelector('.settings');
|
||||
const settings = page.locator('.settings');
|
||||
await expect(settings).not.toHaveValue('', {timeout: 30000});
|
||||
|
||||
const original = await settings.inputValue();
|
||||
|
||||
await page.getByTestId('test-settings-button').click();
|
||||
await expect(page.locator('.ToastRootSuccess')).toBeVisible({timeout: 5000});
|
||||
|
||||
await settings.fill('{"broken":');
|
||||
await page.getByTestId('test-settings-button').click();
|
||||
await expect(page.locator('.ToastRootFailure')).toBeVisible({timeout: 5000});
|
||||
|
||||
// Invalid JSON must not be accepted by save either
|
||||
await page.getByTestId('save-settings-button').click();
|
||||
await expect(page.locator('.ToastRootFailure')).toBeVisible({timeout: 5000});
|
||||
|
||||
// Restore so subsequent tests have valid settings
|
||||
await settings.fill(original);
|
||||
await saveSettings(page);
|
||||
});
|
||||
|
||||
test('restart works', async function ({page}) {
|
||||
await page.goto('http://localhost:9001/admin/settings');
|
||||
await page.waitForSelector('.settings')
|
||||
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
|
||||
await page.getByTestId('mode-toggle-raw').click();
|
||||
await page.waitForSelector('.settings');
|
||||
await restartEtherpad(page)
|
||||
// Re-login after restart since session is lost
|
||||
await loginToAdmin(page, 'admin', 'changeme1')
|
||||
await page.goto('http://localhost:9001/admin/settings');
|
||||
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
|
||||
await page.getByTestId('mode-toggle-raw').click();
|
||||
await page.waitForSelector('.settings')
|
||||
const settings = page.locator('.settings');
|
||||
await expect(settings).not.toHaveValue('', {timeout: 30000});
|
||||
});
|
||||
|
||||
test('form view derives label + help text from key comment', async ({page}) => {
|
||||
await page.goto('http://localhost:9001/admin/settings');
|
||||
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
|
||||
// Inject a two-sentence comment above "title" so the first sentence becomes
|
||||
// the row label and the rest renders as help text.
|
||||
await page.getByTestId('mode-toggle-raw').click();
|
||||
const raw = page.getByTestId('settings-raw-textarea');
|
||||
await expect(raw).toBeVisible({timeout: 10000});
|
||||
const original = await raw.inputValue();
|
||||
const withComment = original.replace(
|
||||
'"title"',
|
||||
'/* CustomTitleLabel. ExtraHelpMarker about it. */\n"title"',
|
||||
);
|
||||
await raw.fill(withComment);
|
||||
await page.getByTestId('save-settings-button').click();
|
||||
await expect(page.locator('.ToastRootSuccess')).toBeVisible({timeout: 5000});
|
||||
|
||||
await page.getByTestId('mode-toggle-form').click();
|
||||
await page.waitForSelector('[data-testid="settings-form-view"]');
|
||||
// The injected text appears somewhere in the title row — either as label
|
||||
// (if no template comment precedes it) or as help text (if one does and
|
||||
// the comments are concatenated). Either way, it must be rendered.
|
||||
const titleLabel = page.locator('label[for="field-title"]');
|
||||
await expect(titleLabel).toBeVisible({timeout: 10000});
|
||||
const titleRow = titleLabel.locator('xpath=ancestor::*[contains(@class,"settings-row")][1]');
|
||||
await expect(titleRow).toContainText('CustomTitleLabel');
|
||||
await expect(titleRow).toContainText('ExtraHelpMarker');
|
||||
|
||||
// Restore
|
||||
await page.getByTestId('mode-toggle-raw').click();
|
||||
await raw.fill(original);
|
||||
await page.getByTestId('save-settings-button').click();
|
||||
await expect(page.locator('.ToastRootSuccess')).toBeVisible({timeout: 5000});
|
||||
});
|
||||
|
||||
test('form view falls back to template documentation when live comment is absent', async ({page}) => {
|
||||
await page.goto('http://localhost:9001/admin/settings');
|
||||
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
|
||||
// settings.json.template documents `port` with the env-var explainer.
|
||||
// Even if the live settings.json has no comment for port, the template
|
||||
// fallback should populate label + help.
|
||||
const portLabel = page.locator('label[for="field-port"]');
|
||||
await expect(portLabel).toBeVisible({timeout: 10000});
|
||||
// Label is a non-empty string (humanized 'Port' or template-derived).
|
||||
expect((await portLabel.textContent())?.trim().length ?? 0).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('editing title via form input round-trips through save', async ({page}) => {
|
||||
await page.goto('http://localhost:9001/admin/settings');
|
||||
// Wait for settings to load (form view renders once socket emits settings).
|
||||
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
|
||||
await page.getByTestId('mode-toggle-raw').click();
|
||||
const raw = page.getByTestId('settings-raw-textarea');
|
||||
await expect(raw).toBeVisible({timeout: 10000});
|
||||
const original = await raw.inputValue();
|
||||
|
||||
await page.getByTestId('mode-toggle-form').click();
|
||||
const titleField = page.getByTestId('field-title');
|
||||
await expect(titleField).toBeVisible({timeout: 10000});
|
||||
await titleField.fill('Etherpad-Form-Edit');
|
||||
await page.getByTestId('save-settings-button').click();
|
||||
await expect(page.locator('.ToastRootSuccess')).toBeVisible({timeout: 5000});
|
||||
|
||||
await page.reload();
|
||||
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
|
||||
await page.getByTestId('mode-toggle-raw').click();
|
||||
const after = await page.getByTestId('settings-raw-textarea').inputValue();
|
||||
// jsonc-parser modify() preserves the file's existing spacing style.
|
||||
// The shipped settings.json is compact (no spaces around colons).
|
||||
expect(after).toMatch(/"title"\s*:\s*"Etherpad-Form-Edit"/);
|
||||
// Other keys must still be present (file structure preserved).
|
||||
expect(after).toContain('"requireAuthentication"');
|
||||
|
||||
// Restore
|
||||
await page.getByTestId('settings-raw-textarea').fill(original);
|
||||
await page.getByTestId('save-settings-button').click();
|
||||
await expect(page.locator('.ToastRootSuccess')).toBeVisible({timeout: 5000});
|
||||
});
|
||||
|
||||
test('boolean toggle round-trips through save', async ({page}) => {
|
||||
await page.goto('http://localhost:9001/admin/settings');
|
||||
// Wait for settings to load (form view renders once socket emits settings).
|
||||
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
|
||||
await page.getByTestId('mode-toggle-raw').click();
|
||||
const original = await page.getByTestId('settings-raw-textarea').inputValue();
|
||||
|
||||
await page.getByTestId('mode-toggle-form').click();
|
||||
const toggle = page.getByTestId('field-requireAuthentication');
|
||||
await expect(toggle).toBeVisible({timeout: 10000});
|
||||
const before = await toggle.getAttribute('aria-checked');
|
||||
await toggle.click();
|
||||
await page.getByTestId('save-settings-button').click();
|
||||
await expect(page.locator('.ToastRootSuccess')).toBeVisible({timeout: 5000});
|
||||
|
||||
await page.reload();
|
||||
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
|
||||
const toggleAfter = page.getByTestId('field-requireAuthentication');
|
||||
await expect(toggleAfter).toBeVisible({timeout: 10000});
|
||||
const after = await toggleAfter.getAttribute('aria-checked');
|
||||
expect(after).not.toEqual(before);
|
||||
|
||||
// Restore
|
||||
await page.getByTestId('mode-toggle-raw').click();
|
||||
await page.getByTestId('settings-raw-textarea').fill(original);
|
||||
await page.getByTestId('save-settings-button').click();
|
||||
await expect(page.locator('.ToastRootSuccess')).toBeVisible({timeout: 5000});
|
||||
});
|
||||
|
||||
test('env placeholder default value is editable inline and persists to raw', async ({page}) => {
|
||||
await page.goto('http://localhost:9001/admin/settings');
|
||||
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
|
||||
|
||||
// Grab the original raw content so we can restore at the end.
|
||||
await page.getByTestId('mode-toggle-raw').click();
|
||||
const raw = page.getByTestId('settings-raw-textarea');
|
||||
await expect(raw).toBeVisible({timeout: 10000});
|
||||
const original = await raw.inputValue();
|
||||
|
||||
// Switch to form and edit sso.issuer's env-placeholder default inline.
|
||||
await page.getByTestId('mode-toggle-form').click();
|
||||
const input = page.getByTestId('env-sso.issuer').first();
|
||||
await expect(input).toBeVisible({timeout: 10000});
|
||||
// Sanity: this IS the editable <input> (env pill no longer read-only).
|
||||
expect(await input.evaluate((el) => el.tagName.toLowerCase())).toBe('input');
|
||||
await input.fill('http://edited.example:9001');
|
||||
await page.getByTestId('save-settings-button').click();
|
||||
await expect(page.locator('.ToastRootSuccess')).toBeVisible({timeout: 5000});
|
||||
|
||||
// Reload and confirm the raw JSON now embeds the new default.
|
||||
await page.reload();
|
||||
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
|
||||
await page.getByTestId('mode-toggle-raw').click();
|
||||
const after = await page.getByTestId('settings-raw-textarea').inputValue();
|
||||
expect(after).toContain('${SSO_ISSUER:http://edited.example:9001}');
|
||||
|
||||
// Restore.
|
||||
await page.getByTestId('settings-raw-textarea').fill(original);
|
||||
await page.getByTestId('save-settings-button').click();
|
||||
await expect(page.locator('.ToastRootSuccess')).toBeVisible({timeout: 5000});
|
||||
});
|
||||
|
||||
test('docker-like env-placeholder-heavy settings.json still has editable controls', async ({page}) => {
|
||||
// Regression: a settings.json where every value is "${VAR:default}"
|
||||
// (the shape of settings.json.docker) must NOT degrade the form into a
|
||||
// read-only viewer. Editing any env default round-trips through save.
|
||||
await page.goto('http://localhost:9001/admin/settings');
|
||||
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
|
||||
|
||||
await page.getByTestId('mode-toggle-raw').click();
|
||||
const raw = page.getByTestId('settings-raw-textarea');
|
||||
await expect(raw).toBeVisible({timeout: 10000});
|
||||
const original = await raw.inputValue();
|
||||
|
||||
// Replace with a minimal env-placeholder-heavy document.
|
||||
const envHeavy = JSON.stringify({
|
||||
title: '${TITLE:Etherpad-EnvHeavy}',
|
||||
port: '${PORT:9001}',
|
||||
ip: '${IP:0.0.0.0}',
|
||||
requireAuthentication: '${REQUIRE_AUTHENTICATION:false}',
|
||||
enableAdminUITests: true,
|
||||
users: {admin: {password: 'changeme1', is_admin: true}},
|
||||
});
|
||||
await raw.fill(envHeavy);
|
||||
await page.getByTestId('save-settings-button').click();
|
||||
await expect(page.locator('.ToastRootSuccess')).toBeVisible({timeout: 5000});
|
||||
|
||||
await page.reload();
|
||||
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
|
||||
|
||||
// Every visible env-pill row must expose an editable <input>, not a
|
||||
// read-only span.
|
||||
for (const id of ['env-title', 'env-port', 'env-ip', 'env-requireAuthentication']) {
|
||||
const el = page.getByTestId(id).first();
|
||||
await expect(el).toBeVisible({timeout: 10000});
|
||||
expect(await el.evaluate((n) => n.tagName.toLowerCase())).toBe('input');
|
||||
}
|
||||
|
||||
// Editing one of them round-trips.
|
||||
await page.getByTestId('env-title').fill('Etherpad-EnvEdit');
|
||||
await page.getByTestId('save-settings-button').click();
|
||||
await expect(page.locator('.ToastRootSuccess')).toBeVisible({timeout: 5000});
|
||||
|
||||
await page.reload();
|
||||
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
|
||||
await page.getByTestId('mode-toggle-raw').click();
|
||||
const after = await page.getByTestId('settings-raw-textarea').inputValue();
|
||||
expect(after).toContain('${TITLE:Etherpad-EnvEdit}');
|
||||
|
||||
// Restore original.
|
||||
await page.getByTestId('settings-raw-textarea').fill(original);
|
||||
await page.getByTestId('save-settings-button').click();
|
||||
await expect(page.locator('.ToastRootSuccess')).toBeVisible({timeout: 5000});
|
||||
});
|
||||
|
||||
// Regression for https://github.com/ether/etherpad/issues/7740.
|
||||
// settings.json.template uses same-line `/* … */` annotations for the
|
||||
// padShortcutEnabled keys, e.g.
|
||||
// "altF9": true, /* focus on the File Menu and/or editbar */
|
||||
// A previous heuristic in findLeading treated any line ending in `*/` as
|
||||
// a comment continuation, so each subsequent key's "leading comment"
|
||||
// absorbed every preceding sibling line. After the fix, altC's row must
|
||||
// render with a clean key-derived label and the trailing comment in the
|
||||
// help slot — and must not contain altF9's source line.
|
||||
test('#7740 trailing-comment key renders clean label, comment as help', async ({page}) => {
|
||||
await page.goto('http://localhost:9001/admin/settings');
|
||||
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
|
||||
|
||||
const altCLabel = page.locator('label[for="field-padShortcutEnabled.altC"]');
|
||||
await expect(altCLabel).toBeVisible({timeout: 10000});
|
||||
const labelText = (await altCLabel.textContent() ?? '').trim();
|
||||
expect(labelText).not.toMatch(/altF9/i);
|
||||
expect(labelText).not.toMatch(/focus on the File Menu/i);
|
||||
|
||||
const altCRow = altCLabel.locator(
|
||||
'xpath=ancestor::*[contains(@class,"settings-row")][1]',
|
||||
);
|
||||
await expect(altCRow).toContainText('focus on the Chat window');
|
||||
});
|
||||
|
||||
test('toggling form on broken raw JSON shows parse error banner', async ({page}) => {
|
||||
await page.goto('http://localhost:9001/admin/settings');
|
||||
// Wait for settings to load (form view renders once socket emits settings).
|
||||
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
|
||||
await page.getByTestId('mode-toggle-raw').click();
|
||||
const raw = page.getByTestId('settings-raw-textarea');
|
||||
await expect(raw).toBeVisible({timeout: 10000});
|
||||
const original = await raw.inputValue();
|
||||
|
||||
await raw.fill('{ "broken":');
|
||||
await page.getByTestId('mode-toggle-form').click();
|
||||
await expect(page.getByTestId('parse-error-banner')).toBeVisible();
|
||||
|
||||
// CTA returns to raw view
|
||||
await page.getByTestId('parse-error-switch-raw').click();
|
||||
await expect(raw).toBeVisible();
|
||||
|
||||
// Restore
|
||||
await raw.fill(original);
|
||||
await page.getByTestId('save-settings-button').click();
|
||||
await expect(page.locator('.ToastRootSuccess')).toBeVisible({timeout: 5000});
|
||||
});
|
||||
})
|
||||
|
|
|
|||
40
src/tests/frontend-new/admin-spec/focusloss.spec.ts
Normal file
40
src/tests/frontend-new/admin-spec/focusloss.spec.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import {expect, test} from "@playwright/test";
|
||||
import {loginToAdmin} from "../helper/adminhelper";
|
||||
|
||||
test.beforeEach(async ({ page })=>{
|
||||
await loginToAdmin(page, 'admin', 'changeme1');
|
||||
})
|
||||
|
||||
// Regression for the byte-offset React-key bug: when a JSON edit shifted
|
||||
// every sibling's character offset, React remounted every input — eating
|
||||
// focus mid-keystroke. The fix keys children on their JSONPath, which is
|
||||
// invariant under value-length changes.
|
||||
//
|
||||
// Using Playwright's `.fill()` on the source input would itself steal
|
||||
// focus before we could observe whether the target survived, so we drive
|
||||
// the source via the native value setter + a bubbling `input` event:
|
||||
// React's synthetic onChange fires and the form re-renders, but focus
|
||||
// stays on whatever the test put it on.
|
||||
test('editing a sibling array element does not lose focus on the focused one', async ({page}) => {
|
||||
await page.goto('http://localhost:9001/admin/settings');
|
||||
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
|
||||
|
||||
const firstInput = page.getByTestId('field-socketTransportProtocols.0');
|
||||
const secondInput = page.getByTestId('field-socketTransportProtocols.1');
|
||||
|
||||
await expect(firstInput).toBeVisible();
|
||||
await expect(secondInput).toBeVisible();
|
||||
|
||||
await secondInput.focus();
|
||||
await expect(secondInput).toBeFocused();
|
||||
|
||||
await firstInput.evaluate((el, val) => {
|
||||
const desc = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype, 'value',
|
||||
);
|
||||
desc?.set?.call(el, val);
|
||||
el.dispatchEvent(new Event('input', {bubbles: true}));
|
||||
}, 'websocket-long');
|
||||
|
||||
await expect(secondInput).toBeFocused();
|
||||
});
|
||||
|
|
@ -12,16 +12,19 @@ export const loginToAdmin = async (page: Page, username: string, password: strin
|
|||
|
||||
|
||||
export const saveSettings = async (page: Page) => {
|
||||
// Click save
|
||||
await page.locator('.settings-button-bar').locator('button').first().click()
|
||||
await page.waitForSelector('.ToastRootSuccess')
|
||||
// If a success toast is already open (from a previous save), wait for it to
|
||||
// close first so we don't mistake the stale open state for the new ack.
|
||||
// Radix Toast toggles data-state rather than removing the element.
|
||||
const existing = page.locator('.ToastRootSuccess[data-state="open"]');
|
||||
if (await existing.count() > 0) {
|
||||
await existing.waitFor({state: 'hidden'}).catch(() => {});
|
||||
}
|
||||
await page.getByTestId('save-settings-button').click();
|
||||
await page.waitForSelector('.ToastRootSuccess[data-state="open"]');
|
||||
}
|
||||
|
||||
export const restartEtherpad = async (page: Page) => {
|
||||
// Click restart
|
||||
const restartButton = page.locator('.settings-button-bar').locator('.settingsButton').nth(1)
|
||||
const settings = page.locator('.settings');
|
||||
await expect(settings).not.toHaveValue('', {timeout: 30000});
|
||||
const restartButton = page.getByTestId('restart-etherpad-button');
|
||||
await expect(restartButton).toBeVisible({timeout: 10000})
|
||||
await restartButton.click()
|
||||
// Wait for the server to come back up by polling.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue