diff --git a/.changeset/27091-book-tree-sibling-claims.md b/.changeset/27091-book-tree-sibling-claims.md new file mode 100644 index 00000000000..e742385a491 --- /dev/null +++ b/.changeset/27091-book-tree-sibling-claims.md @@ -0,0 +1,18 @@ +--- +'@objectstack/spec': patch +'@objectstack/rest': patch +'@object-ui/console': patch +--- + +Clause-②: no +Metadata-protocol: none + +fix(spec): a book's *Uncategorized* group no longer lists the pages a sibling book places (#27091) + +When two books split one package's docs, for example a user manual and an admin manual gated by `audience: { permissionSet }`, each book's tree used to list the other book's pages under a trailing *Uncategorized* group. `resolveBookTree` judged "claimed" against the book's own groups only. ADR-0046 §6.4 rolls up only the docs "left unplaced", and a doc a sibling book places is placed. + +- **`resolveBookTree(book, docs, bookPackage?, books?)`** takes an optional fourth argument, every registered book. With it, the *Uncategorized* group holds only the docs of the book's packages that no book claims. The claim judge is `resolveBookClaimedDocs` over each book, the same claim set `resolveDocAudiences` unions audiences over. Without it, the tree is the same as before. +- **`GET /meta/book/:name/tree`** passes the books it already reads, so an administrator opening the user manual no longer sees the admin pages under *Uncategorized*. A doc that no book claims still rolls up there. +- **The console's docs pages** render the book sidebar with the console's own copy of the resolver (`book-nav.ts`). It takes the same optional fourth argument, and the reader and the book landing pass the authored books, so the sidebar no longer lists a sibling book's pages under *Uncategorized* either. + +The change only removes entries from the catch-all: a sibling book's docs appear nowhere new, and a doc's effective audience (§6.7) does not change. diff --git a/packages/rest/src/meta-item-read-gate.ts b/packages/rest/src/meta-item-read-gate.ts index a2d858e01b5..5d14f27dd89 100644 --- a/packages/rest/src/meta-item-read-gate.ts +++ b/packages/rest/src/meta-item-read-gate.ts @@ -396,7 +396,9 @@ export interface DocsAudience { /** * `book` resolved over `docs`, narrowed to the entries this caller may read * — exactly the body `GET /meta/book/:name/tree` serves once the book's own - * audience has admitted the caller. `canRead` is a {@link docReader} over + * audience has admitted the caller. Its *Uncategorized* group holds only + * the docs NO book of this audience claims (#27091), so a sibling book's + * pages never land there. `canRead` is a {@link docReader} over * the same `docs` when the caller already holds one. */ readableTree( @@ -702,7 +704,10 @@ export async function resolveDocsAudience( admitsBook: (book: Book) => audienceAllows(book?.audience, caller), docReader, readableTree: (book, docs, canRead) => { - const tree = resolveBookTree(book, docs, book._packageId); + // [#27091] Every book this audience was built over judges the + // *Uncategorized* catch-all, so a doc a sibling book places is not + // this book's orphan (ADR-0046 §6.4). + const tree = resolveBookTree(book, docs, book._packageId, books); // The fast path serves the tree whole — no entry can fail an // audience every doc passes, and the empty-group drop below is // part of the narrowing, not of the tree. diff --git a/packages/rest/src/rest.test.ts b/packages/rest/src/rest.test.ts index 6351bf0792e..551bf228146 100644 --- a/packages/rest/src/rest.test.ts +++ b/packages/rest/src/rest.test.ts @@ -749,8 +749,9 @@ describe('RestServer', () => { const tree = res.json.mock.calls.at(-1)![0]; const docsInTree = tree.groups.flatMap((g: any) => g.entries.map((e: any) => e.doc)); expect(docsInTree).toContain('pub_intro'); - // org_intro / gated_setup are claimed by other books; the orphan pass - // would render them, but the audience filter must drop them for anon. + // org_intro / gated_setup are claimed by other books, so they are not + // this book's orphans (#27091); the audience filter would drop them for + // anon even if they were. expect(docsInTree).not.toContain('org_intro'); expect(docsInTree).not.toContain('gated_setup'); }); @@ -784,6 +785,27 @@ describe('RestServer', () => { expect(docsInTree).toContain('gated_setup'); }); + it('tree: [#27091] a holder of every book sees no sibling book\'s pages under Uncategorized', async () => { + // Three books split one package's docs: each doc is claimed by exactly + // one book, so no tree has a doc "left unplaced" (ADR-0046 §6.4). The + // holder reads every doc, so only the resolver can keep them out. + protocol.getMetaItems = metaByType([...BOOKS], [...DOCS, { name: 'loose_note', _packageId: 'pkg' }]); + const rest = new RestServer(server as any, protocol as any, ANON_API as any); + (rest as any).resolveExecCtx = vi.fn().mockResolvedValue({ userId: 'u1' }); + (rest as any).securityServiceProvider = async () => ({ + resolvePermissionSetNames: async () => ['member_default', 'crm_admin'], + }); + rest.registerRoutes(); + const route = getMetaRoute(rest, 'GET', '/api/v1/meta/book/:name/tree'); + for (const [name, own] of [['org_book', 'org_intro'], ['gated_book', 'gated_setup']]) { + const res = mockRes(); + await route!.handler({ params: { name }, query: {}, headers: {} }, res); + const tree = res.json.mock.calls.at(-1)![0]; + expect(Object.fromEntries(tree.groups.map((g: any) => [g.key, g.entries.map((e: any) => e.doc)]))) + .toEqual({ g: [own], uncategorized: ['loose_note'] }); + } + }); + it('list: anonymous /meta/book returns only public books', async () => { protocol.getMetaItems = metaByType(); const rest = new RestServer(server as any, protocol as any, ANON_API as any); diff --git a/packages/spec/liveness/doc.json b/packages/spec/liveness/doc.json index a4bae718801..6a3cfd2f57c 100644 --- a/packages/spec/liveness/doc.json +++ b/packages/spec/liveness/doc.json @@ -47,7 +47,7 @@ "tags": { "status": "live", "verifiedAt": "2026-09-27", - "evidence": "packages/spec/src/system/book.zod.ts#matchesInclude (`Array.isArray(doc.tags) && doc.tags.includes(include.tag)` — the tag branch of `include`); packages/spec/src/system/book.zod.ts#resolveBookTree (the caller that decides membership from it); packages/rest/src/meta-item-read-gate.ts#createMetaBookTreeAnswer (`tags: d.tags` in the tree corpus the route hands `audience.readableTree(book, docs)` — the transport half); packages/rest/src/meta-item-read-gate.ts#resolveBookTree (`resolveBookTree(book, docs, book._packageId)` — where that corpus reaches the resolver)", + "evidence": "packages/spec/src/system/book.zod.ts#matchesInclude (`Array.isArray(doc.tags) && doc.tags.includes(include.tag)` — the tag branch of `include`); packages/spec/src/system/book.zod.ts#resolveBookTree (the caller that decides membership from it); packages/rest/src/meta-item-read-gate.ts#createMetaBookTreeAnswer (`tags: d.tags` in the tree corpus the route hands `audience.readableTree(book, docs)` — the transport half); packages/rest/src/meta-item-read-gate.ts#resolveBookTree (`resolveBookTree(book, docs, book._packageId, books)` — where that corpus reaches the resolver)", "note": "DECLARED 2026-08-02 (#4509, ADR-0049) — the enforce half of enforce-or-remove. The consumer, the transport and the resolver-side interface all predated this key: matchesInclude compared `doc.tags` against a group's `include: { tag }`, the book route already forwarded `tags: d.tags`, and ResolverDoc declared `tags?: string[]` marked '(P3d; absent today)'. What was missing was one line HERE — DocSchema is strict, so authoring `tags:` was a parse error, every doc reached the resolver with tags undefined, and the `{ tag }` include variant could never match. Removing the variant was the alternative and was rejected: a union member has no clean tombstone (retiredKey covers object keys), so authors would have gotten a bare union error, and it would have discarded working matcher code. Live on arrival — the branch it feeds is reachable the moment a doc carries a tag. 2026-08-28: RE-ANCHORED (commit 8cb96ec41) and REPOINTED — `book.zod.ts:213` is `description?: string;` in the `ResolvedEntry` interface, and the parenthetical claiming it was \"matchesInclude tag branch\" named the right function ~61 lines away; the \"reached from :255 and :281\" hops were prose the ledger never bounded. `rest-server.ts:3218` had rotted onto `this.registerSecurityEndpoints(bp)` — a route-registration call, ~1,700 lines from the corpus that forwards the tags. Re-closed by hand against 93ea19bca." } } diff --git a/packages/spec/src/system/book.test.ts b/packages/spec/src/system/book.test.ts index 0f5d123f129..e9dec3b41dd 100644 --- a/packages/spec/src/system/book.test.ts +++ b/packages/spec/src/system/book.test.ts @@ -262,6 +262,84 @@ describe('resolveBookTree — the Uncategorized group holds only the book\'s own }); }); +// [#27091] ADR-0046 §6.4 rolls up docs "left unplaced"; a doc a SIBLING book +// places is placed. Two books over one package (split by audience, §6.7) used +// to list each other's pages under *Uncategorized*, because "claimed" was +// judged against the book's own groups only. +describe('resolveBookTree — a doc a sibling book claims is not this book\'s orphan (§6.4)', () => { + const corpus: ResolverDoc[] = [ + { name: 'crm_overview', packageId: 'crm' }, + { name: 'crm_sales_leads', packageId: 'crm' }, + { name: 'crm_admin_access', packageId: 'crm' }, + { name: 'crm_admin_automation', packageId: 'crm' }, + ]; + const withStray: ResolverDoc[] = [...corpus, { name: 'crm_stray', packageId: 'crm' }]; + const userManual: Book & { packageId?: string } = { + name: 'crm_manual', + packageId: 'crm', + groups: [ + { key: 'start', label: 'Start', include: 'crm_overview' }, + { key: 'sales', label: 'Sales', include: 'crm_sales_*' }, + ], + }; + const adminManual: Book & { packageId?: string } = { + name: 'crm_admin_manual', + packageId: 'crm', + audience: { permissionSet: 'system_admin' }, + groups: [{ key: 'admin', label: 'Admin', include: 'crm_admin_*' }], + }; + const books = [userManual, adminManual]; + const keysAndDocs = (tree: ResolvedBook) => + Object.fromEntries(tree.groups.map((g) => [g.key, g.entries.map((e) => e.doc)])); + const docsOf = (tree: ResolvedBook) => tree.groups.flatMap((g) => g.entries.map((e) => e.doc)); + + it('two books with disjoint includes over one package: neither tree has an Uncategorized group', () => { + expect(keysAndDocs(resolveBookTree(userManual, corpus, 'crm', books))).toEqual({ + start: ['crm_overview'], + sales: ['crm_sales_leads'], + }); + expect(keysAndDocs(resolveBookTree(adminManual, corpus, 'crm', books))).toEqual({ + admin: ['crm_admin_access', 'crm_admin_automation'], + }); + }); + + it('a doc NO book claims still rolls up under Uncategorized, in every book of its package', () => { + expect(keysAndDocs(resolveBookTree(userManual, withStray, 'crm', books)).uncategorized).toEqual(['crm_stray']); + expect(keysAndDocs(resolveBookTree(adminManual, withStray, 'crm', books)).uncategorized).toEqual(['crm_stray']); + }); + + it('an audience-gated sibling\'s docs never reach the other book\'s tree, and nothing appears anywhere new', () => { + const userDocs = docsOf(resolveBookTree(userManual, withStray, 'crm', books)); + expect(userDocs).not.toContain('crm_admin_access'); + expect(userDocs).not.toContain('crm_admin_automation'); + // The catch-all only shrinks: every entry with siblings was an entry without them. + for (const book of books) { + const before = docsOf(resolveBookTree(book, withStray, 'crm')); + expect(before).toEqual(expect.arrayContaining(docsOf(resolveBookTree(book, withStray, 'crm', books)))); + } + }); + + it('the §6.7 audience union is unmoved: the claim sets never read the catch-all', () => { + expect(Object.fromEntries(resolveDocAudiences(books, withStray))).toEqual({ + crm_overview: ['org'], + crm_sales_leads: ['org'], + crm_admin_access: [{ permissionSet: 'system_admin' }], + crm_admin_automation: [{ permissionSet: 'system_admin' }], + crm_stray: ['org'], + }); + for (const book of books) { + const authored = resolveBookTree(book, withStray, 'crm', books).groups.filter((g) => g.key !== 'uncategorized'); + expect([...resolveBookClaimedDocs(book, withStray, 'crm')]).toEqual(authored.flatMap((g) => g.entries.map((e) => e.doc))); + } + }); + + it('a single-book package is byte-identical to the tree without `books`', () => { + const alone = resolveBookTree(userManual, withStray, 'crm'); + expect(JSON.stringify(resolveBookTree(userManual, withStray, 'crm', [userManual]))).toBe(JSON.stringify(alone)); + expect(keysAndDocs(alone).uncategorized).toEqual(['crm_admin_access', 'crm_admin_automation', 'crm_stray']); + }); +}); + describe('deriveImplicitPackageBook + audience', () => { it('synthesizes a one-group book including every doc of the package', () => { const book = deriveImplicitPackageBook('app_todo', 'Todo'); diff --git a/packages/spec/src/system/book.zod.ts b/packages/spec/src/system/book.zod.ts index d3001a5dbdc..6bb1240e6c6 100644 --- a/packages/spec/src/system/book.zod.ts +++ b/packages/spec/src/system/book.zod.ts @@ -302,18 +302,33 @@ function entryFromDoc(doc: ResolverDoc): ResolvedEntry { * - Otherwise membership is derived: a doc joins the first group (in group * order) whose `include` matches it OR whose `key` equals the doc's explicit * `group`. Within a group, docs sort by `doc.order` then label. - * - Any doc OF THE BOOK'S PACKAGES claimed by no group falls into a - * synthetic *Uncategorized* group appended last — no doc of the book is - * ever dropped (ADR-0046 §6.4: "any doc left unplaced rolls up under a - * synthetic *Uncategorized* group", the unplaced docs being the + * - Any doc OF THE BOOK'S PACKAGES that NO registered book claims falls + * into a synthetic *Uncategorized* group appended last — no doc of the + * book is ever dropped (ADR-0046 §6.4: "any doc left unplaced rolls up + * under a synthetic *Uncategorized* group", the unplaced docs being the * package's). The book's packages are `bookPackage` plus every group's * `package`; a doc of any OTHER package is not this book's to catch — it * stays reachable through its own package's book. When the book declares * no package at all, every unclaimed doc is an orphan, as before. * Explicit placement is NOT scoped: a doc of another package whose * `group` names one of this book's groups still joins that group. + * - "Claimed" is judged against `books`, every registered book (#27091): + * a doc a SIBLING book places is placed, not "left unplaced" (§6.4), so + * it is not this book's orphan — two books splitting one package's docs + * (by audience, §6.7) no longer list each other's pages under + * *Uncategorized*. The judge is `resolveBookClaimedDocs` over each book, + * the very claim set `resolveDocAudiences` unions audiences over, so the + * catch-all only ever SHRINKS: a sibling's docs leave it and appear + * nowhere new. That claim set excludes the *Uncategorized* group, so the + * §6.7 audience union cannot move. Without `books` only this book's own + * groups judge, as before. */ -export function resolveBookTree(book: Book, docs: ResolverDoc[], bookPackage?: string): ResolvedBook { +export function resolveBookTree( + book: Book, + docs: ResolverDoc[], + bookPackage?: string, + books?: readonly AudienceBook[], +): ResolvedBook { const groupsSorted = [...book.groups] .map((g, i) => ({ g, i })) .sort((a, b) => (a.g.order ?? 0) - (b.g.order ?? 0) || a.i - b.i) @@ -398,8 +413,9 @@ export function resolveBookTree(book: Book, docs: ResolverDoc[], bookPackage?: s const ownPackages = [bookPackage, ...book.groups.map((g) => g.package)].filter( (p): p is string => typeof p === 'string' && p.length > 0, ); + const claimedByAnyBook = books ? claimedByBooks(books, docs) : undefined; const orphans = docs - .filter((d) => !claimed.has(d.name)) + .filter((d) => !claimed.has(d.name) && !claimedByAnyBook?.has(d.name)) .filter((d) => ownPackages.length === 0 || ownPackages.some((p) => matchesInclude(d, '*', p))) .sort(byOrderThenLabel); if (orphans.length) { @@ -493,6 +509,26 @@ export interface AudienceBook extends Book { packageId?: string; } +/** + * THE cross-book claim pass: each book with the docs it claims + * (`resolveBookClaimedDocs` over its own `packageId`). `resolveDocAudiences` + * unions audiences over it and `resolveBookTree`'s orphan pass reads its + * union ({@link claimedByBooks}), so the two never judge "claimed" apart. + */ +function* claimsPerBook( + books: readonly AudienceBook[], + docs: ResolverDoc[], +): Generator<[AudienceBook, Set]> { + for (const book of books) yield [book, resolveBookClaimedDocs(book, docs, book.packageId)]; +} + +/** Every doc some book in `books` claims: the union of {@link claimsPerBook}. */ +function claimedByBooks(books: readonly AudienceBook[], docs: ResolverDoc[]): Set { + const all = new Set(); + for (const [, claimed] of claimsPerBook(books, docs)) for (const name of claimed) all.add(name); + return all; +} + /** * Effective audience of every doc = the union over the books that claim it * (ADR-0046 §6.7). A doc claimed by no authored book falls back to `'org'` — @@ -507,9 +543,9 @@ export function resolveDocAudiences( ): Map { const audiences = new Map(); for (const doc of docs) audiences.set(doc.name, []); - for (const book of books) { + for (const [book, claimed] of claimsPerBook(books, docs)) { const audience: BookAudience = book.audience ?? 'org'; - for (const name of resolveBookClaimedDocs(book, docs, book.packageId)) { + for (const name of claimed) { audiences.get(name)?.push(audience); } } diff --git a/ui/apps/console/src/pages/BookPage.tsx b/ui/apps/console/src/pages/BookPage.tsx index 8a864653d56..b791f880760 100644 --- a/ui/apps/console/src/pages/BookPage.tsx +++ b/ui/apps/console/src/pages/BookPage.tsx @@ -45,12 +45,12 @@ export function DocRefusal({ name, message }: { name?: string; message?: string export default function BookPage() { const { slug, appName } = useParams<{ slug: string; appName?: string }>(); const { t } = useObjectTranslation(); - const { books, docs, state } = useBookData(); + const { books, authoredBooks, docs, state } = useBookData(); const found = useMemo(() => books.find((b) => bookSlug(b) === slug), [books, slug]); const opensTo = useMemo( - () => (found ? firstDoc(resolveBookTree(found, docs)) : undefined), - [found, docs], + () => (found ? firstDoc(resolveBookTree(found, docs, undefined, authoredBooks)) : undefined), + [found, docs, authoredBooks], ); if (state === 'loading') { diff --git a/ui/apps/console/src/pages/DocPage.tsx b/ui/apps/console/src/pages/DocPage.tsx index 28c934b4091..d41eb5b096a 100644 --- a/ui/apps/console/src/pages/DocPage.tsx +++ b/ui/apps/console/src/pages/DocPage.tsx @@ -158,12 +158,14 @@ export default function DocPage() { // reading in, so its spine renders as a persistent left nav. Resolved from // the portal book set; degrades to no sidebar if the slug is unknown (e.g. // the backend serves no books yet). - const { books, docs: allDocs } = useBookData(); + const { books, authoredBooks, docs: allDocs } = useBookData(); const base = appName ? `/apps/${appName}/docs` : '/docs'; const resolvedBook = useMemo(() => { const book = books.find((b) => bookSlug(b) === slug); - return book ? resolveBookTree(book, allDocs) : null; - }, [books, allDocs, slug]); + // Every authored book judges the Uncategorized group, so a doc a sibling + // book places is not listed there (objectstack#27091). + return book ? resolveBookTree(book, allDocs, undefined, authoredBooks) : null; + }, [books, authoredBooks, allDocs, slug]); const docHref = useCallback((docName: string) => `${base}/${slug}/${docName}`, [base, slug]); // A `:slug` segment that is a book's NAME rather than its slug — what a // `{ type: 'doc', book, doc }` navigation entry links to (objectui#11197) — is diff --git a/ui/apps/console/src/pages/book-nav.siblingBooks-27091.test.tsx b/ui/apps/console/src/pages/book-nav.siblingBooks-27091.test.tsx new file mode 100644 index 00000000000..4242951fda2 --- /dev/null +++ b/ui/apps/console/src/pages/book-nav.siblingBooks-27091.test.tsx @@ -0,0 +1,135 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#27091 — a doc a SIBLING book places is not this book's orphan. + * + * ADR-0046 §6.4 rolls up only the docs "left unplaced". HotCRM ships two books + * over one package (hotcrm#2327): `crm_manual` and `crm_admin_manual`, the + * latter gated by a permission set. Every doc is claimed by exactly one book, + * yet each book's sidebar listed the other's pages under a trailing + * Uncategorized group, because "claimed" was judged against the book's own + * groups only. The framework's `resolveBookTree` judges against every book + * since objectstack#27091; this port follows it, and the docs pages pass the + * AUTHORED books. + * + * Each resolver case is checked against the spec's resolver over the same + * docs and books, the answer `GET /meta/book/:name/tree` gives. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { resolveBookTree as specResolveBookTree } from '@objectstack/spec/system'; + +const { ADAPTER } = vi.hoisted(() => { + const ADAPTER = { + getClient: () => ({ + meta: { + getItems: async () => [], + getItem: async (_type: string, name: string) => ({ item: { name, content: `# ${name}` } }), + }, + }), + }; + return { ADAPTER }; +}); + +vi.mock('@object-ui/app-shell', async (importOriginal) => ({ + ...(await importOriginal>()), + useAdapter: () => ADAPTER, +})); + +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => ({ t: (_k: string, o?: { defaultValue?: string }) => o?.defaultValue ?? _k }), +})); + +// Imported AFTER the mocks so the page picks up the mocked modules. +import DocPage from './DocPage'; +import { BookDataContext } from './use-book-data'; +import { buildPortalBooks, resolveBookTree, type Book, type ResolvedBook, type ResolverDoc } from './book-nav'; + +const docs: ResolverDoc[] = [ + { name: 'crm_overview', label: 'Overview', packageId: 'crm' }, + { name: 'crm_sales_leads', label: 'Leads', packageId: 'crm' }, + { name: 'crm_admin_access', label: 'Access control', packageId: 'crm' }, + { name: 'crm_admin_automation', label: 'Automation', packageId: 'crm' }, +]; +const userManual: Book = { + name: 'crm_manual', + label: 'CRM Manual', + packageId: 'crm', + groups: [ + { key: 'start', label: 'Start', include: 'crm_overview' }, + { key: 'sales', label: 'Sales', include: 'crm_sales_*' }, + ], +}; +const adminManual: Book = { + name: 'crm_admin_manual', + label: 'CRM Admin Manual', + packageId: 'crm', + groups: [{ key: 'admin', label: 'Admin', include: 'crm_admin_*' }], +}; +const authored = [userManual, adminManual]; + +const keysAndDocs = (r: { groups: { key: string; entries: { doc?: string }[] }[] }) => + Object.fromEntries(r.groups.map((g) => [g.key, g.entries.map((e) => e.doc)])); + +/** What `GET /meta/book/:name/tree` answers: the spec resolver, judged by every book. */ +const specBook = (b: Book) => ({ name: b.name, label: b.label, groups: b.groups ?? [], packageId: b.packageId }); +const endpointTree = (b: Book, all: ResolverDoc[]) => + specResolveBookTree(specBook(b), all, b.packageId, authored.map(specBook)); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('objectstack#27091 — the port: a sibling book\'s pages are not Uncategorized', () => { + it('HotCRM\'s two books: neither tree has an Uncategorized group, and the spec agrees', () => { + const user: ResolvedBook = resolveBookTree(userManual, docs, undefined, authored); + expect(keysAndDocs(user)).toEqual({ start: ['crm_overview'], sales: ['crm_sales_leads'] }); + expect(keysAndDocs(resolveBookTree(adminManual, docs, undefined, authored))).toEqual({ + admin: ['crm_admin_access', 'crm_admin_automation'], + }); + for (const b of authored) { + expect(keysAndDocs(resolveBookTree(b, docs, undefined, authored))).toEqual(keysAndDocs(endpointTree(b, docs))); + } + }); + + it('a doc NO book claims still rolls up under Uncategorized', () => { + const withStray = [...docs, { name: 'crm_stray', label: 'Stray', packageId: 'crm' }]; + expect(keysAndDocs(resolveBookTree(userManual, withStray, undefined, authored)).uncategorized).toEqual(['crm_stray']); + }); + + it('CONTROL: without `books` the tree is unchanged, the sibling\'s pages still in Uncategorized', () => { + expect(keysAndDocs(resolveBookTree(userManual, docs)).uncategorized).toEqual([ + 'crm_admin_access', + 'crm_admin_automation', + ]); + }); +}); + +describe('objectstack#27091 — the reader\'s sidebar for crm_manual', () => { + it('lists the book\'s own pages and no Uncategorized group holding the admin pages', async () => { + const value = { authoredBooks: authored, books: buildPortalBooks(authored, docs), docs, state: 'ready' as const }; + render( + + + + } /> + + + , + ); + expect((await screen.findAllByRole('link', { name: 'Leads' })).length).toBeGreaterThan(0); + expect(screen.queryByText('Uncategorized')).toBeNull(); + expect(screen.queryByRole('link', { name: 'Access control' })).toBeNull(); + expect(screen.queryByRole('link', { name: 'Automation' })).toBeNull(); + }); +}); diff --git a/ui/apps/console/src/pages/book-nav.ts b/ui/apps/console/src/pages/book-nav.ts index f6ba8243fa7..d3a3071ca09 100644 --- a/ui/apps/console/src/pages/book-nav.ts +++ b/ui/apps/console/src/pages/book-nav.ts @@ -182,8 +182,19 @@ function entryFromDoc(doc: ResolverDoc): ResolvedEntry { /** * Resolve a book spine against the current doc set into a rendered tree. * Faithful port of ADR-0046 §6.2.1 — see book-nav.ts header. + * + * `books` is every authored book: a doc one of them claims is placed, not + * "left unplaced" (§6.4), so it never lands in this book's Uncategorized + * group (the framework's resolver answers the same since objectstack#27091). + * Pass the AUTHORED books, never the portal set: its synthetic implicit + * per-package books (§6.4) are a reading fallback, not an authored claim. */ -export function resolveBookTree(book: Book, docs: ResolverDoc[], bookPackage?: string): ResolvedBook { +export function resolveBookTree( + book: Book, + docs: ResolverDoc[], + bookPackage?: string, + books?: readonly Book[], +): ResolvedBook { const scopeDefault = bookPackage ?? book.packageId; const groupsSorted = [...(book.groups ?? [])] .map((g, i) => ({ g, i })) @@ -265,12 +276,14 @@ export function resolveBookTree(book: Book, docs: ResolverDoc[], bookPackage?: s // asked through `include`'s own scope test (ADR-0046 §6.4; the framework's // resolver answers the same since objectstack#20980). Another package's // unplaced doc is that package's own book's orphan, not this one's. A book - // that declares no package keeps every unclaimed doc. + // that declares no package keeps every unclaimed doc. A doc another book in + // `books` claims is not an orphan either. const ownPackages = [scopeDefault, ...groupsSorted.map((g) => g.package)].filter( (p): p is string => typeof p === 'string' && p.length > 0, ); + const claimedByAnyBook = books ? claimedByBooks(books, docs) : undefined; const orphans = docs - .filter((d) => !claimed.has(d.name)) + .filter((d) => !claimed.has(d.name) && !claimedByAnyBook?.has(d.name)) .filter((d) => ownPackages.length === 0 || ownPackages.some((p) => matchesInclude(d, '*', p))) .sort(byOrderThenLabel); if (orphans.length) { @@ -285,6 +298,22 @@ export function resolveBookTree(book: Book, docs: ResolverDoc[], bookPackage?: s return { name: book.name, label: book.label, description: book.description, groups: resolvedGroups }; } +/** + * Every doc some book in `books` claims: the docs its authored groups place, + * never its synthetic Uncategorized group (the framework's + * `resolveBookClaimedDocs`, unioned over the books). + */ +function claimedByBooks(books: readonly Book[], docs: ResolverDoc[]): Set { + const all = new Set(); + for (const book of books) { + for (const g of resolveBookTree(book, docs).groups) { + if (g.synthetic) continue; + for (const e of g.entries) if (e.doc) all.add(e.doc); + } + } + return all; +} + /** * Synthesize the implicit per-package book (ADR-0046 §6.4): no authored book ⇒ * one book keyed by the package id, a single group including every doc.