Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/lead-disqualification-reason-i18n.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions src/translations/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
9 changes: 9 additions & 0 deletions src/translations/es-ES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
9 changes: 9 additions & 0 deletions src/translations/ja-JP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '連絡先' },
Expand Down
11 changes: 11 additions & 0 deletions src/translations/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '联系人' },
Expand Down
194 changes: 194 additions & 0 deletions test/metadata-references.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string[]> = {
'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<AnyRec>(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/<locale>.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*
Expand Down
Loading