diff --git a/.changeset/lead-disqualification-reason-i18n.md b/.changeset/lead-disqualification-reason-i18n.md new file mode 100644 index 00000000..eb3e9a8e --- /dev/null +++ b/.changeset/lead-disqualification-reason-i18n.md @@ -0,0 +1,21 @@ +--- +'hotcrm': patch +--- + +Translate `crm_lead.disqualification_reason` in all four locales, and guard the +class with a test. The field had no entry in any bundle, so a `required` field +sitting on eight lead forms rendered its seven raw stored values — `not_a_fit`, +`no_budget`, `wrong_persona`, `unreachable`, `duplicate`, `competitor`, `other` — +inside an otherwise fully translated form. `en` looked correct only by accident: +a missing entry falls back to the English `label` in code, which is why the one +locale a reviewer is most likely to open is the one where the bug cannot be seen. + +Adds a `select fields are translated in every locale` block to +`test/metadata-references.test.ts`, extending the action-label coverage guard +(#494) to select fields: every select field needs a label and a label for every +option value, in every locale pack. The 34 select fields that were already +incomplete when this landed are listed in a shrink-only `PENDING_SELECT_LABELS` +ledger — a field added or extended from here on has nothing to hide behind, and +the ledger cannot rot, because an entry that has since been translated fails as +stale and an entry naming a field or locale that does not exist fails as a ghost. +Fixes #631. diff --git a/src/translations/en.ts b/src/translations/en.ts index 0d208a62..e19cce1b 100644 --- a/src/translations/en.ts +++ b/src/translations/en.ts @@ -250,6 +250,15 @@ export const en: TranslationData = { notes: { label: 'Notes' }, do_not_call: { label: 'Do Not Call' }, email_opt_out: { label: 'Email Opt Out' }, + disqualification_reason: { + label: 'Disqualification Reason', + help: 'Required when status is Unqualified', + options: { + not_a_fit: 'Not a Fit', no_budget: 'No Budget', wrong_persona: 'Wrong Persona', + unreachable: 'Unreachable', duplicate: 'Duplicate', competitor: 'Competitor', + other: 'Other', + }, + }, duplicate_of_type: { label: 'Duplicate Of', options: { crm_lead: 'Lead', crm_contact: 'Contact' }, diff --git a/src/translations/es-ES.ts b/src/translations/es-ES.ts index 5f1566eb..2dd13ab4 100644 --- a/src/translations/es-ES.ts +++ b/src/translations/es-ES.ts @@ -228,6 +228,15 @@ export const esES: TranslationData = { notes: { label: 'Notas' }, do_not_call: { label: 'No Llamar' }, email_opt_out: { label: 'Excluir de Correos' }, + disqualification_reason: { + label: 'Motivo de No Calificación', + help: 'Obligatorio cuando el estado es No Calificado', + options: { + not_a_fit: 'No Encaja', no_budget: 'Sin Presupuesto', wrong_persona: 'Perfil Incorrecto', + unreachable: 'Ilocalizable', duplicate: 'Duplicado', competitor: 'Competidor', + other: 'Otro', + }, + }, duplicate_of_type: { label: 'Duplicado De', options: { crm_lead: 'Prospecto', crm_contact: 'Contacto' }, diff --git a/src/translations/ja-JP.ts b/src/translations/ja-JP.ts index 3501bbe2..08728339 100644 --- a/src/translations/ja-JP.ts +++ b/src/translations/ja-JP.ts @@ -228,6 +228,15 @@ export const jaJP: TranslationData = { notes: { label: 'メモ' }, do_not_call: { label: '電話拒否' }, email_opt_out: { label: 'メール配信停止' }, + disqualification_reason: { + label: '不適格理由', + help: 'ステータスが「不適格」の場合は必須', + options: { + not_a_fit: 'ニーズが合わない', no_budget: '予算なし', wrong_persona: '担当者が異なる', + unreachable: '連絡不能', duplicate: '重複リード', competitor: '競合他社に決定', + other: 'その他', + }, + }, duplicate_of_type: { label: '重複対象', options: { crm_lead: 'リード', crm_contact: '連絡先' }, diff --git a/src/translations/zh-CN.ts b/src/translations/zh-CN.ts index f1d45f94..ef4a03af 100644 --- a/src/translations/zh-CN.ts +++ b/src/translations/zh-CN.ts @@ -301,6 +301,17 @@ export const zhCN: TranslationData = { notes: { label: '备注' }, do_not_call: { label: '禁止致电' }, email_opt_out: { label: '拒收邮件' }, + disqualification_reason: { + // 状态里的 unqualified 译作「未通过」,所以这里跟着叫「未通过原因」 + // 而不是「取消资格原因」—— 两个字段在同一张表单上下相邻,用词必须成对。 + label: '未通过原因', + help: '状态为「未通过」时必填', + options: { + not_a_fit: '需求不匹配', no_budget: '预算不足', wrong_persona: '联系人角色不符', + unreachable: '无法联系', duplicate: '重复线索', competitor: '选择了竞争对手', + other: '其他', + }, + }, duplicate_of_type: { label: '重复于', options: { crm_lead: '线索', crm_contact: '联系人' }, diff --git a/test/metadata-references.test.ts b/test/metadata-references.test.ts index 907e2fb4..85ed4844 100644 --- a/test/metadata-references.test.ts +++ b/test/metadata-references.test.ts @@ -1287,6 +1287,200 @@ describe('action labels are translated in every locale', () => { }); }); +/** + * Select fields — the field's own label AND every option label — are translated + * in every locale. + * + * The sibling guard above does this for action labels (#494). It was never + * extended to select fields, and `crm_lead.disqualification_reason` (#631) is + * what that costs: a `required` field on eight lead forms with no entry in ANY + * of the four bundles, so the form rendered `not_a_fit` / `no_budget` / + * `wrong_persona` as raw stored VALUES in the middle of an otherwise fully + * translated screen. + * + * It stayed invisible for the same reason #494's did: a missing entry is not an + * error. The resolver falls back to the English `label` in code, and in `en` + * that fallback is indistinguishable from a correct translation — so the only + * locale a reviewer is likely to open is the one locale where the bug cannot be + * seen. That is a class a test catches and an eyeball does not. + * + * The two neighbouring guards in `picklist values never reach the UI unresolved` + * check that the entries which DO exist point at something real. This one checks + * the other direction: that they exist at all. Both are needed — a bundle can be + * perfectly well-formed and still be empty. + */ +describe('select fields are translated in every locale', () => { + /** + * Select fields knowingly still missing translations, keyed + * `object.field` → the locales that lack them. + * + * This map may only ever SHRINK. It is a ledger of pre-existing debt, not a + * place to park new work: a field added or extended from here on has no entry + * to hide behind, so it fails on the PR that introduces it. Two assertions + * below keep the ledger from rotting — an entry whose gap has since been + * filled fails as stale, and an entry naming a field or locale that does not + * exist fails as a ghost. + * + * Surveyed when #631 landed: 34 fields over 12 objects — 111 (locale, field) + * pairs, ~380 option labels. They are enumerated in #645 rather than fixed + * here, because translating that much in a one-field bug-fix PR would bury the + * field the PR is actually about. + */ + /** + * Shorthand for a field NO locale translates. Written out literally rather + * than derived from `localePacks`, so that adding a fifth locale surfaces as + * new gaps to fill instead of silently extending every exemption to cover it. + */ + const UNTRANSLATED_EVERYWHERE = ['en', 'zh-CN', 'ja-JP', 'es-ES']; + const PENDING_SELECT_LABELS: Record = { + 'crm_account.tier': ['ja-JP', 'es-ES'], + 'crm_account.segment': ['ja-JP', 'es-ES'], + 'crm_account.health_score': ['ja-JP', 'es-ES'], + 'crm_campaign.type': ['en', 'ja-JP', 'es-ES'], + 'crm_campaign.channel': ['en', 'ja-JP', 'es-ES'], + 'crm_campaign.status': ['en', 'ja-JP', 'es-ES'], + 'crm_case.status': ['en', 'ja-JP', 'es-ES'], + 'crm_case.priority': ['en', 'ja-JP', 'es-ES'], + 'crm_case.type': UNTRANSLATED_EVERYWHERE, + 'crm_contact.salutation': UNTRANSLATED_EVERYWHERE, + 'crm_contact.lead_source': ['en', 'ja-JP', 'es-ES'], + 'crm_contract.status': ['en', 'ja-JP', 'es-ES'], + 'crm_contract.billing_frequency': UNTRANSLATED_EVERYWHERE, + 'crm_contract.payment_terms': UNTRANSLATED_EVERYWHERE, + 'crm_contract.contract_type': UNTRANSLATED_EVERYWHERE, + // Partial: 5 of 7 categories and 4 of 8 tags are translated everywhere. + 'crm_knowledge_article.category': UNTRANSLATED_EVERYWHERE, + 'crm_knowledge_article.tags': UNTRANSLATED_EVERYWHERE, + 'crm_lead.salutation': ['en', 'ja-JP', 'es-ES'], + 'crm_lead.industry': ['en', 'ja-JP', 'es-ES'], + 'crm_opportunity.competitors': UNTRANSLATED_EVERYWHERE, + 'crm_opportunity.approval_status': ['ja-JP', 'es-ES'], + 'crm_opportunity.win_reason': ['ja-JP', 'es-ES'], + 'crm_opportunity.loss_reason': ['ja-JP', 'es-ES'], + 'crm_product.category': ['en', 'ja-JP', 'es-ES'], + 'crm_product.family': UNTRANSLATED_EVERYWHERE, + 'crm_product.billing_type': UNTRANSLATED_EVERYWHERE, + 'crm_product.unit_of_measure': UNTRANSLATED_EVERYWHERE, + 'crm_quote.status': ['en', 'ja-JP', 'es-ES'], + 'crm_quote.payment_terms': UNTRANSLATED_EVERYWHERE, + 'crm_task.status': ['en', 'ja-JP', 'es-ES'], + 'crm_task.priority': ['en', 'ja-JP', 'es-ES'], + 'crm_task.type': UNTRANSLATED_EVERYWHERE, + 'crm_task.related_to_type': UNTRANSLATED_EVERYWHERE, + 'crm_task.recurrence_type': UNTRANSLATED_EVERYWHERE, + }; + + /** Every authored select field, with its option VALUES (what the DB stores). */ + const selectFields = objects.flatMap((obj) => + Object.entries(obj.fields ?? {}) + .filter(([, f]) => Array.isArray(f?.options) && f.options.length) + .map(([fieldName, f]) => ({ + key: `${obj.name}.${fieldName}`, + objectName: obj.name as string, + fieldName, + values: (f.options as AnyRec[]).map((o) => String(o.value)), + })), + ); + + const isPending = (key: string, locale: string) => + (PENDING_SELECT_LABELS[key] ?? []).includes(locale); + + /** The translation entry for one select field in one locale pack. */ + const entryFor = (pack: AnyRec, objectName: string, fieldName: string): AnyRec | undefined => + pack?.objects?.[objectName]?.fields?.[fieldName]; + + it('sees a non-trivial set of select fields and locales', () => { + // Guards the guard: `stack.objects` returning [] (or the translations + // bundle failing to flatten) would make every assertion below pass by + // checking nothing — which is exactly how the navigation guard in this file + // spent its life green. + expect(selectFields.length, 'no select fields discovered').toBeGreaterThanOrEqual(40); + expect(localePacks.length, 'no locale packs found in stack.translations').toBeGreaterThan(0); + }); + + it('every select field has a translated label in every locale', () => { + const bad: string[] = []; + for (const { key, objectName, fieldName } of selectFields) { + for (const [locale, pack] of localePacks) { + if (isPending(key, locale)) continue; + if (!entryFor(pack, objectName, fieldName)?.label) { + bad.push(`${locale}: ${objectName}.fields.${fieldName}.label`); + } + } + } + expect( + bad, + `select fields with no translated label:\n ${bad.join('\n ')}\n` + + 'Add the entry to src/translations/.ts — a missing one silently ' + + 'falls back to the English label in code.', + ).toEqual([]); + }); + + it('every option value has a translated label in every locale', () => { + // The half of the defect users actually notice. A missing field label reads + // as an odd column heading; a missing OPTION label puts the raw stored value + // (`not_a_fit`, `waiting_customer`) into a picklist a rep has to choose from. + const bad: string[] = []; + for (const { key, objectName, fieldName, values } of selectFields) { + for (const [locale, pack] of localePacks) { + if (isPending(key, locale)) continue; + const options = entryFor(pack, objectName, fieldName)?.options ?? {}; + const missing = values.filter((v) => !options[v]); + if (missing.length) { + bad.push(`${locale}: ${objectName}.${fieldName} — ${missing.join(', ')}`); + } + } + } + expect( + bad, + `option values with no translated label:\n ${bad.join('\n ')}\n` + + 'These render as the raw stored value in the picklist.', + ).toEqual([]); + }); + + it('the pending map contains no stale entries', () => { + // A field that has since been translated must leave the ledger, or the + // ledger stops meaning anything and the field silently loses its guard. + const byKey = new Map(selectFields.map((f) => [f.key, f])); + const stale: string[] = []; + for (const [key, locales] of Object.entries(PENDING_SELECT_LABELS)) { + const field = byKey.get(key); + if (!field) continue; // ghost — reported by the test below + for (const locale of locales) { + const pack = packFor(locale); + if (!pack) continue; // ghost locale — reported by the test below + const entry = entryFor(pack, field.objectName, field.fieldName); + const complete = + !!entry?.label && field.values.every((v) => (entry.options ?? {})[v]); + if (complete) stale.push(`${locale}: ${key}`); + } + } + expect( + stale, + `these are now fully translated — remove them from PENDING_SELECT_LABELS:\n ${stale.join('\n ')}`, + ).toEqual([]); + }); + + it('the pending map only names real fields and real locales', () => { + const keys = new Set(selectFields.map((f) => f.key)); + const locales = new Set(localePacks.map(([l]) => l)); + const ghosts: string[] = []; + for (const [key, pending] of Object.entries(PENDING_SELECT_LABELS)) { + if (!keys.has(key)) { + ghosts.push(`${key} is not a select field on any object`); + continue; + } + for (const locale of pending) { + if (!locales.has(locale)) ghosts.push(`${key} names unknown locale "${locale}"`); + } + } + expect( + ghosts, + `PENDING_SELECT_LABELS has entries that check nothing:\n ${ghosts.join('\n ')}`, + ).toEqual([]); + }); +}); + /** * `form.data` is a data provider the form renderer never reads: a form binds to * its object and record through the route context, so the block only *looked*