mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
feat: rename editable design systems from Settings + od CLI (#2812)
* feat: rename editable design systems from Settings + od CLI
Editable (user-created) design systems can already be renamed via
PATCH /api/design-systems/:id, but the capability was not surfaced
in the UI or CLI.
- Settings -> Design Systems: editable cards show a hover-reveal pencil
next to the name that opens a rename modal; built-in cards stay
read-only. Reuses common.rename/save/cancel (no new i18n keys).
- CLI: 'od design-systems rename <id> --title <new> [--json]', backed by
a unit-tested pure arg parser (design-system-rename-args.ts).
Both surfaces call the existing PATCH endpoint.
* Route od design-systems --help and -h to the rename-aware usage
The dispatcher only special-cased the `help` subcommand, so
`od design-systems --help` and `-h` fell through to the generic library
list, which advertises only `list` and `show`. That left `rename` off the
main discovery path even though this PR ships it.
Pulled the usage text and the help-arg check into a small pure module so
`help`, `--help`, and `-h` all render the same rename-aware usage, and added
a test that asserts the flag forms route to help and that the text lists
rename. The pure module keeps the assertion off process.exit / console.log.
* Reject --title flag-as-value and keep the rename modal open on failure
Two rename edge cases from review.
CLI: parseDesignSystemRenameArgs took the next token after --title
unconditionally, so `rename user:acme --title --json` parsed the title as
"--json" and could rename the system to a flag name instead of failing usage
validation. A separate --title value must now be a real token; a leading dash
means the user uses the --title=<value> form. Malformed inputs return null,
which the CLI surfaces as a usage error.
Web: commitRename closed the modal unconditionally, but updateDesignSystemDraft
returns null on any non-OK response or fetch failure, so a transient error
dropped the typed title with no feedback. The modal now stays open with the
title intact and shows an inline error on failure, matching the existing import
error pattern in this component. Added tests for the flag-as-value rejection
and for the failed-update modal state.
* Gate the rename completion on the active modal session
commitRename mutated the shared modal state after awaiting the PATCH, so a
slow rename for system A could resolve after the user cancelled and opened a
rename for system B, then close B's modal or show A's failure inside B's
dialog.
A monotonic session token (bumped whenever the modal opens or closes) is now
captured before the request and rechecked after it resolves. A stale
completion skips all modal-state updates. The list update for a successful
rename still applies, since that reflects a real server-side change regardless
of which modal is open. Added a regression test that opens a second rename
before the first PATCH settles and confirms the newer modal is untouched.
* Localize the rename-failed error instead of hardcoding English
The inline rename error was hardcoded English on a Settings surface that
otherwise runs through useT(), so non-English users saw English while the
rest of the panel was localized.
Added settings.designSystemRenameFailed to the typed dictionary and all 19
locale files, and the modal now reads it through t(). The translations are
adapted from each locale's existing settings.rescanFailed string ("X failed.
Check the daemon and try again."), swapping the verb to rename, so the daemon
and retry wording matches what those locales already ship.
This commit is contained in:
parent
024e6d86a9
commit
af997b7cf5
28 changed files with 517 additions and 2 deletions
|
|
@ -6,6 +6,8 @@ import { runArtifactsCli } from './artifacts-cli.js';
|
|||
import { runProjectHandoff } from './handoff-cli.js';
|
||||
import { runConnectorsToolCli } from './tools-connectors-cli.js';
|
||||
import { runDesignSystemsToolCli } from './tools-design-systems-cli.js';
|
||||
import { DESIGN_SYSTEMS_USAGE, isDesignSystemsHelpArg } from './design-systems-cli-help.js';
|
||||
import { parseDesignSystemRenameArgs } from './design-system-rename-args.js';
|
||||
import { runLiveArtifactsToolCli } from './tools-live-artifacts-cli.js';
|
||||
import { splitResearchSubcommand } from './research/cli-args.js';
|
||||
import { resolveDaemonUrl } from './daemon-url.js';
|
||||
|
|
@ -5067,9 +5069,53 @@ async function runLibraryList(name, args) {
|
|||
}
|
||||
|
||||
async function runSkills(args) { return runLibraryList('skills', args); }
|
||||
async function runDesignSystems(args) { return runLibraryList('design-systems', args); }
|
||||
async function runCraft(args) { return runLibraryList('craft', args); }
|
||||
|
||||
async function runDesignSystems(args) {
|
||||
if (args[0] === 'rename') return runDesignSystemRename(args.slice(1));
|
||||
if (!args[0] || isDesignSystemsHelpArg(args[0])) {
|
||||
console.log(DESIGN_SYSTEMS_USAGE);
|
||||
process.exit(isDesignSystemsHelpArg(args[0]) ? 0 : 2);
|
||||
}
|
||||
return runLibraryList('design-systems', args);
|
||||
}
|
||||
|
||||
// od design-systems rename <id> --title <new-title> [--json]
|
||||
// Renames an editable (user-created) design system via PATCH
|
||||
// /api/design-systems/:id. Built-in systems are read-only and the daemon
|
||||
// returns 404, surfaced here as a structured failure. Arg parsing lives in
|
||||
// design-system-rename-args.ts so it can be unit-tested.
|
||||
async function runDesignSystemRename(args) {
|
||||
if (args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h')) {
|
||||
console.log(`Usage:
|
||||
od design-systems rename <id> --title <new-title> [--json] [--daemon-url <url>]
|
||||
od design-systems rename <id> "<new title>" [--json]
|
||||
|
||||
Renames an editable (user-created) design system. Built-in systems are read-only.`);
|
||||
process.exit(args.length === 0 ? 2 : 0);
|
||||
}
|
||||
const parsed = parseDesignSystemRenameArgs(args);
|
||||
if (!parsed) {
|
||||
console.error('Usage: od design-systems rename <id> --title <new-title>');
|
||||
process.exit(2);
|
||||
}
|
||||
const flags = parseFlags(args, {
|
||||
string: new Set([...LIBRARY_STRING_FLAGS, 'title']),
|
||||
boolean: LIBRARY_BOOLEAN_FLAGS,
|
||||
});
|
||||
const base = (await libraryDaemonUrl(flags)).replace(/\/$/, '');
|
||||
const resp = await fetch(`${base}/api/design-systems/${encodeURIComponent(parsed.id)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: parsed.title }),
|
||||
});
|
||||
if (!resp.ok) return structuredHttpFailure(resp);
|
||||
const data = await resp.json();
|
||||
if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
||||
const renamed = data.designSystem ?? data;
|
||||
console.log(`Renamed ${parsed.id} -> ${renamed.title ?? parsed.title}`);
|
||||
}
|
||||
|
||||
async function runStatus(args) {
|
||||
// Alias of `od daemon status`.
|
||||
return runDaemon(['status', ...args]);
|
||||
|
|
|
|||
54
apps/daemon/src/design-system-rename-args.ts
Normal file
54
apps/daemon/src/design-system-rename-args.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
// Pure argument parser for `od design-systems rename <id> --title <new>`.
|
||||
// Kept out of cli.ts (a top-level dispatch script that runs on import) so it
|
||||
// can be unit-tested directly, mirroring research/cli-args.ts.
|
||||
//
|
||||
// Accepts the new name either as a `--title <value>` / `--title=<value>` flag
|
||||
// or as the trailing positional(s) after the id (so `rename <id> "New name"`
|
||||
// works). String flags that take a separate value (`--daemon-url <url>`, etc.)
|
||||
// have that value skipped so it is never mistaken for the id or title.
|
||||
|
||||
export interface DesignSystemRenameArgs {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const STRING_FLAGS_WITH_VALUE = new Set(['daemon-url', 'query', 'tag', 'title']);
|
||||
|
||||
// A separate flag value must be a real token, not the next flag. Without this
|
||||
// guard, `--title --json` would read "--json" as the title and rename the
|
||||
// system to a flag name. A leading dash means the user must use the
|
||||
// `--title=<value>` form for a title that genuinely starts with a dash.
|
||||
function isFlagValue(token: string | undefined): token is string {
|
||||
return token !== undefined && !token.startsWith('-');
|
||||
}
|
||||
|
||||
export function parseDesignSystemRenameArgs(args: string[]): DesignSystemRenameArgs | null {
|
||||
let flagTitle: string | undefined;
|
||||
const positionals: string[] = [];
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i]!;
|
||||
if (arg.startsWith('--')) {
|
||||
const eq = arg.indexOf('=');
|
||||
const key = eq >= 0 ? arg.slice(2, eq) : arg.slice(2);
|
||||
const inlineValue = eq >= 0 ? arg.slice(eq + 1) : undefined;
|
||||
if (key === 'title') {
|
||||
if (inlineValue !== undefined) {
|
||||
flagTitle = inlineValue;
|
||||
} else if (isFlagValue(args[i + 1])) {
|
||||
flagTitle = args[++i];
|
||||
}
|
||||
// else: `--title` with no real value -> leave it unset so the missing
|
||||
// title fails usage validation below instead of swallowing a flag.
|
||||
} else if (inlineValue === undefined && STRING_FLAGS_WITH_VALUE.has(key) && isFlagValue(args[i + 1])) {
|
||||
i++; // consume the separate flag value so it is not read as a positional
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('-')) continue; // short flag, no positional
|
||||
positionals.push(arg);
|
||||
}
|
||||
const id = positionals[0];
|
||||
const title = (flagTitle ?? positionals.slice(1).join(' ') ?? '').trim();
|
||||
if (!id || !title) return null;
|
||||
return { id, title };
|
||||
}
|
||||
15
apps/daemon/src/design-systems-cli-help.ts
Normal file
15
apps/daemon/src/design-systems-cli-help.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// Help surface for `od design-systems`. Kept pure and separate from cli.ts so a
|
||||
// test can assert the advertised subcommands without spawning the CLI or
|
||||
// stubbing process.exit / console.log.
|
||||
|
||||
export const DESIGN_SYSTEMS_USAGE = `Usage:
|
||||
od design-systems list List design systems.
|
||||
od design-systems show <id> Print one entry.
|
||||
od design-systems rename <id> --title <new> Rename an editable design system.`;
|
||||
|
||||
// `help`, `--help`, and `-h` all route to the usage text above. Without the
|
||||
// flag forms, `od design-systems --help` falls through to the generic library
|
||||
// list, which only advertises `list` and `show` and never mentions `rename`.
|
||||
export function isDesignSystemsHelpArg(arg: string | undefined): boolean {
|
||||
return arg === 'help' || arg === '--help' || arg === '-h';
|
||||
}
|
||||
66
apps/daemon/tests/design-system-rename-args.test.ts
Normal file
66
apps/daemon/tests/design-system-rename-args.test.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { parseDesignSystemRenameArgs } from '../src/design-system-rename-args.js';
|
||||
|
||||
describe('parseDesignSystemRenameArgs', () => {
|
||||
it('reads the id positional and the title from --title', () => {
|
||||
expect(parseDesignSystemRenameArgs(['user:acme', '--title', 'Acme v2'])).toEqual({
|
||||
id: 'user:acme',
|
||||
title: 'Acme v2',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts --title=<value>', () => {
|
||||
expect(parseDesignSystemRenameArgs(['user:acme', '--title=Acme v2'])).toEqual({
|
||||
id: 'user:acme',
|
||||
title: 'Acme v2',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to trailing positionals as the title', () => {
|
||||
expect(parseDesignSystemRenameArgs(['user:acme', 'Acme', 'v2'])).toEqual({
|
||||
id: 'user:acme',
|
||||
title: 'Acme v2',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not mistake a --daemon-url value for the id or title', () => {
|
||||
expect(
|
||||
parseDesignSystemRenameArgs([
|
||||
'--daemon-url',
|
||||
'http://127.0.0.1:7456',
|
||||
'user:acme',
|
||||
'--title',
|
||||
'Acme v2',
|
||||
]),
|
||||
).toEqual({ id: 'user:acme', title: 'Acme v2' });
|
||||
});
|
||||
|
||||
it('returns null when the title is missing', () => {
|
||||
expect(parseDesignSystemRenameArgs(['user:acme'])).toBeNull();
|
||||
expect(parseDesignSystemRenameArgs(['user:acme', '--title', ' '])).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the id is missing', () => {
|
||||
expect(parseDesignSystemRenameArgs(['--title', 'Acme v2'])).toBeNull();
|
||||
expect(parseDesignSystemRenameArgs([])).toBeNull();
|
||||
});
|
||||
|
||||
it('does not treat a following flag as the --title value', () => {
|
||||
expect(parseDesignSystemRenameArgs(['user:acme', '--title', '--json'])).toBeNull();
|
||||
expect(
|
||||
parseDesignSystemRenameArgs(['user:acme', '--title', '--daemon-url', 'http://127.0.0.1:7456']),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when --title is the last token with no value', () => {
|
||||
expect(parseDesignSystemRenameArgs(['user:acme', '--title'])).toBeNull();
|
||||
});
|
||||
|
||||
it('still accepts a dash-leading title via the --title=<value> form', () => {
|
||||
expect(parseDesignSystemRenameArgs(['user:acme', '--title=-dash-brand'])).toEqual({
|
||||
id: 'user:acme',
|
||||
title: '-dash-brand',
|
||||
});
|
||||
});
|
||||
});
|
||||
27
apps/daemon/tests/design-systems-cli-help.test.ts
Normal file
27
apps/daemon/tests/design-systems-cli-help.test.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
DESIGN_SYSTEMS_USAGE,
|
||||
isDesignSystemsHelpArg,
|
||||
} from '../src/design-systems-cli-help.js';
|
||||
|
||||
describe('od design-systems help surface', () => {
|
||||
it('routes help, --help, and -h to the usage text', () => {
|
||||
expect(isDesignSystemsHelpArg('help')).toBe(true);
|
||||
expect(isDesignSystemsHelpArg('--help')).toBe(true);
|
||||
expect(isDesignSystemsHelpArg('-h')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not treat subcommands or a missing arg as a help request', () => {
|
||||
expect(isDesignSystemsHelpArg('list')).toBe(false);
|
||||
expect(isDesignSystemsHelpArg('show')).toBe(false);
|
||||
expect(isDesignSystemsHelpArg('rename')).toBe(false);
|
||||
expect(isDesignSystemsHelpArg(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('advertises rename alongside list and show so the surface cannot drift', () => {
|
||||
expect(DESIGN_SYSTEMS_USAGE).toContain('list');
|
||||
expect(DESIGN_SYSTEMS_USAGE).toContain('show');
|
||||
expect(DESIGN_SYSTEMS_USAGE).toContain('rename');
|
||||
});
|
||||
});
|
||||
|
|
@ -6,8 +6,10 @@ import {
|
|||
fetchDesignSystems,
|
||||
importGitHubDesignSystem,
|
||||
importLocalDesignSystem,
|
||||
updateDesignSystemDraft,
|
||||
} from '../providers/registry';
|
||||
import { DesignSystemPreviewModal } from './DesignSystemPreviewModal';
|
||||
import { Icon } from './Icon';
|
||||
import { orderDesignSystemGroups } from './design-system-group-order';
|
||||
|
||||
// Sibling Settings section that hosts the design-systems registry.
|
||||
|
|
@ -34,6 +36,14 @@ export function DesignSystemsSection({ cfg, setCfg }: Props) {
|
|||
const [search, setSearch] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState('All');
|
||||
const [previewSystem, setPreviewSystem] = useState<DesignSystemSummary | null>(null);
|
||||
const [renameTarget, setRenameTarget] = useState<{ id: string; original: string } | null>(null);
|
||||
const [renameInput, setRenameInput] = useState('');
|
||||
const [renameError, setRenameError] = useState<string | null>(null);
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
// Monotonic token for the active rename modal session. Bumped whenever the
|
||||
// modal opens or closes so a slow PATCH that resolves after the user has
|
||||
// moved on cannot clobber a newer session's modal state.
|
||||
const renameSessionRef = useRef(0);
|
||||
const [importPath, setImportPath] = useState('');
|
||||
const [importSource, setImportSource] = useState<'local' | 'github'>('local');
|
||||
const [packageImportMode, setPackageImportMode] = useState<'normalized' | 'hybrid' | 'verbatim'>('hybrid');
|
||||
|
|
@ -128,6 +138,60 @@ export function DesignSystemsSection({ cfg, setCfg }: Props) {
|
|||
});
|
||||
}
|
||||
|
||||
function startRename(ds: DesignSystemSummary) {
|
||||
renameSessionRef.current += 1;
|
||||
setRenameTarget({ id: ds.id, original: ds.title });
|
||||
setRenameInput(ds.title);
|
||||
setRenameError(null);
|
||||
}
|
||||
|
||||
function cancelRename() {
|
||||
renameSessionRef.current += 1;
|
||||
setRenameTarget(null);
|
||||
setRenameError(null);
|
||||
setRenaming(false);
|
||||
}
|
||||
|
||||
// Rename an editable design system via PATCH /api/design-systems/:id, then
|
||||
// reflect the new title in the local list (re-sorted to keep card order
|
||||
// stable). Built-in systems never reach here — the button is editable-only.
|
||||
async function commitRename() {
|
||||
if (!renameTarget || renaming) return;
|
||||
const trimmed = renameInput.trim();
|
||||
if (!trimmed || trimmed === renameTarget.original) {
|
||||
cancelRename();
|
||||
return;
|
||||
}
|
||||
const session = renameSessionRef.current;
|
||||
const targetId = renameTarget.id;
|
||||
setRenaming(true);
|
||||
setRenameError(null);
|
||||
const updated = await updateDesignSystemDraft(targetId, { title: trimmed });
|
||||
if (updated) {
|
||||
// The rename happened server-side, so reflect it in the list even if the
|
||||
// user has since moved to another rename session.
|
||||
setDesignSystems((current) =>
|
||||
current
|
||||
.map((d) => (d.id === targetId ? { ...d, title: updated.title } : d))
|
||||
.sort((a, b) => a.title.localeCompare(b.title)),
|
||||
);
|
||||
}
|
||||
// Ignore a stale completion: the user cancelled or opened another rename
|
||||
// while this PATCH was in flight, so the modal state now belongs to a
|
||||
// different session and must not be touched.
|
||||
if (renameSessionRef.current !== session) return;
|
||||
setRenaming(false);
|
||||
// updateDesignSystemDraft returns null on any non-OK response or fetch
|
||||
// failure. Keep the modal open with the typed title intact so a transient
|
||||
// daemon/network error can be retried instead of silently disappearing.
|
||||
if (!updated) {
|
||||
setRenameError(t('settings.designSystemRenameFailed'));
|
||||
return;
|
||||
}
|
||||
setRenameTarget(null);
|
||||
setRenameError(null);
|
||||
}
|
||||
|
||||
function clearImportFeedback() {
|
||||
setImportError(null);
|
||||
setImportMessage(null);
|
||||
|
|
@ -442,7 +506,24 @@ export function DesignSystemsSection({ cfg, setCfg }: Props) {
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="library-ds-title">{ds.title}</div>
|
||||
<div className="library-ds-title">
|
||||
<span className="library-ds-title-text">{ds.title}</span>
|
||||
{ds.source === 'user' || ds.isEditable === true ? (
|
||||
<button
|
||||
type="button"
|
||||
className="library-ds-edit"
|
||||
title={t('common.rename')}
|
||||
aria-label={`${t('common.rename')} ${ds.title}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startRename(ds);
|
||||
}}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Icon name="pencil" size={13} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="library-ds-summary">{ds.summary}</div>
|
||||
</div>
|
||||
<div className="library-ds-toggle-cell">
|
||||
|
|
@ -475,6 +556,52 @@ export function DesignSystemsSection({ cfg, setCfg }: Props) {
|
|||
onClose={() => setPreviewSystem(null)}
|
||||
/>
|
||||
) : null}
|
||||
{renameTarget ? (
|
||||
<div className="modal-backdrop" onClick={cancelRename}>
|
||||
<form
|
||||
className="modal modal-rename"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void commitRename();
|
||||
}}
|
||||
>
|
||||
<h2>{t('common.rename')}</h2>
|
||||
<label>
|
||||
<input
|
||||
type="text"
|
||||
value={renameInput}
|
||||
autoFocus
|
||||
aria-label={t('common.rename')}
|
||||
onChange={(e) => setRenameInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cancelRename();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{renameError ? <p className="library-install-error">{renameError}</p> : null}
|
||||
<div className="row">
|
||||
<button type="button" onClick={cancelRename}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="primary"
|
||||
disabled={
|
||||
renaming ||
|
||||
!renameInput.trim() ||
|
||||
renameInput.trim() === renameTarget.original
|
||||
}
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -184,6 +184,7 @@ export const ar: Dict = {
|
|||
'settings.rescanTitle': 'إعادة مسح PATH',
|
||||
'settings.rescanRunning': 'جارٍ المسح...',
|
||||
'settings.rescanSuccess': 'اكتمل المسح. {count} متاح.',
|
||||
'settings.designSystemRenameFailed': 'فشلت إعادة التسمية. تحقق من البرنامج الخفي وحاول مرة أخرى.',
|
||||
'settings.rescanFailed': 'فشل المسح. تحقق من البرنامج الخفي وحاول مرة أخرى.',
|
||||
'settings.test': 'اختبار',
|
||||
'settings.testTitle': 'أرسل مطالبة اختبار صغيرة للتحقق من الاتصال',
|
||||
|
|
|
|||
|
|
@ -184,6 +184,7 @@ export const de: Dict = {
|
|||
'settings.rescanTitle': 'PATH erneut scannen',
|
||||
'settings.rescanRunning': 'Scannen...',
|
||||
'settings.rescanSuccess': 'Scan abgeschlossen. {count} verfuegbar.',
|
||||
'settings.designSystemRenameFailed': 'Umbenennen fehlgeschlagen. Pruefen Sie den Daemon und versuchen Sie es erneut.',
|
||||
'settings.rescanFailed': 'Scan fehlgeschlagen. Pruefen Sie den Daemon und versuchen Sie es erneut.',
|
||||
'settings.test': 'Test',
|
||||
'settings.testTitle': 'Sende einen winzigen Testprompt, um die Verbindung zu pruefen',
|
||||
|
|
|
|||
|
|
@ -170,6 +170,7 @@ export const en: Dict = {
|
|||
'settings.rescanTitle': 'Re-scan PATH',
|
||||
'settings.rescanRunning': 'Scanning...',
|
||||
'settings.rescanSuccess': 'Scan complete. {count} available.',
|
||||
'settings.designSystemRenameFailed': 'Rename failed. Check that the daemon is running and try again.',
|
||||
'settings.rescanFailed': 'Scan failed. Check the daemon and try again.',
|
||||
'settings.test': 'Test',
|
||||
'settings.testTitle': 'Send a tiny test prompt to verify the connection',
|
||||
|
|
|
|||
|
|
@ -184,6 +184,7 @@ export const esES: Dict = {
|
|||
'settings.rescanTitle': 'Reescanear PATH',
|
||||
'settings.rescanRunning': 'Escaneando...',
|
||||
'settings.rescanSuccess': 'Escaneo completado. {count} disponibles.',
|
||||
'settings.designSystemRenameFailed': 'No se pudo cambiar el nombre. Comprueba el daemon e inténtalo de nuevo.',
|
||||
'settings.rescanFailed': 'El escaneo falló. Comprueba el daemon e inténtalo de nuevo.',
|
||||
'settings.test': 'Probar',
|
||||
'settings.testTitle': 'Envía un mensaje minúsculo de prueba para verificar la conexión',
|
||||
|
|
|
|||
|
|
@ -184,6 +184,7 @@ export const fa: Dict = {
|
|||
'settings.rescanTitle': 'اسکن مجدد PATH',
|
||||
'settings.rescanRunning': 'در حال اسکن...',
|
||||
'settings.rescanSuccess': 'اسکن کامل شد. {count} مورد در دسترس است.',
|
||||
'settings.designSystemRenameFailed': 'تغییر نام ناموفق بود. daemon را بررسی کنید و دوباره تلاش کنید.',
|
||||
'settings.rescanFailed': 'اسکن ناموفق بود. daemon را بررسی کنید و دوباره تلاش کنید.',
|
||||
'settings.test': 'آزمایش',
|
||||
'settings.testTitle': 'یک پیام کوچک آزمایشی برای راستیآزمایی اتصال ارسال کنید',
|
||||
|
|
|
|||
|
|
@ -184,6 +184,7 @@ export const fr: Dict = {
|
|||
'settings.rescanTitle': 'Réanalyser le PATH',
|
||||
'settings.rescanRunning': 'Analyse...',
|
||||
'settings.rescanSuccess': 'Analyse terminée. {count} disponible(s).',
|
||||
'settings.designSystemRenameFailed': 'Le renommage a échoué. Vérifiez le daemon et réessayez.',
|
||||
'settings.rescanFailed': 'L’analyse a échoué. Vérifiez le daemon et réessayez.',
|
||||
'settings.test': 'Tester',
|
||||
'settings.testTitle': 'Envoie un tout petit message de test pour vérifier la connexion',
|
||||
|
|
|
|||
|
|
@ -184,6 +184,7 @@ export const hu: Dict = {
|
|||
'settings.rescanTitle': 'PATH újraellenőrzése',
|
||||
'settings.rescanRunning': 'Ellenőrzés...',
|
||||
'settings.rescanSuccess': 'Ellenőrzés kész. {count} elérhető.',
|
||||
'settings.designSystemRenameFailed': 'Az átnevezés sikertelen. Ellenőrizd a daemont, majd próbáld újra.',
|
||||
'settings.rescanFailed': 'Az ellenőrzés sikertelen. Ellenőrizd a daemont, majd próbáld újra.',
|
||||
'settings.test': 'Teszt',
|
||||
'settings.testTitle': 'Küldj egy apró tesztkérdést a kapcsolat ellenőrzéséhez',
|
||||
|
|
|
|||
|
|
@ -183,6 +183,7 @@ export const id: Dict = {
|
|||
'settings.rescanTitle': 'Pindai ulang PATH',
|
||||
'settings.rescanRunning': 'Memindai...',
|
||||
'settings.rescanSuccess': 'Pemindaian selesai. {count} tersedia.',
|
||||
'settings.designSystemRenameFailed': 'Gagal mengganti nama. Periksa daemon lalu coba lagi.',
|
||||
'settings.rescanFailed': 'Pemindaian gagal. Periksa daemon lalu coba lagi.',
|
||||
'settings.test': 'Tes',
|
||||
'settings.testTitle': 'Kirim prompt kecil untuk memverifikasi koneksi',
|
||||
|
|
|
|||
|
|
@ -183,6 +183,7 @@ export const it: Dict = {
|
|||
'settings.rescanTitle': 'Rianalizza il PATH',
|
||||
'settings.rescanRunning': 'Analisi in corso...',
|
||||
'settings.rescanSuccess': 'Analisi completata. {count} disponibile/i.',
|
||||
'settings.designSystemRenameFailed': 'Rinomina non riuscita. Controlla il daemon e riprova.',
|
||||
'settings.rescanFailed': 'Analisi fallita. Controlla il daemon e riprova.',
|
||||
'settings.test': 'Testa',
|
||||
'settings.testTitle': 'Invia un piccolo messaggio di test per verificare la connessione',
|
||||
|
|
|
|||
|
|
@ -184,6 +184,7 @@ export const ja: Dict = {
|
|||
'settings.rescanTitle': 'PATH を再スキャン',
|
||||
'settings.rescanRunning': 'スキャン中...',
|
||||
'settings.rescanSuccess': 'スキャン完了。{count} 件が利用可能です。',
|
||||
'settings.designSystemRenameFailed': '名前の変更に失敗しました。デーモンを確認して再試行してください。',
|
||||
'settings.rescanFailed': 'スキャンに失敗しました。デーモンを確認して再試行してください。',
|
||||
'settings.test': 'テスト',
|
||||
'settings.testTitle': '接続を確認するため、ごく短いテストプロンプトを送信します',
|
||||
|
|
|
|||
|
|
@ -184,6 +184,7 @@ export const ko: Dict = {
|
|||
'settings.rescanTitle': 'PATH 다시 스캔',
|
||||
'settings.rescanRunning': '스캔 중...',
|
||||
'settings.rescanSuccess': '스캔 완료. {count}개 사용 가능.',
|
||||
'settings.designSystemRenameFailed': '이름 변경에 실패했습니다. 데몬을 확인한 후 다시 시도하세요.',
|
||||
'settings.rescanFailed': '스캔에 실패했습니다. 데몬을 확인한 후 다시 시도하세요.',
|
||||
'settings.test': '테스트',
|
||||
'settings.testTitle': '연결을 확인하기 위해 아주 작은 테스트 프롬프트를 전송합니다',
|
||||
|
|
|
|||
|
|
@ -184,6 +184,7 @@ export const pl: Dict = {
|
|||
'settings.rescanTitle': 'Ponownie skanuj PATH',
|
||||
'settings.rescanRunning': 'Skanowanie...',
|
||||
'settings.rescanSuccess': 'Skanowanie zakończone. Dostępne: {count}.',
|
||||
'settings.designSystemRenameFailed': 'Zmiana nazwy nie powiodła się. Sprawdź daemon i spróbuj ponownie.',
|
||||
'settings.rescanFailed': 'Skanowanie nie powiodło się. Sprawdź daemon i spróbuj ponownie.',
|
||||
'settings.test': 'Test',
|
||||
'settings.testTitle': 'Wyślij maleńki monit testowy, aby zweryfikować połączenie',
|
||||
|
|
|
|||
|
|
@ -184,6 +184,7 @@ export const ptBR: Dict = {
|
|||
'settings.rescanTitle': 'Reescanear PATH',
|
||||
'settings.rescanRunning': 'Escaneando...',
|
||||
'settings.rescanSuccess': 'Escaneamento concluido. {count} disponiveis.',
|
||||
'settings.designSystemRenameFailed': 'Falha ao renomear. Verifique o daemon e tente novamente.',
|
||||
'settings.rescanFailed': 'Falha ao escanear. Verifique o daemon e tente novamente.',
|
||||
'settings.test': 'Testar',
|
||||
'settings.testTitle': 'Envie um pequeno prompt de teste para verificar a conexão',
|
||||
|
|
|
|||
|
|
@ -184,6 +184,7 @@ export const ru: Dict = {
|
|||
'settings.rescanTitle': 'Пересканировать PATH',
|
||||
'settings.rescanRunning': 'Сканирование...',
|
||||
'settings.rescanSuccess': 'Сканирование завершено. Доступно: {count}.',
|
||||
'settings.designSystemRenameFailed': 'Не удалось переименовать. Проверьте демон и повторите попытку.',
|
||||
'settings.rescanFailed': 'Сканирование не удалось. Проверьте демон и повторите попытку.',
|
||||
'settings.test': 'Тест',
|
||||
'settings.testTitle': 'Отправить крошечный тестовый запрос для проверки соединения',
|
||||
|
|
|
|||
|
|
@ -183,6 +183,7 @@ export const th: Dict = {
|
|||
'settings.rescanTitle': 'สแกน PATH ใหม่',
|
||||
'settings.rescanRunning': 'กำลังสแกน...',
|
||||
'settings.rescanSuccess': 'สแกนเสร็จสิ้น พบ {count} ตัวที่ใช้งานได้',
|
||||
'settings.designSystemRenameFailed': 'การเปลี่ยนชื่อล้มเหลว ตรวจสอบ daemon แล้วลองอีกครั้ง',
|
||||
'settings.rescanFailed': 'การสแกนล้มเหลว ตรวจสอบ daemon แล้วลองอีกครั้ง',
|
||||
'settings.test': 'ทดสอบ',
|
||||
'settings.testTitle': 'ส่งข้อความทดสอบขนาดเล็กเพื่อยืนยันการเชื่อมต่อ',
|
||||
|
|
|
|||
|
|
@ -184,6 +184,7 @@ export const tr: Dict = {
|
|||
'settings.rescanTitle': 'PATH’ı yeniden tara',
|
||||
'settings.rescanRunning': 'Taranıyor...',
|
||||
'settings.rescanSuccess': 'Tarama tamamlandı. {count} kullanılabilir.',
|
||||
'settings.designSystemRenameFailed': 'Yeniden adlandırma başarısız. Daemon’u kontrol edip tekrar deneyin.',
|
||||
'settings.rescanFailed': 'Tarama başarısız. Daemon’u kontrol edip tekrar deneyin.',
|
||||
'settings.test': 'Test',
|
||||
'settings.testTitle': 'Bağlantıyı doğrulamak için minik bir test istemi gönderin',
|
||||
|
|
|
|||
|
|
@ -184,6 +184,7 @@ export const uk: Dict = {
|
|||
'settings.rescanTitle': 'Повторно сканувати PATH',
|
||||
'settings.rescanRunning': 'Сканування...',
|
||||
'settings.rescanSuccess': 'Сканування завершено. Доступно: {count}.',
|
||||
'settings.designSystemRenameFailed': 'Не вдалося перейменувати. Перевірте фоновий процес і спробуйте ще раз.',
|
||||
'settings.rescanFailed':
|
||||
'Сканування не вдалося. Перевірте фоновий процес і спробуйте ще раз.',
|
||||
'settings.test': 'Перевірити',
|
||||
|
|
|
|||
|
|
@ -170,6 +170,7 @@ export const zhCN: Dict = {
|
|||
'settings.rescanTitle': '重新扫描 PATH',
|
||||
'settings.rescanRunning': '扫描中...',
|
||||
'settings.rescanSuccess': '扫描完成,{count} 个可用。',
|
||||
'settings.designSystemRenameFailed': '重命名失败,请检查后台守护进程后重试。',
|
||||
'settings.rescanFailed': '扫描失败,请检查后台守护进程后重试。',
|
||||
'settings.test': '测试',
|
||||
'settings.testTitle': '发送极小的测试提示词以验证连接',
|
||||
|
|
|
|||
|
|
@ -183,6 +183,7 @@ export const zhTW: Dict = {
|
|||
'settings.rescanTitle': '重新掃描 PATH',
|
||||
'settings.rescanRunning': '掃描中...',
|
||||
'settings.rescanSuccess': '掃描完成,{count} 個可用。',
|
||||
'settings.designSystemRenameFailed': '重新命名失敗,請檢查背景守護程序後重試。',
|
||||
'settings.rescanFailed': '掃描失敗,請檢查背景守護程序後重試。',
|
||||
'settings.test': '測試',
|
||||
'settings.testTitle': '傳送極小的測試提示以驗證連線',
|
||||
|
|
|
|||
|
|
@ -376,6 +376,7 @@ export interface Dict {
|
|||
'settings.designSystems': string;
|
||||
'settings.designSystemsHint': string;
|
||||
'settings.designSystemsInstalled': string;
|
||||
'settings.designSystemRenameFailed': string;
|
||||
'settings.designSystemsAdd': string;
|
||||
'settings.designSystemsHiddenCount': string;
|
||||
'settings.designSystemsShowAll': string;
|
||||
|
|
|
|||
|
|
@ -20262,6 +20262,39 @@ body.desktop-pet-shell .pet-task-item {
|
|||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* Hover-reveal pencil to rename an editable (user-created) design system in
|
||||
Settings. Sits inline after the title; only rendered for editable cards, so
|
||||
built-in cards are unchanged. Issue #2811. */
|
||||
.library-ds-edit {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
vertical-align: middle;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin-left: 4px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 160ms cubic-bezier(0.23, 1, 0.32, 1),
|
||||
color 160ms cubic-bezier(0.23, 1, 0.32, 1),
|
||||
background 160ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
.library-ds-card-content:hover .library-ds-edit,
|
||||
.library-ds-edit:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
.library-ds-edit:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-muted);
|
||||
}
|
||||
|
||||
.library-preview {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
|
|
|
|||
127
apps/web/tests/components/DesignSystemsSection.test.tsx
Normal file
127
apps/web/tests/components/DesignSystemsSection.test.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { DesignSystemSummary } from '@open-design/contracts';
|
||||
|
||||
import { DesignSystemsSection } from '../../src/components/DesignSystemsSection';
|
||||
import { fetchDesignSystems, updateDesignSystemDraft } from '../../src/providers/registry';
|
||||
import type { AppConfig } from '../../src/types';
|
||||
|
||||
const editable: DesignSystemSummary = {
|
||||
id: 'user:acme',
|
||||
title: 'Acme Design System',
|
||||
category: 'Custom',
|
||||
summary: 'Internal product system.',
|
||||
surface: 'web',
|
||||
source: 'user',
|
||||
status: 'draft',
|
||||
isEditable: true,
|
||||
updatedAt: '2026-05-13T03:19:00.000Z',
|
||||
};
|
||||
|
||||
const builtIn: DesignSystemSummary = {
|
||||
id: 'linear',
|
||||
title: 'Linear',
|
||||
category: 'Productivity & SaaS',
|
||||
summary: 'Quiet issue-tracker system.',
|
||||
surface: 'web',
|
||||
source: 'built-in',
|
||||
status: 'published',
|
||||
isEditable: false,
|
||||
};
|
||||
|
||||
vi.mock('../../src/providers/registry', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../src/providers/registry')>(
|
||||
'../../src/providers/registry',
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
fetchDesignSystems: vi.fn(async () => [editable, builtIn]),
|
||||
updateDesignSystemDraft: vi.fn(async () => ({ ...editable, title: 'Acme v2', body: '' })),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const cfg = { disabledDesignSystems: [] } as unknown as AppConfig;
|
||||
|
||||
describe('DesignSystemsSection rename (issue #2811)', () => {
|
||||
it('renames an editable design system from Settings', async () => {
|
||||
render(<DesignSystemsSection cfg={cfg} setCfg={() => {}} />);
|
||||
|
||||
const renameButton = await screen.findByRole('button', {
|
||||
name: /Rename Acme Design System/i,
|
||||
});
|
||||
fireEvent.click(renameButton);
|
||||
|
||||
const input = screen.getByDisplayValue('Acme Design System');
|
||||
fireEvent.change(input, { target: { value: 'Acme v2' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Save$/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(updateDesignSystemDraft)).toHaveBeenCalledWith('user:acme', {
|
||||
title: 'Acme v2',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the rename modal open with the typed title when the update fails', async () => {
|
||||
vi.mocked(updateDesignSystemDraft).mockResolvedValueOnce(null);
|
||||
render(<DesignSystemsSection cfg={cfg} setCfg={() => {}} />);
|
||||
|
||||
const renameButton = await screen.findByRole('button', {
|
||||
name: /Rename Acme Design System/i,
|
||||
});
|
||||
fireEvent.click(renameButton);
|
||||
|
||||
const input = screen.getByDisplayValue('Acme Design System');
|
||||
fireEvent.change(input, { target: { value: 'Acme v2' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Save$/ }));
|
||||
|
||||
// A failed update must not close the modal; the typed title stays for retry.
|
||||
await screen.findByText(/Rename failed/i);
|
||||
expect(screen.getByDisplayValue('Acme v2')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('ignores a stale rename completion when a newer rename session is open', async () => {
|
||||
const editableB: DesignSystemSummary = { ...editable, id: 'user:beta', title: 'Beta System' };
|
||||
vi.mocked(fetchDesignSystems).mockResolvedValueOnce([editable, editableB, builtIn]);
|
||||
let resolveFirst!: (value: null) => void;
|
||||
vi.mocked(updateDesignSystemDraft).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<null>((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
render(<DesignSystemsSection cfg={cfg} setCfg={() => {}} />);
|
||||
|
||||
// Session 1: rename Acme and submit; the PATCH stays pending.
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Rename Acme Design System/i }));
|
||||
fireEvent.change(screen.getByDisplayValue('Acme Design System'), { target: { value: 'Acme v2' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Save$/ }));
|
||||
|
||||
// Cancel Acme and open a rename for Beta before the first PATCH resolves.
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Cancel$/ }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Rename Beta System/i }));
|
||||
expect(screen.getByDisplayValue('Beta System')).toBeTruthy();
|
||||
|
||||
// The stale Acme request now fails; it must not touch Beta's modal.
|
||||
resolveFirst(null);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(screen.getByDisplayValue('Beta System')).toBeTruthy();
|
||||
expect(screen.queryByText(/Rename failed/i)).toBeNull();
|
||||
});
|
||||
|
||||
it('offers no Rename for built-in (read-only) design systems', async () => {
|
||||
render(<DesignSystemsSection cfg={cfg} setCfg={() => {}} />);
|
||||
await screen.findByText('Linear');
|
||||
expect(screen.queryByRole('button', { name: /Rename Linear/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue