diff --git a/.changeset/opportunity-reference-rail-localized-headings.md b/.changeset/opportunity-reference-rail-localized-headings.md new file mode 100644 index 00000000..fa0bf4d1 --- /dev/null +++ b/.changeset/opportunity-reference-rail-localized-headings.md @@ -0,0 +1,37 @@ +--- +'hotcrm': patch +--- + +Let the opportunity detail page's reference rail take its card headings from the +translation bundle, and stop one of them claiming a filter the component cannot +apply. + +The three entries in `src/pages/opportunity_detail.page.ts` each declared a +literal English `title` — `Quotes`, `Products`, `Open Tasks`. The rail resolves a +heading as `entry.title || i18n.objectLabel({ name: objectName, … })`, so a +literal does not supply a default: it wins, and the locale bundle is never +consulted. `objects.crm_quote.label`, `objects.crm_opportunity_line_item.label` +and `objects.crm_task.label` are translated in all four locales this app ships, +yet the rail printed English into every one of them. Dropping the three literals +hands the heading back to the translation bundle, so the cards now read 报价单 / +商机产品明细 / 任务 in Simplified Chinese and follow any locale added later +without a page edit. + +Two consequences worth knowing before you look at the page: + +- The English headings change with it, from the plural nouns the literals spelled + to the objects' own singular labels — **Quote**, **Opportunity Line Item**, + **Task**. The rail reads `label`, never `pluralLabel`, so a plural heading is + not reachable from metadata today. +- The task card loses the word *Open*, which it was never entitled to. A rail + entry carries no filter and cannot be given one — the rail queries + `{ $filter: { [relationshipField]: parentId }, $top: limit }` and reads nothing + else off the entry — so that card has always counted and listed this deal's + tasks whatever their status. The genuinely filtered view is the **Open Tasks** + related list on the *Related* tab, which does carry `status neq completed`. + +The `sales/opportunities` page in all three doc languages is updated to match, +and three new guards pin the behaviour: rail entries must resolve to real objects +and relationship fields, must declare no literal title, and must declare no +filter — the last one so that an author reaching for the *Related* tab's +predicate gets a red test instead of a key that parses, ships and does nothing. diff --git a/content/docs/sales/opportunities.mdx b/content/docs/sales/opportunities.mdx index 7a5c12cb..13f98297 100644 --- a/content/docs/sales/opportunities.mdx +++ b/content/docs/sales/opportunities.mdx @@ -149,7 +149,7 @@ When you open an opportunity, you'll see: - **Line Items** — with an **Add Product** button. - **Activity timeline** — a unified feed of comments, logged calls (`sys_activity`), outbound emails (`sys_email`), task completions, and field-history audit entries. Every tracked change on the opportunity (stage, amount, close-date, owner) shows up here automatically; there is no separate "history" tab. - **Competitors & Notes** — there is no such panel. The page (`src/pages/opportunity_detail.page.ts`) declares three regions: a header, the main column carrying the tab strip, and one narrow side column (`aside`) whose only occupant is the reference rail described below. Nothing on the page renders talking points. **Competitors** is a real field — an ordinary multi-select on the opportunity, whose options today are the placeholders *Competitor A*, *Competitor B* and *Competitor C* (`src/objects/opportunity.object.ts`) — but it is not on this page at all: you set it in the edit form's *Sales Strategy* section, beside **Next Steps**. The closest thing to notes on the page itself is the *Details* tab's collapsible **Description** section, which carries **Description** and **Next Steps**. -- **AI Reference Rail** — the rail is real, but there is not one suggestion in it, and no component on this page is AI-driven. It is a `record:reference_rail` in that side column, and each of its three cards is a snapshot of records already linked to this deal: **Quotes** (`crm_quote` through its **Opportunity** lookup), **Products** (this deal's `crm_opportunity_line_item` rows) and **Open Tasks** (`crm_task` through **Related Opportunity**). A card carries the total related count as a badge, at most three records, and a *View all* link into the full list; cards that come back empty fold into a single *+ N empty* chip. Two caveats. The **Open Tasks** card applies no status filter — a rail entry declares none, and the rail queries on the relationship alone — so it counts and lists this deal's tasks whatever their status; the list that really filters to not-*Completed*, ten at a time, is **Open Tasks** on the *Related* tab. And there is no Customer 360, no revenue forecast and no "related signal" in the rail or anywhere else on this page: those skills are reached by asking — see *How the AI Copilot helps* below. +- **AI Reference Rail** — the rail is real, but there is not one suggestion in it, and no component on this page is AI-driven. It is a `record:reference_rail` in that side column, and each of its three cards is a snapshot of records already linked to this deal: `crm_quote` through its **Opportunity** lookup, this deal's `crm_opportunity_line_item` rows, and `crm_task` through **Related Opportunity**. A card carries the total related count as a badge, at most three records, and a *View all* link into the full list; cards that come back empty fold into a single *+ N empty* chip. Each card is headed by its object's label in the language you are reading the app in — **Quote**, **Opportunity Line Item** and **Task** in English — because the rail entries declare no heading of their own; the heading is looked up in the translation bundle, the same place the rest of the app gets an object's name. Two caveats. A rail entry cannot filter, and none of these declares one: the rail queries on the relationship alone, so the task card counts and lists this deal's tasks whatever their status. That is exactly why the card is headed **Task** rather than *Open Tasks* — the view that really filters to not-*Completed*, ten at a time, is **Open Tasks** on the *Related* tab. And there is no Customer 360, no revenue forecast and no "related signal" in the rail or anywhere else on this page: those skills are reached by asking — see *How the AI Copilot helps* below. ## How the AI Copilot helps diff --git a/content/docs/sales/opportunities.zh-Hans.mdx b/content/docs/sales/opportunities.zh-Hans.mdx index d68b850d..3badbacb 100644 --- a/content/docs/sales/opportunities.zh-Hans.mdx +++ b/content/docs/sales/opportunities.zh-Hans.mdx @@ -149,7 +149,7 @@ description: 活跃的销售交易——销售管道的核心,包含 7 个阶 - **行项目**——带有一个**添加产品**按钮。 - **活动时间线**——统一汇集评论、已记录通话(`sys_activity`)、外发邮件(`sys_email`)、任务完成以及字段历史审计条目。商机上每个被跟踪的更改(阶段、金额、成交日期、负责人)都会自动出现在这里;没有单独的"历史"标签页。 - **竞争对手与备注**——不存在这样一个面板。这一页(`src/pages/opportunity_detail.page.ts`)声明了三个区域:一个头部、一个装着那组标签页的主列,以及一条窄侧栏(`aside`),而侧栏里唯一的组件就是下一条讲的参考栏。页面上没有任何组件渲染「谈话要点」。**竞争对手**这个字段是真实存在的——商机上一个普通的多选字段,目前的选项还是占位的 *Competitor A*、*Competitor B*、*Competitor C*(`src/objects/opportunity.object.ts`)——但它根本不在这一页上:你是在编辑表单的 *Sales Strategy* 分区里填它,紧挨着 **下一步**。这一页上最接近「备注」的,是 *Details* 标签页里那个可折叠的 **描述** 分区,里面放着 **描述** 与 **下一步**。 -- **AI 参考栏**——参考栏确实存在,但里面没有任何一条建议,这一页上也没有任何一个组件是 AI 驱动的。它是那条侧栏里的一个 `record:reference_rail`,三张卡片每一张都只是已经关联到本商机的记录快照:**Quotes**(报价单 `crm_quote`,经其 **商机** 查找字段)、**Products**(本商机的 `crm_opportunity_line_item` 明细行)、**Open Tasks**(任务 `crm_task`,经 **关联商机** 字段)。每张卡片给出关联记录总数的徽章、最多三条记录,以及一个跳转到完整列表的 *View all* 链接;查出来是空的卡片会折叠成一个 *+ N empty* 小按钮。两点提醒。**Open Tasks** 这张卡片并不按状态过滤——参考栏的条目不声明过滤条件,查询只按关联字段走——所以它统计和列出的是本商机的全部任务,不分状态;真正过滤掉 *Completed*、一次十条的,是 *Related* 标签页上的 **Open Tasks** 列表。另外,参考栏里没有 Customer 360、没有营收预测,也没有任何「相关信号」,整页都没有:那些技能是靠提问去用的——见下文《AI Copilot 如何提供帮助》。 +- **AI 参考栏**——参考栏确实存在,但里面没有任何一条建议,这一页上也没有任何一个组件是 AI 驱动的。它是那条侧栏里的一个 `record:reference_rail`,三张卡片每一张都只是已经关联到本商机的记录快照:报价单 `crm_quote`(经其 **商机** 查找字段)、本商机的 `crm_opportunity_line_item` 明细行、任务 `crm_task`(经 **关联商机** 字段)。每张卡片给出关联记录总数的徽章、最多三条记录,以及一个跳转到完整列表的 *View all* 链接;查出来是空的卡片会折叠成一个 *+ N empty* 小按钮。卡片的标题按你当前阅读界面的语言取自对象标签——中文界面下是**报价单**、**商机产品明细**、**任务**——因为参考栏的条目自己不声明标题,标题是到译文包里查的,与全站其他地方取对象名的来源是同一处。两点提醒。参考栏的条目无法过滤,这三条也都没有声明过滤条件:查询只按关联字段走,所以任务那张卡片统计和列出的是本商机的全部任务,不分状态。这也正是它的标题是**任务**而不是「待办任务」的原因——真正过滤掉 *Completed*、一次十条的,是 *Related* 标签页上的 **Open Tasks** 列表。另外,参考栏里没有 Customer 360、没有营收预测,也没有任何「相关信号」,整页都没有:那些技能是靠提问去用的——见下文《AI Copilot 如何提供帮助》。 ## AI Copilot 如何提供帮助 diff --git a/content/docs/sales/opportunities.zh-Hant.mdx b/content/docs/sales/opportunities.zh-Hant.mdx index 94dbfdaf..379b87c6 100644 --- a/content/docs/sales/opportunities.zh-Hant.mdx +++ b/content/docs/sales/opportunities.zh-Hant.mdx @@ -149,7 +149,7 @@ description: 活躍的銷售交易——銷售管道的核心,包含 7 個階 - **行項目**——帶有一個**新增產品**按鈕。 - **活動時間線**——統一彙集評論、已記錄通話(`sys_activity`)、外發郵件(`sys_email`)、任務完成以及欄位歷史稽核條目。商機上每個被追蹤的更改(階段、金額、成交日期、負責人)都會自動出現在這裡;沒有單獨的「歷史」標籤頁。 - **競爭對手與備註**——不存在這樣一個面板。這一頁(`src/pages/opportunity_detail.page.ts`)宣告了三個區域:一個頭部、一個裝著那組標籤頁的主欄,以及一條窄側欄(`aside`),而側欄裡唯一的元件就是下一條講的參考欄。頁面上沒有任何元件渲染「談話要點」。**競爭對手**這個欄位是真實存在的——商機上一個普通的多選欄位,目前的選項還是佔位的 *Competitor A*、*Competitor B*、*Competitor C*(`src/objects/opportunity.object.ts`)——但它根本不在這一頁上:你是在編輯表單的 *Sales Strategy* 分區裡填它,緊挨著 **下一步**。這一頁上最接近「備註」的,是 *Details* 標籤頁裡那個可摺疊的 **描述** 分區,裡面放著 **描述** 與 **下一步**。 -- **AI 參考欄**——參考欄確實存在,但裡面沒有任何一條建議,這一頁上也沒有任何一個元件是 AI 驅動的。它是那條側欄裡的一個 `record:reference_rail`,三張卡片每一張都只是已經關聯到本商機的記錄快照:**Quotes**(報價單 `crm_quote`,經其 **商機** 查找欄位)、**Products**(本商機的 `crm_opportunity_line_item` 明細行)、**Open Tasks**(任務 `crm_task`,經 **關聯商機** 欄位)。每張卡片給出關聯記錄總數的徽章、最多三筆記錄,以及一個跳轉到完整列表的 *View all* 連結;查出來是空的卡片會摺疊成一個 *+ N empty* 小按鈕。兩點提醒。**Open Tasks** 這張卡片並不按狀態過濾——參考欄的條目不宣告過濾條件,查詢只按關聯欄位走——所以它統計和列出的是本商機的全部任務,不分狀態;真正過濾掉 *Completed*、一次十筆的,是 *Related* 標籤頁上的 **Open Tasks** 列表。另外,參考欄裡沒有 Customer 360、沒有營收預測,也沒有任何「相關訊號」,整頁都沒有:那些技能是靠提問去用的——見下文《AI Copilot 如何提供協助》。 +- **AI 參考欄**——參考欄確實存在,但裡面沒有任何一條建議,這一頁上也沒有任何一個元件是 AI 驅動的。它是那條側欄裡的一個 `record:reference_rail`,三張卡片每一張都只是已經關聯到本商機的記錄快照:報價單 `crm_quote`(經其 **商機** 查找欄位)、本商機的 `crm_opportunity_line_item` 明細行、任務 `crm_task`(經 **關聯商機** 欄位)。每張卡片給出關聯記錄總數的徽章、最多三筆記錄,以及一個跳轉到完整列表的 *View all* 連結;查出來是空的卡片會摺疊成一個 *+ N empty* 小按鈕。卡片的標題取自對象標籤,隨你當前介面語言而變——英文介面下是 **Quote**、**Opportunity Line Item**、**Task**,簡體中文介面下是「报价单」「商机产品明细」「任务」——因為參考欄的條目自己不宣告標題,標題是到譯文包裡查的,與全站其他地方取對象名的來源是同一處。兩點提醒。參考欄的條目無法過濾,這三條也都沒有宣告過濾條件:查詢只按關聯欄位走,所以任務那張卡片統計和列出的是本商機的全部任務,不分狀態。這也正是它的標題是「任務」而不是「待辦任務」的原因——真正過濾掉 *Completed*、一次十筆的,是 *Related* 標籤頁上的 **Open Tasks** 列表。另外,參考欄裡沒有 Customer 360、沒有營收預測,也沒有任何「相關訊號」,整頁都沒有:那些技能是靠提問去用的——見下文《AI Copilot 如何提供協助》。 ## AI Copilot 如何提供協助 diff --git a/src/pages/opportunity_detail.page.ts b/src/pages/opportunity_detail.page.ts index 43e46110..26b2cece 100644 --- a/src/pages/opportunity_detail.page.ts +++ b/src/pages/opportunity_detail.page.ts @@ -231,23 +231,54 @@ export const OpportunityDetailPage: Page = { type: 'record:reference_rail', id: 'opp_reference_rail', properties: { + /** + * No entry declares a `title` — deliberately (#972). + * + * The rail resolves a card's heading as + * `entry.title || i18n.objectLabel({ name: objectName, … })`, so a + * literal `title` does not merely provide a default: it WINS, and + * the locale bundle is never consulted. The three literals that + * used to sit here (`Quotes` / `Products` / `Open Tasks`) therefore + * printed English into every one of the four locales this app + * ships, on top of `objects..label` already being translated + * in all of them. Dropping them hands the heading back to the + * translation bundle — the single source of truth for what an + * object is called — so a locale added later is covered for free. + * + * A translated literal is not available as an alternative: unlike + * `record:alert`, whose `title` is run through the inline + * translation-map resolver, the rail renders `entry.title` as a + * raw React child. An `{ en, 'zh-CN' }` map here would not be + * resolved — it would be handed to React as an object. + * + * The third card losing the word "Open" is a correction, not a + * casualty. A rail entry has no filter at all — the rail queries + * `{ $filter: { [relationshipField]: parentId }, $top: limit }` and + * reads nothing else — so that card always counted and listed this + * deal's tasks whatever their status. The heading claimed a filter + * the component cannot apply. The genuinely filtered view is the + * `opp_tasks` related list on the *Related* tab above, which does + * carry `status neq completed`. + * + * Pinned by `test/metadata-references.test.ts` (entries resolve, + * no literal titles) and `test/i18n-references.test.ts` (every + * rail object has a `label` in every locale, so the fallback + * lands on a translation rather than on a humanized object name). + */ entries: [ { objectName: 'crm_quote', relationshipField: 'crm_opportunity', - title: 'Quotes', limit: 3, }, { objectName: 'crm_opportunity_line_item', relationshipField: 'crm_opportunity', - title: 'Products', limit: 3, }, { objectName: 'crm_task', relationshipField: 'related_to_opportunity', - title: 'Open Tasks', limit: 3, }, ], diff --git a/test/i18n-references.test.ts b/test/i18n-references.test.ts index 3f2aa86f..09ce1c77 100644 --- a/test/i18n-references.test.ts +++ b/test/i18n-references.test.ts @@ -394,6 +394,45 @@ describe('every locale is complete on every authored surface', () => { expect(bad, `pages with untranslated copy:\n ${bad.join('\n ')}`).toEqual([]); }); + /** + * Reference-rail cards are headed by the OBJECT's label, not by any copy the + * page owns. The rail resolves + * `entry.title || i18n.objectLabel({ name, label: humanize(name) })`, and + * `objectLabel` looks up `objects..label` in the active locale bundle + * before falling back to the label passed in — which the rail sets to + * `humanize(objectName)`, i.e. `Crm Opportunity Line Item`. + * + * So the rail is one of the few surfaces where a MISSING object translation + * is worse than no translation system at all: the fallback is not the + * authored English label, it is a de-underscored object NAME. #972 removed + * the literal `title` from all three entries to let this lookup happen; this + * guard is what makes that safe, and it is scoped to the rail's objects + * deliberately — `objects..label` completeness at large is the + * surface-completeness test further down, which does not know that these + * three names have a second, harsher consumer. + */ + it('every reference-rail object has a translated label in every locale', () => { + const railObjects = [ + ...new Set( + pages + .flatMap((p: AnyRec) => (p.regions ?? []).flatMap((r: AnyRec) => r.components ?? [])) + .filter((c: AnyRec) => c?.type === 'record:reference_rail') + .flatMap((c: AnyRec) => (c.properties?.entries ?? []) as AnyRec[]) + .map((e: AnyRec) => e.objectName as string), + ), + ]; + expect(railObjects.length, 'no rail entries found — this guard would be vacuous').toBeGreaterThan(0); + + const bad: string[] = []; + for (const [locale, pack] of packs()) { + for (const name of railObjects) { + const label = pack.objects?.[name]?.label; + if (!label) bad.push(`${locale}: objects.${name}.label (rail card would read "${name}" humanized)`); + } + } + expect(bad, `reference-rail headings with no translation:\n ${bad.join('\n ')}`).toEqual([]); + }); + it('every dashboard widget has a translated title and description', () => { const dashboards: AnyRec[] = (stack as any).dashboards ?? []; const bad: string[] = []; diff --git a/test/metadata-references.test.ts b/test/metadata-references.test.ts index d501d61d..68195a0c 100644 --- a/test/metadata-references.test.ts +++ b/test/metadata-references.test.ts @@ -101,6 +101,81 @@ describe('page component references resolve', () => { expect(bad, `dangling related-list columns:\n ${bad.join('\n ')}`).toEqual([]); }); + /** + * `record:reference_rail` entries are the one page reference the dangling-name + * guards above never reached. An entry is a bare object inside + * `properties.entries` — it carries no `type`, so `walk` does not yield it — + * and the platform declares no schema for it either: `record:reference_rail` + * has no row in the spec's `ComponentPropsMap`, and a page component's + * `properties` is `z.record(z.string(), z.unknown())`. Nothing between the + * authored file and the browser reads these names before the rail queries + * them, so a typo is a card that silently comes back empty and — because + * `hideEmpty` defaults on — folds itself away. + */ + const railEntries = components + .filter((c) => c.type === 'record:reference_rail') + .flatMap((c) => ((c.properties?.entries ?? []) as AnyRec[]).map((e) => ({ id: c.id, entry: e }))); + + it('every record:reference_rail entry names a real object and a real relationship field', () => { + expect(railEntries.length, 'no rail entries found — this guard would be vacuous').toBeGreaterThan(0); + const bad: string[] = []; + for (const { id, entry } of railEntries) { + const objectName = entry.objectName; + if (!objectNames.has(objectName)) { + bad.push(`${id}: objectName "${objectName}" is not a defined object`); + continue; + } + const relField = entry.relationshipField; + if (relField && !fieldsOf(objectName).includes(relField)) { + bad.push(`${id}: "${objectName}" has no field "${relField}"`); + } + } + expect(bad, `dangling reference-rail references:\n ${bad.join('\n ')}`).toEqual([]); + }); + + /** + * A rail card's heading resolves as + * `entry.title || i18n.objectLabel({ name: objectName, … })`. A literal + * `title` does not supply a default — it wins outright, and the locale bundle + * is never consulted, so one English string overrides all four locale packs + * at once (#972). Nor can the literal itself be translated: the rail renders + * `entry.title` as a raw React child, unlike `record:alert`, which runs its + * `title` through the inline translation-map resolver. + * + * So: until the rail gains a translated-title channel, "declares a title" and + * "is untranslatable" are the same fact, and the only correct number of + * literals is zero. The other half of this guard — that dropping the literal + * lands on a *translation* rather than on a humanized object name — is + * `test/i18n-references.test.ts`, which requires `objects..label` in + * every locale for exactly these objects. + */ + it('no record:reference_rail entry declares a literal title', () => { + const bad = railEntries + .filter(({ entry }) => entry.title !== undefined) + .map(({ id, entry }) => `${id}: "${entry.objectName}" declares title ${JSON.stringify(entry.title)}`); + expect( + bad, + `reference-rail titles override the localized object label:\n ${bad.join('\n ')}`, + ).toEqual([]); + }); + + /** + * The same rail entry has no filter channel either: the rail issues + * `find(objectName, { $filter: { [relationshipField]: parentId }, $top: limit, + * $count: true })` and reads nothing else off the entry. Because `properties` + * is an unvalidated `z.record`, a `filter` written here would parse, ship, and + * do nothing — the card would keep counting every related record while the + * source claimed otherwise. That is the trap this guard exists to spring: an + * author reaching for the *Related* tab's `status neq completed` and putting + * it on a rail entry gets a red test instead of a silent lie. + */ + it('no record:reference_rail entry declares a filter the rail cannot apply', () => { + const bad = railEntries + .filter(({ entry }) => entry.filter !== undefined) + .map(({ id, entry }) => `${id}: "${entry.objectName}" declares a filter; the rail queries on the relationship alone`); + expect(bad, `inert reference-rail filters:\n ${bad.join('\n ')}`).toEqual([]); + }); + it('record:activity only lists object types this app defines', () => { const bad: string[] = []; for (const c of components) {