From a36dbd78a49d482302d61d82e9a94c573c9ca8aa Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 14:29:47 +0000 Subject: [PATCH] fix(console): the docs portal renders the book resolver's answer; the Uncategorized pre-filter is retired The console's port of `resolveBookTree` now scopes its synthetic Uncategorized group to the book's own packages (the book's, and each group's `package`), asked through `include`'s own scope test, as the framework's resolver does since objectstack#20980. `scopeDocsToBook` and `bookPackages` are deleted; `BookPage`, `DocPage`, `countBookDocs` and `findBookContainingDoc` resolve over every doc they read. Pins: the control pair, corner 1 (a group's `package`), corner 2 (a `pages`-pinned doc of another package), and the two further shapes whose portal answer moves to the resolver's, each checked against the spec's `resolveBookTree`; and the rendered sidebar through the real route table. Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude --- .changeset/11340-book-tree-prefilter.md | 15 ++ apps/console/src/pages/BookPage.tsx | 4 +- apps/console/src/pages/DocPage.tsx | 4 +- .../book-nav.explicitGroup-11245.test.ts | 12 +- apps/console/src/pages/book-nav.test.ts | 7 +- apps/console/src/pages/book-nav.ts | 59 +++----- .../book-nav.uncategorizedScope-11340.test.ts | 129 ++++++++++++++++++ ...s-portal.uncategorizedScope-11340.test.tsx | 127 +++++++++++++++++ 8 files changed, 307 insertions(+), 50 deletions(-) create mode 100644 .changeset/11340-book-tree-prefilter.md create mode 100644 apps/console/src/pages/book-nav.uncategorizedScope-11340.test.ts create mode 100644 apps/console/src/pages/docs-portal.uncategorizedScope-11340.test.tsx diff --git a/.changeset/11340-book-tree-prefilter.md b/.changeset/11340-book-tree-prefilter.md new file mode 100644 index 0000000000..5697452e6a --- /dev/null +++ b/.changeset/11340-book-tree-prefilter.md @@ -0,0 +1,15 @@ +--- +'@object-ui/console': patch +--- + +The docs portal's book sidebar now shows what the book resolver answers, with nothing narrowing the docs in front of it (objectui#11340, ADR-0046 §6.4). The resolver decides book membership. Since objectstack#20980 (`@objectstack/spec` 17.6.0), the framework's `resolveBookTree` keeps a doc in a book's synthetic Uncategorized group only when the doc belongs to one of the book's packages: the book's own, or a group's `package`. The console's port of that resolver now scopes its Uncategorized group the same way. The console's pre-filter, which dropped other packages' docs before resolving, is gone. + +What a reader sees: + +- **Unchanged: another package's ungrouped doc** stays out of a book's Uncategorized group, and the book's own ungrouped docs stay in it. +- **Unchanged: a group's `package` (corner 1).** That package's unplaced docs are listed under the book's Uncategorized group, as the resolver answers. The portal already listed them, because the pre-filter kept every package the book draws from. +- **Changed: a doc of another package pinned by a group's `pages` (corner 2)** is still listed where the pin places it, and now under its own label. Before, the pre-filter had dropped the doc, so the entry showed the doc's bare name. +- **Changed: a book with no package of its own whose group names a `package`.** The groups that name no package now match docs from every package by `include`, as the resolver answers. Before, they matched only the docs of the packages the groups name. +- **Changed: a doc of another package whose `group` names a `pages` group with no `...`.** It is no longer listed under the book's Uncategorized group. That group collects no doc by its key, and the resolver keeps another package's unplaced doc out of Uncategorized. + +The book cards' doc counts and the book a doc opens in follow the same answer. Nothing else changes: no export, prop, type member or i18n key is added or removed. diff --git a/apps/console/src/pages/BookPage.tsx b/apps/console/src/pages/BookPage.tsx index 7f3e935dd4..3941899b31 100644 --- a/apps/console/src/pages/BookPage.tsx +++ b/apps/console/src/pages/BookPage.tsx @@ -11,7 +11,7 @@ import { Navigate, useParams } from 'react-router-dom'; import { BookOpen, FileQuestion, Loader2, Lock } from 'lucide-react'; import { DocShell } from './DocShell'; import { useBookData } from './use-book-data'; -import { resolveBookTree, scopeDocsToBook, bookSlug, firstDoc } from './book-nav'; +import { resolveBookTree, bookSlug, firstDoc } from './book-nav'; /** * The refusal a member outside a doc's or a book's audience gets on a direct @@ -47,7 +47,7 @@ export default function BookPage() { const found = useMemo(() => books.find((b) => bookSlug(b) === slug), [books, slug]); const opensTo = useMemo( - () => (found ? firstDoc(resolveBookTree(found, scopeDocsToBook(found, docs))) : undefined), + () => (found ? firstDoc(resolveBookTree(found, docs)) : undefined), [found, docs], ); diff --git a/apps/console/src/pages/DocPage.tsx b/apps/console/src/pages/DocPage.tsx index fbf0419fcf..27ac4e46f5 100644 --- a/apps/console/src/pages/DocPage.tsx +++ b/apps/console/src/pages/DocPage.tsx @@ -17,7 +17,7 @@ import { DocShell } from './DocShell'; import { BookSidebar } from './BookSidebar'; import { DocRefusal } from './BookPage'; import { useBookData } from './use-book-data'; -import { resolveBookTree, scopeDocsToBook, bookSlug, bookNamedBy, type ResolvedBook } from './book-nav'; +import { resolveBookTree, bookSlug, bookNamedBy, type ResolvedBook } from './book-nav'; interface DocItem { name: string; @@ -157,7 +157,7 @@ export default function DocPage() { const base = appName ? `/apps/${appName}/docs` : '/docs'; const resolvedBook = useMemo(() => { const book = books.find((b) => bookSlug(b) === slug); - return book ? resolveBookTree(book, scopeDocsToBook(book, allDocs)) : null; + return book ? resolveBookTree(book, allDocs) : null; }, [books, 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 diff --git a/apps/console/src/pages/book-nav.explicitGroup-11245.test.ts b/apps/console/src/pages/book-nav.explicitGroup-11245.test.ts index e920b91ea0..3ac43ddcce 100644 --- a/apps/console/src/pages/book-nav.explicitGroup-11245.test.ts +++ b/apps/console/src/pages/book-nav.explicitGroup-11245.test.ts @@ -18,16 +18,16 @@ * resolving, so a doc the admin placed from the doc editor (objectui#11241) * was listed by the endpoint and missing from the sidebar. * - * The package filter still narrows what the synthetic Uncategorized group can - * collect — the controls below hold that — and `include` stays scoped by the - * resolver itself. + * That pre-filter is retired (objectui#11340): the portal renders the resolver's + * answer over every doc. The resolver's own orphan pass keeps another package's + * unplaced docs out of the synthetic Uncategorized group — the controls below + * hold that — and `include` stays scoped by the resolver itself. */ import { describe, it, expect } from 'vitest'; import { resolveBookTree as specResolveBookTree } from '@objectstack/spec/system'; import { resolveBookTree, - scopeDocsToBook, countBookDocs, type Book, type ResolvedBook, @@ -59,8 +59,8 @@ const docs: ResolverDoc[] = [ { name: 'other_stray', label: 'Other Stray', group: 'appendix', packageId: OTHER_PKG }, ]; -/** What the portal renders for a book: the same two calls `DocPage` / `BookPage` make. */ -const portalTree = (b: Book, all: ResolverDoc[]): ResolvedBook => resolveBookTree(b, scopeDocsToBook(b, all)); +/** What the portal renders for a book: the same call `DocPage` / `BookPage` make, over every doc. */ +const portalTree = (b: Book, all: ResolverDoc[]): ResolvedBook => resolveBookTree(b, all); /** What `GET /meta/book/:name/tree` answers: the spec resolver over every doc, scoped by the book's package. */ const endpointTree = (b: Book, all: ResolverDoc[]) => diff --git a/apps/console/src/pages/book-nav.test.ts b/apps/console/src/pages/book-nav.test.ts index 803dba7679..c1529ff09b 100644 --- a/apps/console/src/pages/book-nav.test.ts +++ b/apps/console/src/pages/book-nav.test.ts @@ -47,7 +47,7 @@ describe('resolveBookTree (ADR-0046 §6)', () => { expect(r.groups[1].entries.map((e) => e.doc)).toEqual(['crm_guide_deal', 'crm_guide_lead']); }); - it('drops nothing — unmatched docs fall into Uncategorized last', () => { + it('a book that declares no package drops nothing — unmatched docs fall into Uncategorized last', () => { const book: Book = { name: 'b', groups: [{ key: 'g', label: 'G', include: 'crm_guide_*' }] }; const r = resolveBookTree(book, docs); const uncategorized = r.groups.find((g) => g.key === 'uncategorized'); @@ -109,8 +109,9 @@ describe('resolveBookTree (ADR-0046 §6)', () => { const book: Book = { name: 'bk', groups: [{ key: 'g', label: 'G', include: '*', package: 'a' }] }; const r = resolveBookTree(book, mixed); expect(r.groups[0].entries.map((e) => e.doc)).toEqual(['a_x']); - // b_x is orphaned, not in group g - expect(r.groups.find((g) => g.key === 'uncategorized')?.entries.map((e) => e.doc)).toEqual(['b_x']); + // b_x is in no group, and package b is none of this book's packages, so it is + // not this book's orphan either: no Uncategorized group (objectui#11340) + expect(r.groups.map((g) => g.key)).toEqual(['g']); }); }); diff --git a/apps/console/src/pages/book-nav.ts b/apps/console/src/pages/book-nav.ts index 22b1b9f7dd..f6ba8243fa 100644 --- a/apps/console/src/pages/book-nav.ts +++ b/apps/console/src/pages/book-nav.ts @@ -19,6 +19,10 @@ * so the portal works the moment the backend serves `book` + `doc` through * the ordinary metadata API — without depending on a particular published * `@objectstack/spec` version or the `/meta/book/:name/tree` endpoint. + * + * The portal renders this resolver's answer over every doc it read: nothing + * narrows the doc set before it (objectui#11340), so book membership has one + * authority — the resolver — as ADR-0046 §6.4 asks. */ // ── Authored spine (a subset of the framework `Book` shape) ──────────────── @@ -90,9 +94,9 @@ export interface ResolvedGroup { label: string; entries: ResolvedEntry[]; /** - * True for the synthetic "Uncategorized" catch-all. It appears in EVERY - * book's resolution (it absorbs whatever the book's own groups didn't - * claim), so it must be excluded from authored-membership questions like + * True for the synthetic "Uncategorized" catch-all. It can appear in any + * book's resolution (it absorbs the book's own packages' docs that no group + * claimed), so it must be excluded from authored-membership questions like * "how many docs does this book organize?" or "which book owns this doc?". */ synthetic?: boolean; @@ -256,8 +260,19 @@ export function resolveBookTree(book: Book, docs: ResolverDoc[], bookPackage?: s } // Orphans: docs claimed by no group fall into a synthetic Uncategorized - // group appended last — nothing is ever dropped. - const orphans = docs.filter((d) => !claimed.has(d.name)).sort(byOrderThenLabel); + // group appended last — but only the docs of the book's own packages: the + // book's (`bookPackage`, else `book.packageId`) and each group's `package`, + // 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. + const ownPackages = [scopeDefault, ...groupsSorted.map((g) => g.package)].filter( + (p): p is string => typeof p === 'string' && p.length > 0, + ); + const orphans = docs + .filter((d) => !claimed.has(d.name)) + .filter((d) => ownPackages.length === 0 || ownPackages.some((p) => matchesInclude(d, '*', p))) + .sort(byOrderThenLabel); if (orphans.length) { resolvedGroups.push({ key: UNCATEGORIZED_KEY, @@ -299,36 +314,6 @@ export interface BookCard { docCount: number; } -/** The set of packages a book draws from: its own plus any group overrides. */ -function bookPackages(book: Book): Set { - const pkgs = new Set(); - if (book.packageId) pkgs.add(book.packageId); - for (const g of book.groups ?? []) if (g.package) pkgs.add(g.package); - return pkgs; -} - -/** - * Narrow the doc set to the packages a book draws from before resolving, so the - * synthetic Uncategorized group stays scoped to the book instead of vacuuming - * up every other package's docs. When a book declares no package (its own or a - * group override) we can't scope safely, so all docs are kept and membership - * falls to each group's `include` glob. - * - * A doc whose own `group` names one of this book's group keys is kept whatever - * its package (objectui#11245): the framework's `resolveBookTree` places a doc - * by its explicit `group` with no package scope, and the book tree endpoint - * answers that resolver, so the sidebar lists the doc where the endpoint does. - * The package filter narrows only what `include` and the synthetic - * Uncategorized group can collect; `include` stays scoped by the resolver - * itself (`group.package`, else the book's package). - */ -export function scopeDocsToBook(book: Book, docs: ResolverDoc[]): ResolverDoc[] { - const pkgs = bookPackages(book); - if (pkgs.size === 0) return docs; - const groupKeys = new Set((book.groups ?? []).map((g) => g.key)); - return docs.filter((d) => pkgs.has(pkgOf(d)) || (d.group != null && groupKeys.has(d.group))); -} - /** Sort books for the index: by `order`, then label, then name (stable). */ export function sortBooks(books: Book[]): Book[] { return [...books] @@ -347,7 +332,7 @@ export function sortBooks(books: Book[]): Book[] { * excluding the synthetic Uncategorized catch-all (see {@link ResolvedGroup}). */ export function countBookDocs(book: Book, docs: ResolverDoc[]): number { - const resolved = resolveBookTree(book, scopeDocsToBook(book, docs)); + const resolved = resolveBookTree(book, docs); const seen = new Set(); for (const g of resolved.groups) { if (g.synthetic) continue; @@ -438,7 +423,7 @@ export function findBookContainingDoc( docName: string, ): { book: Book; resolved: ResolvedBook } | null { for (const book of sortBooks(books)) { - const resolved = resolveBookTree(book, scopeDocsToBook(book, docs)); + const resolved = resolveBookTree(book, docs); // Authored membership only — a doc that merely lands in this book's // synthetic Uncategorized group is not "owned" by it (every book has one). const has = resolved.groups.some( diff --git a/apps/console/src/pages/book-nav.uncategorizedScope-11340.test.ts b/apps/console/src/pages/book-nav.uncategorizedScope-11340.test.ts new file mode 100644 index 0000000000..2dd39f756b --- /dev/null +++ b/apps/console/src/pages/book-nav.uncategorizedScope-11340.test.ts @@ -0,0 +1,129 @@ +/** + * 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. + */ + +/** + * objectui#11340 — the book's synthetic Uncategorized group holds only the + * unplaced docs of the book's own packages (the book's, and each group's + * `package`), and the portal renders that answer with no pre-filter in front. + * + * The framework's `resolveBookTree` scopes its orphan pass that way since + * objectstack#20980 (`@objectstack/spec` 17.6.0). The portal's resolver is a + * local port of it; until this card the port's orphan pass was unscoped, and the + * portal narrowed the doc set before resolving (`scopeDocsToBook`) to make up for + * it. That second authority is retired; the port's orphan pass now scopes. + * + * Each case below states the portal's answer AND checks it against the spec's + * resolver over the same docs, scoped by the book's package — what `GET + * /meta/book/:name/tree` answers. Every fixture doc carries a `packageId`, the + * `_packageId` stamp the server reads for the same test. + */ + +import { describe, it, expect } from 'vitest'; +import { resolveBookTree as specResolveBookTree } from '@objectstack/spec/system'; +import { resolveBookTree, countBookDocs, type Book, type ResolvedBook, type ResolverDoc } from './book-nav'; + +/** What `GET /meta/book/:name/tree` answers: the spec resolver, scoped by the book's package. */ +const endpointTree = (b: Book, all: ResolverDoc[]) => + specResolveBookTree({ name: b.name, label: b.label, groups: b.groups ?? [] }, all, b.packageId); + +/** Every group, authored and synthetic, as `{ key, label, entries }` (the portal-only flag dropped). */ +const groupsOf = (r: { groups: { key: string; label: string; entries: unknown[] }[] }) => + r.groups.map((g) => ({ key: g.key, label: g.label, entries: g.entries })); + +const members = (r: ResolvedBook, key: string) => r.groups.find((g) => g.key === key)?.entries.map((e) => e.doc); +const everywhere = (r: ResolvedBook) => r.groups.flatMap((g) => g.entries.map((e) => e.doc)); + +describe('objectui#11340 — Uncategorized holds only the book\'s own packages\' unplaced docs', () => { + const book: Book = { + name: 'crm_manual', + label: 'CRM Manual', + packageId: 'crm', + groups: [{ key: 'start', label: 'Getting started', include: 'crm_intro' }], + }; + const docs: ResolverDoc[] = [ + { name: 'crm_intro', label: 'Intro', packageId: 'crm' }, + { name: 'crm_stray', label: 'Stray', packageId: 'crm' }, + { name: 'ops_keys', label: 'Keys', packageId: 'ops' }, + ]; + + it('another package\'s ungrouped doc is absent; the book\'s own ungrouped doc is present (the control)', () => { + const tree = resolveBookTree(book, docs); + expect(members(tree, 'uncategorized')).toEqual(['crm_stray']); + expect(everywhere(tree)).not.toContain('ops_keys'); + expect(groupsOf(tree)).toEqual(groupsOf(endpointTree(book, docs))); + }); + + it('a book that declares no package keeps every unclaimed doc', () => { + const unscoped: Book = { name: 'manual', groups: book.groups }; + // Ordered by label: Keys, then Stray. + expect(members(resolveBookTree(unscoped, docs), 'uncategorized')).toEqual(['ops_keys', 'crm_stray']); + expect(groupsOf(resolveBookTree(unscoped, docs))).toEqual(groupsOf(endpointTree(unscoped, docs))); + }); + + it('corner 1: a group\'s `package` makes that package\'s unplaced docs the book\'s orphans too', () => { + const scoped: Book = { + name: 'a_manual', + packageId: 'a', + groups: [ + { key: 'own', label: 'Own', order: 1, include: 'a_*' }, + { key: 'ext', label: 'Ext', order: 2, include: 'b_ref_*', package: 'b' }, + ], + }; + const mixed: ResolverDoc[] = [ + { name: 'a_1', packageId: 'a' }, + { name: 'b_ref_1', packageId: 'b' }, + { name: 'b_note', packageId: 'b' }, + { name: 'c_note', packageId: 'c' }, + ]; + const tree = resolveBookTree(scoped, mixed); + expect(members(tree, 'ext')).toEqual(['b_ref_1']); + expect(members(tree, 'uncategorized')).toEqual(['b_note']); + expect(everywhere(tree)).not.toContain('c_note'); + expect(groupsOf(tree)).toEqual(groupsOf(endpointTree(scoped, mixed))); + }); + + it('corner 2: a `pages`-pinned doc of another package is listed where the pin places it, as that doc', () => { + const pinned: Book = { + ...book, + groups: [{ key: 'pinned', label: 'Pinned', pages: ['crm_intro', 'ops_keys'] }], + }; + const tree = resolveBookTree(pinned, docs); + expect(tree.groups[0].entries).toEqual([ + { doc: 'crm_intro', label: 'Intro', description: undefined }, + { doc: 'ops_keys', label: 'Keys', description: undefined }, + ]); + expect(members(tree, 'uncategorized')).toEqual(['crm_stray']); + expect(countBookDocs(pinned, docs)).toBe(2); + expect(groupsOf(tree)).toEqual(groupsOf(endpointTree(pinned, docs))); + }); + + it('a book with no package of its own and a group `package` lets its unscoped `include` read every package', () => { + const noOwn: Book = { + name: 'manual', + groups: [ + { key: 'all', label: 'All', order: 1, include: '*' }, + { key: 'ext', label: 'Ext', order: 2, include: 'b_ref_*', package: 'b' }, + ], + }; + const mixed: ResolverDoc[] = [ + { name: 'a_1', packageId: 'a' }, + { name: 'b_1', packageId: 'b' }, + ]; + const tree = resolveBookTree(noOwn, mixed); + expect(members(tree, 'all')).toEqual(['a_1', 'b_1']); + expect(groupsOf(tree)).toEqual(groupsOf(endpointTree(noOwn, mixed))); + }); + + it('another package\'s doc naming a `pages` group that collects nothing by key is in no group of the book', () => { + const pinned: Book = { ...book, groups: [{ key: 'pinned', label: 'Pinned', pages: ['crm_intro'] }] }; + const withForeign: ResolverDoc[] = [...docs, { name: 'ops_named', packageId: 'ops', group: 'pinned' }]; + const tree = resolveBookTree(pinned, withForeign); + expect(everywhere(tree)).not.toContain('ops_named'); + expect(groupsOf(tree)).toEqual(groupsOf(endpointTree(pinned, withForeign))); + }); +}); diff --git a/apps/console/src/pages/docs-portal.uncategorizedScope-11340.test.tsx b/apps/console/src/pages/docs-portal.uncategorizedScope-11340.test.tsx new file mode 100644 index 0000000000..90f644a3b4 --- /dev/null +++ b/apps/console/src/pages/docs-portal.uncategorizedScope-11340.test.tsx @@ -0,0 +1,127 @@ +/** + * 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. + */ + +/** + * objectui#11340 — the rendered book sidebar, through the REAL route table + * (DocsLayout → DocPage → BookSidebar), shows the resolver's answer with no + * pre-filter in front of it. + * + * Book `crm_manual` (package `crm`) has a "Getting started" group, a "Pinned" + * group whose `pages` pin `ops_keys` from package `ops`, and an "Extensions" + * group that reads package `b` through its own `package`. Its Uncategorized + * group lists the unplaced docs of `crm` and `b` — the book's own packages — + * and no other package's (the control). The pinned doc of another package is + * listed under its own label. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, within } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; + +const { ADAPTER } = vi.hoisted(() => { + const DOCS = [ + { name: 'crm_intro', label: 'Intro', _packageId: 'crm' }, + { name: 'crm_stray', label: 'Stray Page', _packageId: 'crm' }, + { name: 'ops_keys', label: 'Ops Keys', _packageId: 'ops' }, + { name: 'ops_note', label: 'Ops Note', _packageId: 'ops' }, + { name: 'b_ref_one', label: 'B Reference', _packageId: 'b' }, + { name: 'b_note', label: 'B Note', _packageId: 'b' }, + ]; + const BOOKS = [ + { name: 'crm_manual', label: 'CRM Manual', _packageId: 'crm', + groups: [ + { key: 'start', label: 'Getting started', order: 1, include: 'crm_intro' }, + { key: 'pinned', label: 'Pinned', order: 2, pages: ['ops_keys'] }, + { key: 'ext', label: 'Extensions', order: 3, include: 'b_ref_*', package: 'b' }, + ] }, + ]; + const ADAPTER = { + getClient: () => ({ + meta: { + getItems: async (type: string) => (type === 'doc' ? DOCS : type === 'book' ? BOOKS : []), + getItem: async (_type: string, name: string) => ({ item: { name, content: `${name} BODY` } }), + }, + }), + }; + return { ADAPTER }; +}); + +vi.mock('@object-ui/app-shell', async (importOriginal) => ({ + ...(await importOriginal>()), + useAdapter: () => ADAPTER, +})); + +vi.mock('@object-ui/plugin-markdown', async (importOriginal) => ({ + ...(await importOriginal>()), + MarkdownRenderer: ({ schema }: { schema: { content?: string } }) => ( +
{schema.content}
+ ), + extractToc: () => [], +})); + +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 pages pick up the mocked modules. +import DocsLayout from './DocsLayout'; +import DocPage from './DocPage'; + +function Portal({ entry }: { entry: string }) { + return ( + + + }> + } /> + + + + ); +} + +/** Each rendered copy of a sidebar group (wide nav + narrow disclosure), by its label. */ +const sidebarGroups = async (label: string) => + (await screen.findAllByText(label)).map((el) => el.parentElement as HTMLElement); + +/** Open the reader on the book's first page and wait for the sidebar. */ +async function openManual() { + render(); + expect(await screen.findByTestId('doc-content')).toHaveTextContent('crm_intro BODY'); + await sidebarGroups('Getting started'); +} + +afterEach(cleanup); + +describe('objectui#11340 — the book sidebar renders the resolver\'s Uncategorized group', () => { + it('lists the book\'s own unplaced doc under Uncategorized, and no other package\'s (the control)', async () => { + await openManual(); + const groups = await sidebarGroups('Uncategorized'); + expect(groups.length).toBeGreaterThanOrEqual(1); + for (const group of groups) { + expect(within(group).getByRole('link', { name: 'Stray Page' })).toHaveAttribute('href', '/docs/crm_manual/crm_stray'); + } + expect(screen.queryAllByRole('link', { name: 'Ops Note' })).toEqual([]); + }); + + it('corner 1: a group\'s `package` makes that package\'s unplaced doc an Uncategorized entry', async () => { + await openManual(); + for (const group of await sidebarGroups('Uncategorized')) { + expect(within(group).getByRole('link', { name: 'B Note' })).toHaveAttribute('href', '/docs/crm_manual/b_note'); + } + }); + + it('corner 2: a `pages`-pinned doc of another package is listed in its pinned group under its own label', async () => { + await openManual(); + const groups = await sidebarGroups('Pinned'); + expect(groups.length).toBeGreaterThanOrEqual(1); + for (const group of groups) { + expect(within(group).getByRole('link', { name: 'Ops Keys' })).toHaveAttribute('href', '/docs/crm_manual/ops_keys'); + } + }); +});