fix(web): localize built-in skill edit as a user-override flow (#4793)

* i18n(web): add built-in skill user-override strings

Add skillsOverrideCreate / skillsOverrideSave / skillsBuiltInOverrideWarning
to the typed dictionary and every locale, ahead of wiring the built-in
skill edit flow through them.

* fix(web): localize the built-in skill override confirmation

The built-in-skill edit warning was hardcoded English, so it stayed
English under non-English locales even though the rest of the dialog
followed the active language. Read it from the i18n dictionary instead.

* fix(web): label built-in skill editing as create/save user override

Editing a built-in skill silently produced a user override, but the
affordances read "Edit" / "Save" — so the built-in → user type change
was a surprise. The edit action, confirmation, and form heading now read
"Create user override" and the submit reads "Save as user override".

* test(web): lock built-in skill override wording across locales

Cover the regression gap raised on #4793: assert the built-in skill edit
flow renders the user-override i18n keys end to end — the edit tooltip, the
confirmation warning, the confirm button, the form heading, and the submit
button — in the default en locale and a translated zh-CN path, and that a
user skill keeps the plain Edit/Save affordance with no override warning.

Assertions compare against the locale dictionaries rather than hardcoded
literals, so a copy tweak in a locale file stays green while reverting to
the old Edit/Save keys (or a hardcoded-English warning) goes red.
This commit is contained in:
Aria Zhao
2026-07-10 13:33:06 +00:00
committed by GitHub
parent 10adca2cbf
commit 96bb1cc9b7
22 changed files with 173 additions and 9 deletions
+23 -9
View File
@@ -580,6 +580,10 @@ function SkillRow({
const summaryName = localizeSkillName(locale, skill) || skill.id;
const summaryDescription = localizeSkillDescription(locale, skill);
const canDelete = skill.source === 'user';
// Editing a built-in skill does not modify it in place — it writes a
// user-owned shadow copy. Frame the affordance as creating a user override
// so the built-in → user transition is not a surprise.
const isBuiltIn = skill.source !== 'user';
return (
<div
className={`skills-row${enabled ? '' : ' skills-row-disabled'}${
@@ -651,7 +655,11 @@ function SkillRow({
<Button
size="icon"
onClick={onStartEdit}
title={t('settings.skillsEdit')}
title={
isBuiltIn
? t('settings.skillsOverrideCreate')
: t('settings.skillsEdit')
}
data-testid="skills-edit"
>
<Icon name="edit" size={13} />
@@ -689,11 +697,7 @@ function SkillRow({
role="alert"
data-testid="skills-edit-builtin-warning"
>
<p>
Editing this built-in skill creates a user override. The built-in
entry will be hidden from the list until you delete the override.
Continue?
</p>
<p>{t('settings.skillsBuiltInOverrideWarning')}</p>
<div className="skills-edit-builtin-actions">
<button
type="button"
@@ -709,7 +713,7 @@ function SkillRow({
onClick={onConfirmBuiltInEdit}
data-testid="skills-edit-builtin-confirm"
>
{t('settings.skillsEdit')}
{t('settings.skillsOverrideCreate')}
</button>
</div>
</div>
@@ -759,13 +763,18 @@ function SkillRow({
{editing && draft ? (
<SkillDraftForm
heading={t('settings.skillsEdit')}
heading={
isBuiltIn
? t('settings.skillsOverrideCreate')
: t('settings.skillsEdit')
}
subheading={skill.id}
draft={draft}
setDraft={setDraft}
error={draftError}
saving={draftSaving}
isEdit
isBuiltInOverride={isBuiltIn}
onCancel={onCancelEdit}
onSubmit={onSubmitEdit}
/>
@@ -782,6 +791,8 @@ interface SkillDraftFormProps {
error: string | null;
saving: boolean;
isEdit: boolean;
/** Editing a built-in skill: the submit reads "Save as user override". */
isBuiltInOverride?: boolean;
onCancel: () => void;
onSubmit: () => void;
}
@@ -794,6 +805,7 @@ function SkillDraftForm({
error,
saving,
isEdit,
isBuiltInOverride = false,
onCancel,
onSubmit,
}: SkillDraftFormProps) {
@@ -876,7 +888,9 @@ function SkillDraftForm({
{saving
? t('settings.skillsSaving')
: isEdit
? t('settings.skillsSave')
? isBuiltInOverride
? t('settings.skillsOverrideSave')
: t('settings.skillsSave')
: t('settings.skillsCreate')}
</button>
</div>
+3
View File
@@ -3339,6 +3339,9 @@ export const ar: Dict = {
'settings.skillsCreate': 'إنشاء',
'settings.skillsSave': 'حفظ',
'settings.skillsSaving': 'جاري الحفظ…',
'settings.skillsOverrideCreate': 'إنشاء تجاوز المستخدم',
'settings.skillsOverrideSave': 'حفظ كتجاوز المستخدم',
'settings.skillsBuiltInOverrideWarning': 'يؤدي تعديل هذه المهارة المدمجة إلى إنشاء تجاوز خاص بالمستخدم. سيتم إخفاء العنصر المدمج من القائمة حتى تحذف التجاوز. هل تريد المتابعة؟',
'settings.skillsFiles': 'الملفات',
'settings.skillsNoFiles': 'لا توجد ملفات في مجلد هذه المهارة.',
'settings.skillsNameRequired': 'اسم المهارة مطلوب.',
+3
View File
@@ -3339,6 +3339,9 @@ export const de: Dict = {
'settings.skillsCreate': 'Erstellen',
'settings.skillsSave': 'Speichern',
'settings.skillsSaving': 'Speichern…',
'settings.skillsOverrideCreate': 'Benutzer-Override erstellen',
'settings.skillsOverrideSave': 'Als Benutzer-Override speichern',
'settings.skillsBuiltInOverrideWarning': 'Das Bearbeiten dieser integrierten Fähigkeit erstellt einen Benutzer-Override. Der integrierte Eintrag wird aus der Liste ausgeblendet, bis du den Override löschst. Fortfahren?',
'settings.skillsFiles': 'Dateien',
'settings.skillsNoFiles': 'Keine Dateien in diesem Skill-Ordner.',
'settings.skillsNameRequired': 'Skill-Name ist erforderlich.',
+3
View File
@@ -3377,6 +3377,9 @@ export const en: Dict = {
'settings.skillsCreate': 'Create',
'settings.skillsSave': 'Save',
'settings.skillsSaving': 'Saving…',
'settings.skillsOverrideCreate': 'Create user override',
'settings.skillsOverrideSave': 'Save as user override',
'settings.skillsBuiltInOverrideWarning': 'Editing this built-in skill creates a user override. The built-in entry will be hidden from the list until you delete the override. Continue?',
'settings.skillsFiles': 'Files',
'settings.skillsNoFiles': 'No files in this skill folder.',
'settings.skillsNameRequired': 'Skill name is required.',
+3
View File
@@ -3339,6 +3339,9 @@ export const esES: Dict = {
'settings.skillsCreate': 'Crear',
'settings.skillsSave': 'Guardar',
'settings.skillsSaving': 'Guardando…',
'settings.skillsOverrideCreate': 'Crear anulación de usuario',
'settings.skillsOverrideSave': 'Guardar como anulación de usuario',
'settings.skillsBuiltInOverrideWarning': 'Editar esta habilidad integrada crea una anulación de usuario. La entrada integrada se ocultará de la lista hasta que elimines la anulación. ¿Continuar?',
'settings.skillsFiles': 'Archivos',
'settings.skillsNoFiles': 'No hay archivos en esta carpeta de habilidad.',
'settings.skillsNameRequired': 'El nombre de la habilidad es obligatorio.',
+3
View File
@@ -3339,6 +3339,9 @@ export const fa: Dict = {
'settings.skillsCreate': 'ایجاد',
'settings.skillsSave': 'ذخیره',
'settings.skillsSaving': 'در حال ذخیره…',
'settings.skillsOverrideCreate': 'ایجاد بازنویسی کاربر',
'settings.skillsOverrideSave': 'ذخیره به‌عنوان بازنویسی کاربر',
'settings.skillsBuiltInOverrideWarning': 'ویرایش این مهارت داخلی یک بازنویسی کاربر ایجاد می‌کند. مورد داخلی تا زمانی که بازنویسی را حذف کنید از فهرست پنهان می‌شود. ادامه می‌دهید؟',
'settings.skillsFiles': 'فایل‌ها',
'settings.skillsNoFiles': 'هیچ فایلی در این پوشه مهارت نیست.',
'settings.skillsNameRequired': 'نام مهارت الزامی است.',
+3
View File
@@ -3339,6 +3339,9 @@ export const fr: Dict = {
'settings.skillsCreate': 'Créer',
'settings.skillsSave': 'Enregistrer',
'settings.skillsSaving': 'Enregistrement…',
'settings.skillsOverrideCreate': 'Créer une surcharge utilisateur',
'settings.skillsOverrideSave': 'Enregistrer comme surcharge utilisateur',
'settings.skillsBuiltInOverrideWarning': 'Modifier cette compétence intégrée crée une surcharge utilisateur. L\'entrée intégrée sera masquée de la liste jusqu\'à ce que vous supprimiez la surcharge. Continuer ?',
'settings.skillsFiles': 'Fichiers',
'settings.skillsNoFiles': 'Aucun fichier dans ce dossier de Skill.',
'settings.skillsNameRequired': 'Le nom du Skill est requis.',
+3
View File
@@ -3339,6 +3339,9 @@ export const hu: Dict = {
'settings.skillsCreate': 'Létrehozás',
'settings.skillsSave': 'Mentés',
'settings.skillsSaving': 'Mentés…',
'settings.skillsOverrideCreate': 'Felhasználói felülbírálás létrehozása',
'settings.skillsOverrideSave': 'Mentés felhasználói felülbírálásként',
'settings.skillsBuiltInOverrideWarning': 'A beépített készség szerkesztése felhasználói felülbírálást hoz létre. A beépített bejegyzés rejtve marad a listából, amíg nem törli a felülbírálást. Folytatja?',
'settings.skillsFiles': 'Fájlok',
'settings.skillsNoFiles': 'Nincs fájl ebben a készségmappában.',
'settings.skillsNameRequired': 'A készség neve kötelező.',
+3
View File
@@ -3339,6 +3339,9 @@ export const id: Dict = {
'settings.skillsCreate': 'Buat',
'settings.skillsSave': 'Simpan',
'settings.skillsSaving': 'Menyimpan…',
'settings.skillsOverrideCreate': 'Buat penggantian pengguna',
'settings.skillsOverrideSave': 'Simpan sebagai penggantian pengguna',
'settings.skillsBuiltInOverrideWarning': 'Mengedit keterampilan bawaan ini akan membuat penggantian pengguna. Entri bawaan akan disembunyikan dari daftar hingga Anda menghapus penggantian. Lanjutkan?',
'settings.skillsFiles': 'Berkas',
'settings.skillsNoFiles': 'Tidak ada berkas dalam folder keterampilan ini.',
'settings.skillsNameRequired': 'Nama keterampilan wajib diisi.',
+3
View File
@@ -3339,6 +3339,9 @@ export const it: Dict = {
'settings.skillsCreate': 'Crea',
'settings.skillsSave': 'Salva',
'settings.skillsSaving': 'Salvataggio…',
'settings.skillsOverrideCreate': 'Crea override utente',
'settings.skillsOverrideSave': 'Salva come override utente',
'settings.skillsBuiltInOverrideWarning': 'La modifica di questa competenza integrata crea un override utente. La voce integrata verrà nascosta dall\'elenco finché non elimini l\'override. Continuare?',
'settings.skillsFiles': 'File',
'settings.skillsNoFiles': 'Nessun file in questa cartella di competenza.',
'settings.skillsNameRequired': 'Il nome dello skill è obbligatorio.',
+3
View File
@@ -3339,6 +3339,9 @@ export const ja: Dict = {
'settings.skillsCreate': '作成',
'settings.skillsSave': '保存',
'settings.skillsSaving': '保存中…',
'settings.skillsOverrideCreate': 'ユーザー上書きを作成',
'settings.skillsOverrideSave': 'ユーザー上書きとして保存',
'settings.skillsBuiltInOverrideWarning': 'この組み込みスキルを編集すると、ユーザー上書きが作成されます。上書きを削除するまで、組み込みの項目は一覧から非表示になります。続行しますか?',
'settings.skillsFiles': 'ファイル',
'settings.skillsNoFiles': 'このスキルフォルダーにファイルはありません。',
'settings.skillsNameRequired': 'スキル名は必須です。',
+3
View File
@@ -3339,6 +3339,9 @@ export const ko: Dict = {
'settings.skillsCreate': '만들기',
'settings.skillsSave': '저장',
'settings.skillsSaving': '저장 중…',
'settings.skillsOverrideCreate': '사용자 재정의 만들기',
'settings.skillsOverrideSave': '사용자 재정의로 저장',
'settings.skillsBuiltInOverrideWarning': '이 기본 제공 스킬을 편집하면 사용자 재정의가 생성됩니다. 재정의를 삭제할 때까지 기본 제공 항목은 목록에서 숨겨집니다. 계속하시겠습니까?',
'settings.skillsFiles': '파일',
'settings.skillsNoFiles': '이 스킬 폴더에 파일이 없습니다.',
'settings.skillsNameRequired': '스킬 이름은 필수입니다.',
+3
View File
@@ -3339,6 +3339,9 @@ export const pl: Dict = {
'settings.skillsCreate': 'Utwórz',
'settings.skillsSave': 'Zapisz',
'settings.skillsSaving': 'Zapisywanie…',
'settings.skillsOverrideCreate': 'Utwórz nadpisanie użytkownika',
'settings.skillsOverrideSave': 'Zapisz jako nadpisanie użytkownika',
'settings.skillsBuiltInOverrideWarning': 'Edycja tej wbudowanej umiejętności tworzy nadpisanie użytkownika. Wbudowany wpis zostanie ukryty z listy, dopóki nie usuniesz nadpisania. Kontynuować?',
'settings.skillsFiles': 'Pliki',
'settings.skillsNoFiles': 'Brak plików w tym folderze umiejętności.',
'settings.skillsNameRequired': 'Nazwa umiejętności jest wymagana.',
+3
View File
@@ -3339,6 +3339,9 @@ export const ptBR: Dict = {
'settings.skillsCreate': 'Criar',
'settings.skillsSave': 'Salvar',
'settings.skillsSaving': 'Salvando…',
'settings.skillsOverrideCreate': 'Criar substituição do usuário',
'settings.skillsOverrideSave': 'Salvar como substituição do usuário',
'settings.skillsBuiltInOverrideWarning': 'Editar esta habilidade integrada cria uma substituição do usuário. A entrada integrada ficará oculta na lista até você excluir a substituição. Continuar?',
'settings.skillsFiles': 'Arquivos',
'settings.skillsNoFiles': 'Nenhum arquivo nesta pasta de habilidade.',
'settings.skillsNameRequired': 'O nome da habilidade é obrigatório.',
+3
View File
@@ -3339,6 +3339,9 @@ export const ru: Dict = {
'settings.skillsCreate': 'Создать',
'settings.skillsSave': 'Сохранить',
'settings.skillsSaving': 'Сохранение…',
'settings.skillsOverrideCreate': 'Создать пользовательское переопределение',
'settings.skillsOverrideSave': 'Сохранить как пользовательское переопределение',
'settings.skillsBuiltInOverrideWarning': 'Редактирование этого встроенного навыка создаёт пользовательское переопределение. Встроенная запись будет скрыта из списка, пока вы не удалите переопределение. Продолжить?',
'settings.skillsFiles': 'Файлы',
'settings.skillsNoFiles': 'В папке этого навыка нет файлов.',
'settings.skillsNameRequired': 'Имя навыка обязательно.',
+3
View File
@@ -3339,6 +3339,9 @@ export const th: Dict = {
'settings.skillsCreate': 'สร้าง',
'settings.skillsSave': 'บันทึก',
'settings.skillsSaving': 'กำลังบันทึก…',
'settings.skillsOverrideCreate': 'สร้างการแทนที่ของผู้ใช้',
'settings.skillsOverrideSave': 'บันทึกเป็นการแทนที่ของผู้ใช้',
'settings.skillsBuiltInOverrideWarning': 'การแก้ไขสกิลในตัวนี้จะสร้างการแทนที่ของผู้ใช้ รายการในตัวจะถูกซ่อนจากรายการจนกว่าคุณจะลบการแทนที่ ดำเนินการต่อหรือไม่?',
'settings.skillsFiles': 'ไฟล์',
'settings.skillsNoFiles': 'ไม่มีไฟล์ในโฟลเดอร์ทักษะนี้',
'settings.skillsNameRequired': 'ต้องระบุชื่อทักษะ',
+3
View File
@@ -3339,6 +3339,9 @@ export const tr: Dict = {
'settings.skillsCreate': 'Oluştur',
'settings.skillsSave': 'Kaydet',
'settings.skillsSaving': 'Kaydediliyor…',
'settings.skillsOverrideCreate': 'Kullanıcı geçersiz kılması oluştur',
'settings.skillsOverrideSave': 'Kullanıcı geçersiz kılması olarak kaydet',
'settings.skillsBuiltInOverrideWarning': 'Bu yerleşik beceriyi düzenlemek bir kullanıcı geçersiz kılması oluşturur. Geçersiz kılmayı silene kadar yerleşik giriş listeden gizlenir. Devam edilsin mi?',
'settings.skillsFiles': 'Dosyalar',
'settings.skillsNoFiles': 'Bu yetenek klasöründe dosya yok.',
'settings.skillsNameRequired': 'Beceri adı gereklidir.',
+3
View File
@@ -3339,6 +3339,9 @@ export const uk: Dict = {
'settings.skillsCreate': 'Створити',
'settings.skillsSave': 'Зберегти',
'settings.skillsSaving': 'Збереження…',
'settings.skillsOverrideCreate': 'Створити користувацьке перевизначення',
'settings.skillsOverrideSave': 'Зберегти як користувацьке перевизначення',
'settings.skillsBuiltInOverrideWarning': 'Редагування цього вбудованого вміння створює користувацьке перевизначення. Вбудований запис буде приховано зі списку, доки ви не видалите перевизначення. Продовжити?',
'settings.skillsFiles': 'Файли',
'settings.skillsNoFiles': 'У теці цієї навички немає файлів.',
'settings.skillsNameRequired': 'Ім\'я навички обов\'язкове.',
+4
View File
@@ -3588,6 +3588,10 @@ export const zhCN: Dict = {
"settings.skillsCreate": "创建",
"settings.skillsSave": "保存",
"settings.skillsSaving": "保存中…",
"settings.skillsOverrideCreate": "创建用户覆盖",
"settings.skillsOverrideSave": "另存为用户覆盖",
"settings.skillsBuiltInOverrideWarning":
"编辑此内置技能会创建一份用户覆盖。在你删除该覆盖之前,内置条目将从列表中隐藏。是否继续?",
"settings.skillsFiles": "文件",
"settings.skillsNoFiles": "该技能目录下暂无文件。",
"settings.skillsNameRequired": "技能名称为必填项。",
+4
View File
@@ -3597,6 +3597,10 @@ export const zhTW: Dict = {
"settings.skillsCreate": "建立",
"settings.skillsSave": "儲存",
"settings.skillsSaving": "儲存中…",
"settings.skillsOverrideCreate": "建立使用者覆寫",
"settings.skillsOverrideSave": "另存為使用者覆寫",
"settings.skillsBuiltInOverrideWarning":
"編輯此內建技能會建立一份使用者覆寫。在你刪除該覆寫之前,內建項目將從清單中隱藏。是否繼續?",
"settings.skillsFiles": "檔案",
"settings.skillsNoFiles": "此技能資料夾沒有檔案。",
"settings.skillsNameRequired": "技能名稱為必填項。",
+3
View File
@@ -567,6 +567,9 @@ export interface Dict {
'settings.skillsCreate': string;
'settings.skillsSave': string;
'settings.skillsSaving': string;
'settings.skillsOverrideCreate': string;
'settings.skillsOverrideSave': string;
'settings.skillsBuiltInOverrideWarning': string;
'settings.skillsFiles': string;
'settings.skillsNoFiles': string;
'settings.skillsNameRequired': string;
@@ -5,6 +5,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { SkillsSection } from '../../src/components/SkillsSection';
import { I18nProvider } from '../../src/i18n';
import { en } from '../../src/i18n/locales/en';
import { zhCN } from '../../src/i18n/locales/zh-CN';
import type { AppConfig } from '../../src/types';
import type { SkillSummary } from '@open-design/contracts';
@@ -224,6 +226,92 @@ describe('SkillsSection', () => {
expect(await within(row).findByTestId('skills-edit-form')).toBeTruthy();
});
// Regression for the nettee / lefarcen follow-up on PR #4793: editing a
// built-in skill must read as an explicit "user override" flow in every
// locale, never a hardcoded-English or plain "Edit" / "Save" affordance.
// Lock the override wording end to end — edit tooltip → confirmation →
// form heading → submit — in the default English locale and one translated
// zh-CN path, so a regression to the old wording (or to a missing locale
// key) goes red.
describe.each([
{ locale: 'en' as const, dict: en },
{ locale: 'zh-CN' as const, dict: zhCN },
])('built-in skill override wording ($locale)', ({ locale, dict }) => {
it('frames the edit affordance, confirmation, form, and submit as a user override', async () => {
renderSkillsSection(
[
makeSkill({
id: 'builtin-skill',
name: 'Built-in skill',
source: 'built-in',
}),
],
{ locale },
);
const row = await screen.findByTestId('skill-row-builtin-skill');
// The edit (pencil) tooltip reads "Create user override".
expect(within(row).getByTestId('skills-edit').title).toBe(
dict['settings.skillsOverrideCreate'],
);
fireEvent.click(within(row).getByTestId('skills-edit'));
// The inline confirmation shows the localized override warning and an
// override-framed confirm button — not the plain "Edit" label.
expect(
await within(row).findByText(dict['settings.skillsBuiltInOverrideWarning']),
).toBeTruthy();
expect(
within(row).getByTestId('skills-edit-builtin-confirm').textContent,
).toBe(dict['settings.skillsOverrideCreate']);
fireEvent.click(within(row).getByTestId('skills-edit-builtin-confirm'));
// The override form heading and submit both read as "user override".
const form = await within(row).findByTestId('skills-edit-form');
expect(within(form).getByRole('heading', { level: 4 }).textContent).toBe(
dict['settings.skillsOverrideCreate'],
);
expect(within(form).getByTestId('skills-save').textContent).toBe(
dict['settings.skillsOverrideSave'],
);
});
});
// The override wording must stay scoped to built-in skills: editing a
// user-authored skill keeps the plain Edit / Save affordance and never
// surfaces the override confirmation.
it('keeps the plain edit/save wording for user skills', async () => {
renderSkillsSection(
[
makeSkill({
id: 'user-skill',
name: 'User skill',
source: 'user',
}),
],
{ locale: 'en' },
);
const row = await screen.findByTestId('skill-row-user-skill');
expect(within(row).getByTestId('skills-edit').title).toBe(
en['settings.skillsEdit'],
);
fireEvent.click(within(row).getByTestId('skills-edit'));
expect(within(row).queryByTestId('skills-edit-builtin-warning')).toBeNull();
const form = await within(row).findByTestId('skills-edit-form');
expect(within(form).getByRole('heading', { level: 4 }).textContent).toBe(
en['settings.skillsEdit'],
);
expect(within(form).getByTestId('skills-save').textContent).toBe(
en['settings.skillsSave'],
);
});
it('matches localized built-in skill names and descriptions in search', async () => {
renderSkillsSection(
[