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
41 changes: 41 additions & 0 deletions .changeset/search-navigation-built-in-views-real.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
'hotcrm': patch
---

Give the *Built-in vs personal vs shared* bullet in `guides/search-and-navigation`
examples that exist, in all three locales.

The bullet that teaches what a **built-in** view IS held up two names, and neither
was a view:

- ***All Open Opportunities*** is a splice of two real ones. The opportunity view
set in `src/views/opportunity.view.ts` ships **Open Deals**
(`open_opportunities` — the default, pinned tab) and **All Opportunities**
(`all_opportunities` — the unfiltered book). A reader who went looking for the
spliced third name found nothing and had no way to tell that the two halves
were both there under other spellings.
- ***Pending My Approval*** is the phantom PR #969 removed from
`content/docs/revenue/approvals.mdx` and #960 from
`getting-started/quick-tour`; this page was its last landing place. The
approvals plugin ships **My Pending** / **I Submitted** / **Completed** /
**All** on `sys_approval_request`.

The zh pages were each wrong in their own way, so neither was fixed by
translating the English: zh-Hans said 「待我审批」, which is real but is the
**navigation** label of `nav_approval_requests` rather than a view; zh-Hant said
「待我審核」, which no surface can show at all — this app ships en / zh-CN /
ja-JP / es-ES and no Traditional Chinese pack. Both now name the real views and
quote the zh-CN labels the console actually resolves (「进行中商机」,
「全部商机」, 「我的待办」).

The names were swapped without adding a denial: unlike the approvals page, this
bullet is an *example*, not a navigation roster, so there is no wrong list for a
reader to reconcile. `src/` is untouched.
`test/docs-search-navigation-views.test.ts` pins it from both ends — every
emphasised name in the bullet must be a label some view in this app or its
plugins really carries (bold or italic, since the phantoms were italic), every
「…」 gloss must be a string a locale pack really ships, and the source side pins
**Open Deals** as the default tab, **All Opportunities** as unfiltered,
**My Pending** on the plugin, and the zero-hit status of the two retired names —
so a future release that ships a view by one of them fails a test rather than
making the page accidentally right.
2 changes: 1 addition & 1 deletion content/docs/guides/search-and-navigation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ Every object has list views (Leads, Opportunities, etc.). They share the same co

### Built-in vs personal vs shared

- **Built-in** views ship with the system (*All Open Opportunities*, *Pending My Approval*).
- **Built-in** views ship with the system — Opportunities opens on **Open Deals** and keeps the unfiltered **All Opportunities** one tab over; approval requests arrive with **My Pending**.
- **Personal** views are saved for just you.
- **Shared** views are saved for a group or the whole org (admin permission required).

Expand Down
2 changes: 1 addition & 1 deletion content/docs/guides/search-and-navigation.zh-Hans.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ description: 在几秒钟内找到任何东西 —— 全局搜索、键盘快

### 内置 vs 个人 vs 共享

- **内置**视图随系统提供(*所有未结商机*、*待我审批*)。
- **内置**视图随系统提供——商机默认打开 **Open Deals**(简体界面显示为「进行中商机」),不带筛选的 **All Opportunities**(「全部商机」)就在旁边一个标签页;审批请求带的是 **My Pending**(「我的待办」)。
- **个人**视图仅为你自己保存。
- **共享**视图为一个组或整个组织保存(需要管理员权限)。

Expand Down
2 changes: 1 addition & 1 deletion content/docs/guides/search-and-navigation.zh-Hant.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ description: 在幾秒鐘內找到任何東西 —— 全域搜尋、鍵盤快

### 內建 vs 個人 vs 共享

- **內建**檢視隨系統提供(*所有未結商機*、*待我審核*)。
- **內建**檢視隨系統提供——商機預設打開 **Open Deals**(本應用提供 en / zh-CN / ja-JP / es-ES 四種介面語言,沒有繁體語言包;簡體介面下顯示為「进行中商机」),不帶篩選的 **All Opportunities**(簡體「全部商机」)就在旁邊一個標籤頁;審批請求帶的是 **My Pending**(簡體「我的待办」)。
- **個人**檢視僅為你自己儲存。
- **共享**檢視為一個群組或整個組織儲存(需要管理員權限)。

Expand Down
241 changes: 241 additions & 0 deletions test/docs-search-navigation-views.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
// 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 { createRequire } from 'node:module';
import { SysApprovalRequest } from '@objectstack/plugin-approvals';
import { REPO_ROOT } from './helpers/repo-root';
import { type AnyRec, packFor, views } from './helpers/metadata-fixtures';

/**
* `guides/search-and-navigation` › *Built-in vs personal vs shared*, pinned to
* source (#973).
*
* The bullet that teaches what a **built-in** view IS carried two examples and
* neither existed:
*
* - *All Open Opportunities* — a conflation of two real ones. The opportunity
* view set ships **Open Deals** (`open_opportunities`, the default and
* pinned tab) and **All Opportunities** (`all_opportunities`, unfiltered);
* the page spliced them into a third name that greps 0 in `src/`.
* - *Pending My Approval* — the same phantom PR #969 removed from
* `revenue/approvals` and #960 from `getting-started/quick-tour`. This page
* was its last landing place. The approvals plugin ships **My Pending** /
* **I Submitted** / **Completed** / **All** on `sys_approval_request`.
*
* The zh pages were wrong in two further ways, each its own kind of wrong:
* zh-Hans said 「待我审批」, which is real but is the **navigation** label of
* `nav_approval_requests`, not a view; zh-Hant said 「待我審核」, which is
* neither — this app ships en / zh-CN / ja-JP / es-ES and no Hant pack, so no
* surface can display it.
*
* Both were fixed by naming the real views, not by adding a denial: unlike
* `revenue/approvals`, this bullet is an *example*, not a navigation roster, so
* there is no wrong list for a reader to reconcile — hence this guard pins the
* names and the retired spellings, and asserts no prose.
*
* Nothing else checks this: `os validate` and `pnpm lint` walk authored
* metadata and never open `content/docs`, so — as with
* `docs-revenue-approvals-navigation.test.ts` (#963) — the check lives where
* the claim lives.
*/

/** Every label a list view of this app shows a user, plus its tab labels. */
const APP_VIEW_LABELS: string[] = views.flatMap((set: AnyRec) => {
const list = (set.list ?? {}) as AnyRec;
const extra = Object.values((set.listViews ?? {}) as Record<string, AnyRec>);
// Tab labels count too: `my_open_deals` is labelled *My Open Deals* on the
// view and *My Deals* on the tab, and a reader naming either one is naming
// something they can actually see.
const tabs = ((list.tabs ?? []) as AnyRec[]).map((t) => t.label);
return [list.label, ...extra.map((v) => v.label), ...tabs].filter(Boolean) as string[];
});

/** Built-in list-view labels the approvals plugin ships for the request object. */
const APPROVAL_VIEW_LABELS: string[] = Object.values(
((SysApprovalRequest as AnyRec).listViews ?? {}) as Record<string, AnyRec>,
).map((v) => v.label as string);

const REAL_VIEW_LABELS = new Set([...APP_VIEW_LABELS, ...APPROVAL_VIEW_LABELS]);

/** zh-CN labels for this app's own views, from the pack the console resolves. */
const APP_ZH_VIEW_LABELS = new Set<string>(
Object.values(((packFor('zh-CN') as AnyRec)?.objects ?? {}) as Record<string, AnyRec>).flatMap(
(obj) =>
Object.values((obj._views ?? {}) as Record<string, AnyRec>)
.map((v) => v.label as string)
.filter(Boolean),
),
);

/**
* The approvals plugin's shipped bundle, with `\uXXXX` escapes decoded.
*
* The package exports its schemas (`SysApprovalRequest`) but not its locale
* packs, so the zh-CN view labels the zh pages quote — 「我的待办」 for
* `my_pending` — are only reachable as text in the built bundle, where the
* bundler emits every non-ASCII character as an escape (the same reason a
* plain `grep 我的待办 dist/index.js` returns nothing and reads as "the plugin
* ships no zh-CN"). Decoding once here keeps the gloss check honest instead of
* unpinned.
*/
const PLUGIN_BUNDLE: string = (() => {
const require = createRequire(import.meta.url);
const entry = require.resolve('@objectstack/plugin-approvals');
const raw = readFileSync(entry, 'utf8');
return raw.replace(/\\u([0-9a-fA-F]{4})/g, (_m, hex) => String.fromCharCode(parseInt(hex, 16)));
})();

/** Does some surface of this app or its plugins ship `label` in zh-CN? */
const shipsZhLabel = (label: string): boolean =>
APP_ZH_VIEW_LABELS.has(label) || PLUGIN_BUNDLE.includes(`"${label}"`);

const PAGES = [
{
file: 'content/docs/guides/search-and-navigation.mdx',
lang: 'en',
heading: '### Built-in vs personal vs shared',
/** Verbatim fragments of the wrong bullet. None may return. */
retired: ['All Open Opportunities', 'Pending My Approval'],
},
{
file: 'content/docs/guides/search-and-navigation.zh-Hans.mdx',
lang: 'zh-Hans',
heading: '### 内置 vs 个人 vs 共享',
// 待我审批 is a real string — the zh-CN label of `nav_approval_requests` —
// but it names a sidebar entry, not a view, so it may not come back HERE.
retired: ['所有未结商机', '待我审批'],
},
{
file: 'content/docs/guides/search-and-navigation.zh-Hant.mdx',
lang: 'zh-Hant',
heading: '### 內建 vs 個人 vs 共享',
retired: ['所有未結商機', '待我審核'],
},
] as const;

/** The `### …` section named by `heading`, up to the next heading of any level. */
const sectionOf = (file: string, heading: string): string => {
const lines = readFileSync(join(REPO_ROOT, file), 'utf8').split('\n');
const start = lines.findIndex((l) => l.trim() === heading);
expect(start, `${file}: heading '${heading}' not found`).toBeGreaterThanOrEqual(0);
const rest = lines.slice(start + 1);
const end = rest.findIndex((l) => /^#{1,6} /.test(l));
return (end === -1 ? rest : rest.slice(0, end)).join('\n');
};

/** The one bullet in the section that defines the built-in category. */
const builtInBullet = (file: string, heading: string): string => {
const bullet = sectionOf(file, heading)
.split('\n')
.find((l) => l.startsWith('- **'));
expect(bullet, `${file}: the built-in bullet is gone from '${heading}'`).toBeTruthy();
return bullet!;
};

/**
* Emphasised runs after the leading category word (**Built-in** / **内置** /
* **內建**) — the names the bullet holds up as examples.
*
* Bold *and* italic, deliberately: the two phantoms #973 removed were written
* in italics (`*All Open Opportunities*`), so a bold-only reader would go green
* on the very shape the defect took and catch a reintroduction only through the
* verbatim `retired` list below.
*/
const namesIn = (bullet: string): string[] =>
[...bullet.matchAll(/\*+([^*]+)\*+/g)].map((m) => m[1].trim()).slice(1);

/** `「…」` glosses — the zh pages' way of quoting a zh-CN interface string. */
const glossesIn = (bullet: string): string[] =>
[...bullet.matchAll(/「([^」]+)」/g)].map((m) => m[1].trim());

describe('the source facts the built-in-views example rests on (#973)', () => {
it('sees a non-trivial view set, a plugin that ships views, and a zh-CN pack', () => {
// Guards the guard: any of these coming back empty would make every
// assertion below pass by checking nothing.
expect(REAL_VIEW_LABELS.size, 'no view labels found at all').toBeGreaterThan(20);
expect(APPROVAL_VIEW_LABELS.length, 'sys_approval_request ships no list views').toBeGreaterThanOrEqual(4);
expect(APP_ZH_VIEW_LABELS.size, 'zh-CN pack exposes no view labels').toBeGreaterThan(20);
expect(PLUGIN_BUNDLE.length, 'approvals bundle read as empty').toBeGreaterThan(100_000);
});

it('Opportunities opens on Open Deals and keeps All Opportunities one tab over', () => {
const opp = views.find((v: AnyRec) => v.list?.name === 'open_opportunities') as AnyRec;
expect(opp, 'the opportunity view set is gone — this pin is out of date').toBeTruthy();
expect(opp.list.label).toBe('Open Deals');
const openTab = (opp.list.tabs as AnyRec[]).find((t) => t.view === 'open_opportunities');
expect(openTab?.isDefault, 'Open Deals is no longer the default tab').toBe(true);
const all = (opp.listViews as AnyRec).all_opportunities as AnyRec;
expect(all.label).toBe('All Opportunities');
// "unfiltered" is a claim the bullet makes in all three locales.
expect(all.filter, 'All Opportunities grew a filter — the bullet says it has none').toBeUndefined();
});

it('the approvals plugin ships My Pending on approval requests', () => {
expect(APPROVAL_VIEW_LABELS).toContain('My Pending');
});

it('*All Open Opportunities* and *Pending My Approval* are labels of nothing', () => {
// The reason the bullet changed. Either name becoming real is a product
// decision that should reopen this text, not something to discover from a
// reader's bug report.
expect([...REAL_VIEW_LABELS].filter((l) => /All Open Opportunities|Pending My Approval/.test(l))).toEqual([]);
});

it('zh-CN labels the two opportunity views 进行中商机 / 全部商机', () => {
const oppPack = ((packFor('zh-CN') as AnyRec)?.objects ?? {})['crm_opportunity'] as AnyRec;
expect(oppPack?._views?.open_opportunities?.label).toBe('进行中商机');
expect(oppPack?._views?.all_opportunities?.label).toBe('全部商机');
});

it('the approvals plugin labels my_pending 我的待办 in zh-CN', () => {
expect(shipsZhLabel('我的待办')).toBe(true);
});
});

describe('guides/search-and-navigation names only views that exist (#973)', () => {
for (const { file, lang, heading, retired } of PAGES) {
it(`${lang}: every view the built-in bullet names is one this app ships`, () => {
const named = namesIn(builtInBullet(file, heading));
// Vacuity guard: a bullet that stopped naming any view teaches the
// category with no anchor at all — a reader's only handle on the
// definition is the example. Held at two, not three, so that a bullet
// carrying the two wrong names is judged by the membership check below
// (which says WHICH name is fictional) rather than dismissed here.
expect(named.length, `${file}: the built-in bullet names no view`).toBeGreaterThanOrEqual(2);
const bad = named
.filter((n) => !REAL_VIEW_LABELS.has(n))
.map((n) => `${file}: names a view "${n}" that nothing in this app ships`);
expect(
bad,
`documented views that do not exist:\n ${bad.join('\n ')}\n` +
'Use a label off `src/views/*.view.ts` or a plugin\'s `listViews` — do not ' +
'invent a name that reads plausible.',
).toEqual([]);
});

if (lang !== 'en') {
it(`${lang}: every 「…」 gloss is a zh-CN string some surface really shows`, () => {
const glosses = glossesIn(builtInBullet(file, heading));
expect(glosses.length, `${file}: the bullet quotes no zh-CN label`).toBeGreaterThanOrEqual(2);
const bad = glosses
.filter((g) => !shipsZhLabel(g))
.map((g) => `${file}: quotes 「${g}」, which no zh-CN pack carries`);
expect(bad, `invented zh-CN labels:\n ${bad.join('\n ')}`).toEqual([]);
});
}

it(`${lang}: the retired spellings stay retired`, () => {
const section = sectionOf(file, heading);
const back = retired.filter((r) => section.includes(r));
expect(
back,
`${file}: '${back.join("', '")}' is back in the built-in bullet. ` +
'These name no view: *All Open Opportunities* and *Pending My Approval* exist ' +
'nowhere, 待我审批 is the sidebar label of `nav_approval_requests`, and 待我審核 ' +
'is in no locale pack at all.',
).toEqual([]);
});
}
});
Loading