diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/NewEntryDialog.test.ts b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/NewEntryDialog.test.ts index 5c6fe39d..461a8df3 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/NewEntryDialog.test.ts +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/NewEntryDialog.test.ts @@ -26,15 +26,19 @@ describe('nameProblem', () => { test('refuses a slash rather than quietly creating intermediate folders', () => { // `mkdir -p` semantics from a box labelled "Folder name" is a surprise, and the server's rename // endpoint refuses slashes for the same reason. - expect(nameProblem('a/b', [])).toBe('Name cannot contain "/"'); - expect(nameProblem('/abs', [])).toBe('Name cannot contain "/"'); + // + // Asserted on the KEY, not the English wording — a validation message is now translated (see + // NewEntryDialog.tsx), and a test pinned to the sentence would fail every time a translator edits + // it without the logic having changed at all. + expect(nameProblem('a/b', [])).toEqual({ key: 'newEntry.nameSlash' }); + expect(nameProblem('/abs', [])).toEqual({ key: 'newEntry.nameSlash' }); }); test('catches a collision before the request, which is the point', () => { - expect(nameProblem('notes.txt', ['notes.txt'])).toBe('"notes.txt" already exists here'); + expect(nameProblem('notes.txt', ['notes.txt'])).toEqual({ key: 'newEntry.nameExists', name: 'notes.txt' }); // Trimmed first, so trailing whitespace cannot smuggle a duplicate past the check and into a // server-side clobber refusal. - expect(nameProblem('notes.txt ', ['notes.txt'])).toBe('"notes.txt" already exists here'); + expect(nameProblem('notes.txt ', ['notes.txt'])).toEqual({ key: 'newEntry.nameExists', name: 'notes.txt' }); }); test('collision is case-SENSITIVE, matching the filesystem underneath', () => { @@ -59,7 +63,7 @@ describe('urlProblem', () => { }); test('refuses whitespace, the one thing that is always a typo', () => { - expect(urlProblem('https://host/a b.git')).toBe('A URL cannot contain spaces'); + expect(urlProblem('https://host/a b.git')).toEqual({ key: 'newEntry.urlSpaces' }); }); test('empty is not an error', () => { diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/NewEntryDialog.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/NewEntryDialog.tsx index 973307bd..f22c06d7 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/NewEntryDialog.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/NewEntryDialog.tsx @@ -11,6 +11,8 @@ import { } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import type { PanelTranslationKey } from '../../../../locales'; +import { useTranslation } from '../../../../locales'; // Naming a new file or folder. // @@ -31,32 +33,53 @@ type NewEntryDialogProps = { existingNames: string[]; }; +// Module-level data, so it carries catalog KEYS rather than English — the component resolves them. const COPY = { file: { - title: 'New file', - description: 'Include the extension — it is what the editor uses to pick a language. It opens once created.', - label: 'File name', - placeholder: 'notes.txt', - submit: 'Create and open', + titleKey: 'newEntry.file.title', + descriptionKey: 'newEntry.file.description', + labelKey: 'newEntry.file.label', + placeholderKey: 'newEntry.file.placeholder', + submitKey: 'newEntry.file.submit', Icon: FilePlus, }, folder: { - title: 'New folder', - description: 'Created in the folder you are viewing.', - label: 'Folder name', - placeholder: 'documents', - submit: 'Create folder', + titleKey: 'newEntry.folder.title', + descriptionKey: 'newEntry.folder.description', + labelKey: 'newEntry.folder.label', + placeholderKey: 'newEntry.folder.placeholder', + submitKey: 'newEntry.folder.submit', Icon: FolderPlus, }, clone: { - title: 'Clone repository', - description: 'Cloned into the folder you are viewing, under its own name.', - label: 'Repository URL', - placeholder: 'https://github.com/owner/repo.git', - submit: 'Clone', + titleKey: 'newEntry.clone.title', + descriptionKey: 'newEntry.clone.description', + labelKey: 'newEntry.clone.label', + placeholderKey: 'newEntry.clone.placeholder', + submitKey: 'newEntry.clone.submit', Icon: GitBranch, }, -} as const; +} satisfies Record< + string, + { + titleKey: PanelTranslationKey; + descriptionKey: PanelTranslationKey; + labelKey: PanelTranslationKey; + placeholderKey: PanelTranslationKey; + submitKey: PanelTranslationKey; + Icon: typeof FilePlus; + } +>; + +/** + * A validation failure, as a catalog key rather than English — the caller (a pure function with no + * hook in scope) cannot call `t()` itself. Mirrors `RfbError` in the Minesweeper panel: an explicit + * closed union, never a key built at runtime from the failure kind. + */ +export type NameProblem = + | { key: 'newEntry.nameReserved' } + | { key: 'newEntry.nameSlash' } + | { key: 'newEntry.nameExists'; name: string }; /** * Why a name is not usable, or null. @@ -65,12 +88,12 @@ const COPY = { * labelled "Folder name" is a surprise, and the server's rename endpoint refuses slashes for the same * reason. Leading dots are fine — a dotfile is a real thing to want. */ -export function nameProblem(raw: string, existing: string[]): string | null { +export function nameProblem(raw: string, existing: string[]): NameProblem | null { const name = raw.trim(); if (!name) return null; // not an error yet, just nothing to submit - if (name === '.' || name === '..') return 'That name is reserved'; - if (name.includes('/')) return 'Name cannot contain "/"'; - if (existing.includes(name)) return `"${name}" already exists here`; + if (name === '.' || name === '..') return { key: 'newEntry.nameReserved' }; + if (name.includes('/')) return { key: 'newEntry.nameSlash' }; + if (existing.includes(name)) return { key: 'newEntry.nameExists', name }; return null; } @@ -82,14 +105,15 @@ export function nameProblem(raw: string, existing: string[]): string | null { * a mistake git reports perfectly well itself. Only whitespace is refused, which is the one thing that * is always a typo. */ -export function urlProblem(raw: string): string | null { +export function urlProblem(raw: string): { key: 'newEntry.urlSpaces' } | null { const url = raw.trim(); if (!url) return null; - if (/\s/.test(url)) return 'A URL cannot contain spaces'; + if (/\s/.test(url)) return { key: 'newEntry.urlSpaces' }; return null; } export const NewEntryDialog = ({ kind, onClose, onSubmit, existingNames }: NewEntryDialogProps) => { + const { t } = useTranslation(); const [name, setName] = useState(''); // Clear on open rather than on close, so the field is never briefly showing the previous name as the @@ -100,9 +124,10 @@ export const NewEntryDialog = ({ kind, onClose, onSubmit, existingNames }: NewEn if (!kind) return null; - const { title, description, label, placeholder, submit, Icon } = COPY[kind]; + const { titleKey, descriptionKey, labelKey, placeholderKey, submitKey, Icon } = COPY[kind]; const problem = kind === 'clone' ? urlProblem(name) : nameProblem(name, existingNames); const canSubmit = name.trim().length > 0 && problem === null; + const problemText = problem ? t(problem.key, 'name' in problem ? { name: problem.name } : undefined) : ''; const commit = (ev?: React.FormEvent) => { ev?.preventDefault(); @@ -121,31 +146,31 @@ export const NewEntryDialog = ({ kind, onClose, onSubmit, existingNames }: NewEn - {title} + {t(titleKey)} - {description} + {t(descriptionKey)}
- + setName(ev.target.value)} /> {/* Reserves its own line whether or not there is a problem, so the dialog does not jump as you type past a colliding name. */} -

{problem ?? ''}

+

{problemText}

diff --git a/src/workspaces/officerdev/src/locales/en-GB.json b/src/workspaces/officerdev/src/locales/en-GB.json index 46d29c6a..37e1dc0d 100644 --- a/src/workspaces/officerdev/src/locales/en-GB.json +++ b/src/workspaces/officerdev/src/locales/en-GB.json @@ -547,5 +547,33 @@ "copyOutput": "Copy output", "track": "Track {{n}}", "runFromStep": "Run from step {{n}}" + }, + "newEntry": { + "nameReserved": "That name is reserved", + "nameSlash": "Name cannot contain \"/\"", + "nameExists": "\"{{name}}\" already exists here", + "urlSpaces": "A URL cannot contain spaces", + "cancel": "Cancel", + "file": { + "title": "New file", + "description": "Include the extension — it is what the editor uses to pick a language. It opens once created.", + "label": "File name", + "placeholder": "notes.txt", + "submit": "Create and open" + }, + "folder": { + "title": "New folder", + "description": "Created in the folder you are viewing.", + "label": "Folder name", + "placeholder": "documents", + "submit": "Create folder" + }, + "clone": { + "title": "Clone repository", + "description": "Cloned into the folder you are viewing, under its own name.", + "label": "Repository URL", + "placeholder": "https://github.com/owner/repo.git", + "submit": "Clone" + } } } diff --git a/src/workspaces/officerdev/src/locales/pt-PT.json b/src/workspaces/officerdev/src/locales/pt-PT.json index 078c79ca..87f61239 100644 --- a/src/workspaces/officerdev/src/locales/pt-PT.json +++ b/src/workspaces/officerdev/src/locales/pt-PT.json @@ -547,5 +547,33 @@ "copyOutput": "Copiar a saída", "track": "Faixa {{n}}", "runFromStep": "Executar a partir do passo {{n}}" + }, + "newEntry": { + "nameReserved": "Esse nome está reservado", + "nameSlash": "O nome não pode conter \"/\"", + "nameExists": "\"{{name}}\" já existe aqui", + "urlSpaces": "Um URL não pode conter espaços", + "cancel": "Cancelar", + "file": { + "title": "Novo ficheiro", + "description": "Inclua a extensão — é o que o editor usa para escolher a linguagem. Abre assim que for criado.", + "label": "Nome do ficheiro", + "placeholder": "notas.txt", + "submit": "Criar e abrir" + }, + "folder": { + "title": "Nova pasta", + "description": "Criada na pasta que está a ver.", + "label": "Nome da pasta", + "placeholder": "documentos", + "submit": "Criar pasta" + }, + "clone": { + "title": "Clonar repositório", + "description": "Clonado para a pasta que está a ver, com o seu próprio nome.", + "label": "URL do repositório", + "placeholder": "https://github.com/owner/repo.git", + "submit": "Clonar" + } } }