diff --git a/.changeset/quick-tour-navigation-table-real.md b/.changeset/quick-tour-navigation-table-real.md new file mode 100644 index 00000000..7a59e92a --- /dev/null +++ b/.changeset/quick-tour-navigation-table-real.md @@ -0,0 +1,28 @@ +--- +'hotcrm': patch +--- + +Rewrite the quick tour's left-navigation table against the app's real navigation. +The table in `content/docs/getting-started/quick-tour.mdx` — the first thing a new +user reads, and a table whose entire job is "here is what the sidebar holds" — had +drifted in every one of its eight rows. Four of the groups it named do not exist in +`src/apps/crm.app.ts` (*Products*, *Activities*, *Analytics*, *AI*), three groups +that do exist were absent altogether (**My Work**, **Activity**, **Insights**), and +the four rows whose group was real each dropped items or used a label the app never +shows: **Sales** was missing **Account Workbench**, **Pipeline** and **Sales +Performance**; **Service** spelled *Knowledge Base* for the entry actually labelled +**Knowledge** and omitted **Service Overview**; **Marketing** listed *Campaign +Members*, which is not a sidebar item at all; and **Approvals** listed two items +where the group has exactly one, **Inbox**. + +The table now carries the pinned **Home** entry and all seven groups with their real +children, in source order, and says which groups are collapsed when the app loads +(**Marketing**, **Insights**, **Approvals**) — the failure mode that makes a reader +conclude something is missing. Every retired name is re-pointed rather than deleted: +the catalog is the **Products** item under **Marketing**, tasks are **My Tasks** and +**All Tasks** under **My Work**, the Copilot is the right-side chat panel, campaign +membership is reached from the campaign detail page, and the approval audit trail is +real data (`sys_approval_action`) that no navigation entry opens. All three locales +updated; `src/` untouched. A new guard, `test/docs-quick-tour-navigation.test.ts`, +compares the table against `CrmApp.navigation` group-for-group and child-for-child in +all three locales, so the next navigation change cannot leave the tour behind. diff --git a/content/docs/getting-started/quick-tour.mdx b/content/docs/getting-started/quick-tour.mdx index 25d0e22a..a9ae8384 100644 --- a/content/docs/getting-started/quick-tour.mdx +++ b/content/docs/getting-started/quick-tour.mdx @@ -30,18 +30,29 @@ Click the **app launcher** (top-left grid icon) to switch between apps: - **Enterprise CRM** — the main app with all CRM functionality. - **Admin** — for system administrators. -Inside Enterprise CRM, the left nav groups things by: +Inside Enterprise CRM, the left nav opens with one pinned item — **Home**, which is the **Executive Overview** dashboard — and everything else sits in seven groups: | Group | Contents | | --- | --- | -| **Sales** | Leads, Accounts, Contacts, Opportunities, Quotes, Contracts | -| **Service** | Cases, Knowledge Base | -| **Marketing** | Campaigns, Campaign Members | -| **Products** | Products | -| **Activities** | Tasks, Events | -| **Analytics** | Dashboards, Reports | -| **AI** | Copilot, Knowledge Bases | -| **Approvals** | Approval Requests, Action History | +| **Sales** | Leads, Accounts, Account Workbench, Contacts, Opportunities, Pipeline, Quotes, Contracts, Sales Performance | +| **My Work** | My Tasks, My Deals, My Leads, My Cases, My Calendar, All Tasks | +| **Activity** | Events, Calendar, Interaction History, Sales Activity | +| **Marketing** | Campaigns, Products | +| **Service** | Cases, Knowledge, Service Overview | +| **Insights** | CRM Overview, Forecasts, Pipeline Coverage, Lead Inflow, SLA Performance | +| **Approvals** | Inbox | + +**Marketing**, **Insights** and **Approvals** are collapsed when the app loads — only **Sales**, **My Work**, **Activity** and **Service** open themselves. Click a group open before deciding something isn't there. + +Four group names this page used to list are not in the app at all, and several item names sit somewhere other than where it put them: + +- *Products* is not a group. The catalog is the **Products** item under **Marketing** — see [Marketing](/docs/marketing/index). +- *Activities* is not a group; the real one is **Activity**, and tasks are not in it. `crm_task` reaches the sidebar as **My Tasks** and **All Tasks**, both under **My Work**. +- *Analytics* is not a group; the real one is **Insights**. Nothing in it is called *Dashboards* or *Reports* — the dashboard and the three reports it pins are listed above under their own names. +- *AI* is not a group, and no sidebar item is called *Copilot* or *Knowledge Bases*. The Copilot is the right-side chat panel on every page (section 6 below), and the knowledge articles are **Knowledge** under **Service**. +- *Knowledge Base* is not a label this app carries. The **Service** entry is **Knowledge**, and the service dashboard beside it is **Service Overview** — see [Service](/docs/service/index). +- *Campaign Members* is not a sidebar item. Campaign membership is reached from the campaign detail page or from global search — see [Campaign Members](/docs/marketing/campaign-members). +- *Approval Requests* and *Action History* are not items either: **Inbox** is the only entry under **Approvals**. The approval audit trail is real data — the approvals plugin stores it as `sys_approval_action` — but no navigation entry in this app opens it. ## 3. Your first lead diff --git a/content/docs/getting-started/quick-tour.zh-Hans.mdx b/content/docs/getting-started/quick-tour.zh-Hans.mdx index d1d94fa4..bdba8cbb 100644 --- a/content/docs/getting-started/quick-tour.zh-Hans.mdx +++ b/content/docs/getting-started/quick-tour.zh-Hans.mdx @@ -30,18 +30,31 @@ description: HotCRM 的 5 分钟演练 —— 主页、你的第一条线索、 - **Enterprise CRM** —— 包含所有 CRM 功能的主应用。 - **Admin** —— 面向系统管理员。 -在 Enterprise CRM 内,左侧导航按以下方式分组: +在 Enterprise CRM 内,左侧导航最上面是一个固定条目——**Home**,它打开的是 **Executive Overview** 仪表盘;其余内容分在 7 个分组里: | 分组 | 内容 | | --- | --- | -| **Sales** | Leads、Accounts、Contacts、Opportunities、Quotes、Contracts | -| **Service** | Cases、Knowledge Base | -| **Marketing** | Campaigns、Campaign Members | -| **Products** | Products | -| **Activities** | Tasks、Events | -| **Analytics** | Dashboards、Reports | -| **AI** | Copilot、Knowledge Bases | -| **Approvals** | Approval Requests、Action History | +| **Sales** | Leads、Accounts、Account Workbench、Contacts、Opportunities、Pipeline、Quotes、Contracts、Sales Performance | +| **My Work** | My Tasks、My Deals、My Leads、My Cases、My Calendar、All Tasks | +| **Activity** | Events、Calendar、Interaction History、Sales Activity | +| **Marketing** | Campaigns、Products | +| **Service** | Cases、Knowledge、Service Overview | +| **Insights** | CRM Overview、Forecasts、Pipeline Coverage、Lead Inflow、SLA Performance | +| **Approvals** | Inbox | + +上表用的是源码里的英文 label,也就是英文界面上看到的名字。简体中文界面下,它们显示为语言包里的译名——例如 **Inbox** 显示为「待我审批」、**Knowledge** 显示为「知识库」、**My Work** 显示为「我的工作」。 + +**Marketing**、**Insights**、**Approvals** 这三个分组在应用加载时是折叠的——只有 **Sales**、**My Work**、**Activity**、**Service** 会自动展开。先把分组点开,再判断某样东西在不在这里。 + +本页过去列出的 4 个分组名,应用里根本不存在;另有几个条目名,真身并不在它说的位置: + +- *Products*(产品)不是分组。产品目录是 **Marketing** 分组下的 **Products** 条目——参见 [Marketing](/zh-Hans/docs/marketing/index)。 +- *Activities* 不是分组,真实的分组叫 **Activity**,而且任务不在里面。`crm_task` 在侧边栏上是 **My Tasks** 与 **All Tasks** 两项,都挂在 **My Work** 分组下。 +- *Analytics* 不是分组,真实的分组叫 **Insights**。它里面没有任何条目叫 *Dashboards* 或 *Reports*——它固定的那个仪表盘和三张报表,上表已按各自的名字列出。 +- *AI* 不是分组,也没有叫 *Copilot* 或 *Knowledge Bases* 的侧边栏条目。Copilot 是每个页面右侧的聊天面板(见下文第 6 节),知识文章则是 **Service** 分组下的 **Knowledge**。 +- *Knowledge Base* 不是本应用使用的 label。**Service** 分组下的条目叫 **Knowledge**,与它并列的服务仪表盘叫 **Service Overview**——参见 [Service](/zh-Hans/docs/service/index)。 +- *Campaign Members* 不是侧边栏条目。营销活动成员要从营销活动详情页或全局搜索进入——参见 [Campaign Members](/zh-Hans/docs/marketing/campaign-members)。 +- *Approval Requests* 与 *Action History* 也都不是条目:**Approvals** 分组下只有 **Inbox** 一项。审批的操作留痕本身是真实数据,由审批插件以 `sys_approval_action` 存储,但本应用里没有任何导航条目能打开它。 ## 3. 你的第一条线索 diff --git a/content/docs/getting-started/quick-tour.zh-Hant.mdx b/content/docs/getting-started/quick-tour.zh-Hant.mdx index bb41cddd..a25defb8 100644 --- a/content/docs/getting-started/quick-tour.zh-Hant.mdx +++ b/content/docs/getting-started/quick-tour.zh-Hant.mdx @@ -28,18 +28,31 @@ description: HotCRM 的 5 分鐘演練 —— 主頁、你的第一條線索、 - **Enterprise CRM** —— 包含所有 CRM 功能的主應用。 - **Admin** —— 面向系統管理員。 -在 Enterprise CRM 內,左側導覽按以下方式分組: +在 Enterprise CRM 內,左側導覽最上面是一個固定條目——**Home**,它打開的是 **Executive Overview** 儀表板;其餘內容分在 7 個群組裡: | 分組 | 內容 | | --- | --- | -| **Sales** | Leads、Accounts、Contacts、Opportunities、Quotes、Contracts | -| **Service** | Cases、Knowledge Base | -| **Marketing** | Campaigns、Campaign Members | -| **Products** | Products | -| **Activities** | Tasks、Events | -| **Analytics** | Dashboards、Reports | -| **AI** | Copilot、Knowledge Bases | -| **Approvals** | Approval Requests、Action History | +| **Sales** | Leads、Accounts、Account Workbench、Contacts、Opportunities、Pipeline、Quotes、Contracts、Sales Performance | +| **My Work** | My Tasks、My Deals、My Leads、My Cases、My Calendar、All Tasks | +| **Activity** | Events、Calendar、Interaction History、Sales Activity | +| **Marketing** | Campaigns、Products | +| **Service** | Cases、Knowledge、Service Overview | +| **Insights** | CRM Overview、Forecasts、Pipeline Coverage、Lead Inflow、SLA Performance | +| **Approvals** | Inbox | + +上表用的是原始碼裡的英文 label,也就是英文介面上看到的名字。本應用提供 en / zh-CN / ja-JP / es-ES 四種介面語言,沒有繁體語言包——簡體中文介面下這些名字會顯示為語言包裡的譯名,例如 **Inbox** 顯示為「待我审批」、**Knowledge** 顯示為「知识库」、**My Work** 顯示為「我的工作」。 + +**Marketing**、**Insights**、**Approvals** 這三個群組在應用載入時是摺疊的——只有 **Sales**、**My Work**、**Activity**、**Service** 會自動展開。先把群組點開,再判斷某樣東西在不在這裡。 + +本頁過去列出的 4 個群組名,應用裡根本不存在;另有幾個條目名,真身並不在它說的位置: + +- *Products*(產品)不是群組。產品目錄是 **Marketing** 群組下的 **Products** 條目——參見 [Marketing](/zh-Hant/docs/marketing/index)。 +- *Activities* 不是群組,真實的群組叫 **Activity**,而且任務不在裡面。`crm_task` 在側邊欄上是 **My Tasks** 與 **All Tasks** 兩項,都掛在 **My Work** 群組下。 +- *Analytics* 不是群組,真實的群組叫 **Insights**。它裡面沒有任何條目叫 *Dashboards* 或 *Reports*——它釘住的那個儀表板和三張報表,上表已按各自的名字列出。 +- *AI* 不是群組,也沒有叫 *Copilot* 或 *Knowledge Bases* 的側邊欄條目。Copilot 是每個頁面右側的聊天面板(見下文第 6 節),知識文章則是 **Service** 群組下的 **Knowledge**。 +- *Knowledge Base* 不是本應用使用的 label。**Service** 群組下的條目叫 **Knowledge**,與它並列的服務儀表板叫 **Service Overview**——參見 [Service](/zh-Hant/docs/service/index)。 +- *Campaign Members* 不是側邊欄條目。行銷活動成員要從行銷活動詳情頁或全域搜尋進入——參見 [Campaign Members](/zh-Hant/docs/marketing/campaign-members)。 +- *Approval Requests* 與 *Action History* 也都不是條目:**Approvals** 群組下只有 **Inbox** 一項。審批的操作留痕本身是真實資料,由審批外掛以 `sys_approval_action` 儲存,但本應用裡沒有任何導覽條目能打開它。 ## 3. 你的第一條線索 diff --git a/test/docs-quick-tour-navigation.test.ts b/test/docs-quick-tour-navigation.test.ts new file mode 100644 index 00000000..5f77a53b --- /dev/null +++ b/test/docs-quick-tour-navigation.test.ts @@ -0,0 +1,282 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { REPO_ROOT } from './helpers/repo-root'; +import { CrmApp } from '../src/apps/crm.app'; +import { ExecutiveDashboard } from '../src/dashboards/executive.dashboard'; + +/** + * The quick-tour page's left-nav table, pinned to `src/apps/crm.app.ts` (#960). + * + * This is the first table a new user reads, and its entire job is "here is what + * the sidebar holds". Every one of its eight rows had drifted: four named + * groups the app does not have (*Products*, *Activities*, *Analytics*, *AI*), + * three real groups were absent entirely (**My Work**, **Activity**, + * **Insights**), and the four rows whose group did exist each dropped items or + * spelled a label the app never shows (*Knowledge Base* for **Knowledge**, + * *Campaign Members* / *Approval Requests* / *Action History* for items that + * exist nowhere). + * + * Nothing caught it: `os validate` and `pnpm lint` walk authored metadata and + * never open `content/docs`, so — as with the service-index guard in + * `docs-service-index-analytics.test.ts` (#948) — the check has to live where + * the claim lives. Prose that enumerates an IA goes stale the first time the IA + * moves, which is exactly what happened here. + * + * The rules below are two-directional, and the page's typography carries the + * distinction: + * + * - **bold** is reserved for names the app really carries. Every bold Latin run + * in the section must resolve to a navigation label or to the dashboard the + * pinned Home entry opens. (Bold runs containing CJK are the locale prose's + * own emphasis and are skipped — product names stay English in every locale, + * the same convention `docs-drift.test.ts` leans on.) + * - *italic* is reserved for a name the product does NOT carry, the spelling + * #927 / PR #932 established. Every retired name must still be named in + * italics — the convention is to say where a reader's name really lives, not + * to delete it silently — and no phantom may ever appear in bold. + * - exists ⇒ listed: the table's rows are compared against `CrmApp.navigation` + * group-for-group, child-for-child, in source order. Add a nav item without + * touching the tour and this file goes red at PR time, in all three locales. + */ + +type AnyRec = Record; + +const NAV = ((CrmApp as AnyRec).navigation ?? []) as AnyRec[]; +const GROUPS = NAV.filter((n) => n.type === 'group'); +const PINNED = NAV.filter((n) => n.type !== 'group'); + +const childLabels = (g: AnyRec): string[] => + ((g.children ?? []) as AnyRec[]).map((c) => c.label as string); + +const GROUP_LABELS: string[] = GROUPS.map((g) => g.label as string); +const ALL_NAV_LABELS: string[] = [ + ...PINNED.map((p) => p.label as string), + ...GROUP_LABELS, + ...GROUPS.flatMap(childLabels), +]; + +/** Groups that do NOT set `expanded: true` — `GroupNavItemSchema.expanded` defaults to false. */ +const COLLAPSED: string[] = GROUPS.filter((g) => g.expanded !== true).map((g) => g.label as string); +const EXPANDED: string[] = GROUPS.filter((g) => g.expanded === true).map((g) => g.label as string); + +const ALLOWED_BOLD = new Set([...ALL_NAV_LABELS, ExecutiveDashboard.label as string]); + +/** + * Names the old table carried that resolve to no navigation label anywhere. + * Each must be named in italics and must never be bolded. + */ +const PHANTOMS = [ + 'Activities', + 'Analytics', + 'AI', + 'Copilot', + 'Knowledge Bases', + 'Knowledge Base', + 'Campaign Members', + 'Approval Requests', + 'Action History', + 'Dashboards', + 'Reports', +] as const; + +/** + * A name the old table used as a GROUP that is real only as an ITEM. It must be + * named in italics (as the non-existent group) and may also appear in bold (as + * the real item under Marketing), so it is exempt from the never-bold rule. + */ +const RETIRED_GROUP_NAMES = ['Products'] as const; + +const PAGES = [ + { + file: 'content/docs/getting-started/quick-tour.mdx', + lang: 'en', + /** First line of the guarded block; the block runs to the next `## `. */ + start: 'Inside Enterprise CRM,', + sep: ', ', + collapse: /collapsed when the app loads/, + }, + { + file: 'content/docs/getting-started/quick-tour.zh-Hans.mdx', + lang: 'zh-Hans', + start: '在 Enterprise CRM 内,', + sep: '、', + collapse: /在应用加载时是折叠的/, + }, + { + file: 'content/docs/getting-started/quick-tour.zh-Hant.mdx', + lang: 'zh-Hant', + start: '在 Enterprise CRM 內,', + sep: '、', + collapse: /在應用載入時是摺疊的/, + }, +] as const; + +const blockOf = (file: string, start: string): string => { + const lines = readFileSync(join(REPO_ROOT, file), 'utf8').split('\n'); + const from = lines.findIndex((l) => l.startsWith(start)); + expect(from, `${file}: block start '${start}' not found`).toBeGreaterThanOrEqual(0); + const rest = lines.slice(from); + const end = rest.findIndex((l, i) => i > 0 && l.startsWith('## ')); + return (end === -1 ? rest : rest.slice(0, end)).join('\n'); +}; + +/** Body rows of the block's one markdown table, as trimmed cell arrays. */ +const tableRows = (block: string): string[][] => + block + .split('\n') + .filter((l) => l.trim().startsWith('|')) + .map((l) => + l + .trim() + .replace(/^\|/, '') + .replace(/\|$/, '') + .split('|') + .map((c) => c.trim()), + ) + .filter((cells) => !cells.every((c) => /^:?-{2,}:?$/.test(c))) + .slice(1); // drop the header row + +/** + * The CJK range is written as escapes rather than literal characters so this + * file stays greppable in a repo whose tooling scans it as text. + */ +const CJK = /[\u3400-\u9fff]/; + +const boldNames = (block: string): string[] => + [...block.matchAll(/\*\*([^*\n]+)\*\*/g)].map((m) => m[1].trim()).filter((s) => !CJK.test(s)); + +const italicNames = (block: string): string[] => + [...block.replace(/\*\*[^*\n]+\*\*/g, '').matchAll(/\*([^*\n]+)\*/g)].map((m) => m[1].trim()); + +describe('getting-started/quick-tour names the navigation the app really ships (#960)', () => { + describe.each(PAGES)('$file', ({ file, start, sep, collapse }) => { + const block = () => blockOf(file, start); + + it('lists every group, in source order, with exactly its children', () => { + const rows = tableRows(block()); + expect( + rows.map((r) => r[0]), + `${file}: the table's group column must be every CrmApp group, in source order`, + ).toEqual(GROUP_LABELS.map((l) => `**${l}**`)); + + GROUPS.forEach((g, i) => { + expect( + rows[i][1].split(sep).map((s) => s.trim()), + `${file}: contents of the ${g.label} row`, + ).toEqual(childLabels(g)); + }); + }); + + it('names the pinned top-level entry and the dashboard it opens', () => { + const text = block(); + PINNED.forEach((p) => { + expect(text, `${file}: pinned nav item '${p.label}' is not named`).toContain( + `**${p.label}**`, + ); + }); + expect(text).toContain(`**${ExecutiveDashboard.label}**`); + }); + + it('bolds only names the app actually carries', () => { + const unknown = boldNames(block()).filter((n) => !ALLOWED_BOLD.has(n)); + expect( + unknown, + `${file}: bolded name(s) that are not a navigation label or the pinned dashboard's ` + + 'label. Bold is reserved for real names here — a name the app does not carry goes ' + + 'in *italics*.', + ).toEqual([]); + }); + + it('still names every retired name, in italics, and never in bold', () => { + const text = block(); + const italics = italicNames(text); + const bold = boldNames(text); + + const unnamed = [...PHANTOMS, ...RETIRED_GROUP_NAMES].filter((n) => !italics.includes(n)); + expect( + unnamed, + `${file}: a name the old table carried was dropped instead of re-pointed. Readers ` + + 'arrive with these names — say where the thing really is, do not delete it silently.', + ).toEqual([]); + + const promoted = PHANTOMS.filter((n) => bold.includes(n)); + expect( + promoted, + `${file}: name(s) the app does not carry, written in bold as if they were real`, + ).toEqual([]); + }); + + it('says which groups are collapsed when the app loads, and which open themselves', () => { + const text = block(); + expect(text, `${file}: the default-collapse note is missing`).toMatch(collapse); + const line = text.split('\n').find((l) => collapse.test(l)) ?? ''; + COLLAPSED.forEach((l) => + expect(line, `${file}: '${l}' is collapsed on load but the note omits it`).toContain( + `**${l}**`, + ), + ); + EXPANDED.forEach((l) => + expect(line, `${file}: '${l}' opens itself but the note omits it`).toContain(`**${l}**`), + ); + }); + }); +}); + +describe('the source facts the quick-tour table now rests on (#960)', () => { + it('the app has one pinned top-level entry and seven groups', () => { + expect(PINNED.map((p) => p.label)).toEqual(['Home']); + expect(GROUP_LABELS).toEqual([ + 'Sales', + 'My Work', + 'Activity', + 'Marketing', + 'Service', + 'Insights', + 'Approvals', + ]); + }); + + it('Home opens the executive dashboard, not the CRM overview one', () => { + expect(PINNED[0].type).toBe('dashboard'); + expect(PINNED[0].dashboardName).toBe(ExecutiveDashboard.name); + }); + + it('Marketing, Insights and Approvals are the groups that stay collapsed', () => { + expect(COLLAPSED).toEqual(['Marketing', 'Insights', 'Approvals']); + expect(EXPANDED).toEqual(['Sales', 'My Work', 'Activity', 'Service']); + }); + + it('carries no navigation label matching any name the page calls a phantom', () => { + const labels = new Set(ALL_NAV_LABELS); + expect(PHANTOMS.filter((n) => labels.has(n))).toEqual([]); + }); + + it('routes crm_task through My Work only, and reaches campaign members from no nav item', () => { + const walk = (nodes: AnyRec[]): AnyRec[] => + nodes.flatMap((n) => [n, ...walk((n.children ?? []) as AnyRec[])]); + const all = walk(NAV); + expect(all.filter((n) => n.objectName === 'crm_campaign_member')).toEqual([]); + + const taskOwners = GROUPS.filter((g) => + ((g.children ?? []) as AnyRec[]).some((c) => c.objectName === 'crm_task'), + ).map((g) => g.label); + expect(taskOwners).toEqual(['My Work']); + }); + + it('keeps the labels the page spells verbatim', () => { + const byId = new Map( + ((): AnyRec[] => { + const walk = (nodes: AnyRec[]): AnyRec[] => + nodes.flatMap((n) => [n, ...walk((n.children ?? []) as AnyRec[])]); + return walk(NAV); + })().map((n) => [n.id as string, n]), + ); + expect(byId.get('nav_knowledge')?.label).toBe('Knowledge'); + expect(byId.get('nav_service_dashboard')?.label).toBe('Service Overview'); + expect(byId.get('nav_approval_requests')?.label).toBe('Inbox'); + expect(byId.get('nav_product')?.label).toBe('Products'); + }); +});