Skip to content

Commit 7f1a2b2

Browse files
committed
fix(console): the docs sidebar's Uncategorized group skips docs a sibling book claims
The console's port of resolveBookTree (book-nav.ts) takes the same optional fourth argument as the framework resolver: every authored book judges the Uncategorized catch-all, so a doc a sibling book places is not listed there. DocPage and BookPage pass useBookData().authoredBooks, never the portal set. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzgBbveX8mMhrSXB6VJ
1 parent bce0389 commit 7f1a2b2

5 files changed

Lines changed: 177 additions & 9 deletions

File tree

‎.changeset/27091-book-tree-sibling-claims.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
---
22
'@objectstack/spec': patch
33
'@objectstack/rest': patch
4+
'@object-ui/console': patch
45
---
56

67
Clause-②: no
@@ -12,5 +13,6 @@ When two books split one package's docs, for example a user manual and an admin
1213

1314
- **`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.
1415
- **`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.
16+
- **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.
1517

1618
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.

‎ui/apps/console/src/pages/BookPage.tsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,12 @@ export function DocRefusal({ name, message }: { name?: string; message?: string
4545
export default function BookPage() {
4646
const { slug, appName } = useParams<{ slug: string; appName?: string }>();
4747
const { t } = useObjectTranslation();
48-
const { books, docs, state } = useBookData();
48+
const { books, authoredBooks, docs, state } = useBookData();
4949

5050
const found = useMemo(() => books.find((b) => bookSlug(b) === slug), [books, slug]);
5151
const opensTo = useMemo(
52-
() => (found ? firstDoc(resolveBookTree(found, docs)) : undefined),
53-
[found, docs],
52+
() => (found ? firstDoc(resolveBookTree(found, docs, undefined, authoredBooks)) : undefined),
53+
[found, docs, authoredBooks],
5454
);
5555

5656
if (state === 'loading') {

‎ui/apps/console/src/pages/DocPage.tsx‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -158,12 +158,14 @@ export default function DocPage() {
158158
// reading in, so its spine renders as a persistent left nav. Resolved from
159159
// the portal book set; degrades to no sidebar if the slug is unknown (e.g.
160160
// the backend serves no books yet).
161-
const { books, docs: allDocs } = useBookData();
161+
const { books, authoredBooks, docs: allDocs } = useBookData();
162162
const base = appName ? `/apps/${appName}/docs` : '/docs';
163163
const resolvedBook = useMemo<ResolvedBook | null>(() => {
164164
const book = books.find((b) => bookSlug(b) === slug);
165-
return book ? resolveBookTree(book, allDocs) : null;
166-
}, [books, allDocs, slug]);
165+
// Every authored book judges the Uncategorized group, so a doc a sibling
166+
// book places is not listed there (objectstack#27091).
167+
return book ? resolveBookTree(book, allDocs, undefined, authoredBooks) : null;
168+
}, [books, authoredBooks, allDocs, slug]);
167169
const docHref = useCallback((docName: string) => `${base}/${slug}/${docName}`, [base, slug]);
168170
// A `:slug` segment that is a book's NAME rather than its slug — what a
169171
// `{ type: 'doc', book, doc }` navigation entry links to (objectui#11197) — is
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectstack#27091 — a doc a SIBLING book places is not this book's orphan.
11+
*
12+
* ADR-0046 §6.4 rolls up only the docs "left unplaced". HotCRM ships two books
13+
* over one package (hotcrm#2327): `crm_manual` and `crm_admin_manual`, the
14+
* latter gated by a permission set. Every doc is claimed by exactly one book,
15+
* yet each book's sidebar listed the other's pages under a trailing
16+
* Uncategorized group, because "claimed" was judged against the book's own
17+
* groups only. The framework's `resolveBookTree` judges against every book
18+
* since objectstack#27091; this port follows it, and the docs pages pass the
19+
* AUTHORED books.
20+
*
21+
* Each resolver case is checked against the spec's resolver over the same
22+
* docs and books, the answer `GET /meta/book/:name/tree` gives.
23+
*/
24+
25+
import { describe, it, expect, vi, afterEach } from 'vitest';
26+
import { render, screen, cleanup } from '@testing-library/react';
27+
import { MemoryRouter, Routes, Route } from 'react-router-dom';
28+
import { resolveBookTree as specResolveBookTree } from '@objectstack/spec/system';
29+
30+
const { ADAPTER } = vi.hoisted(() => {
31+
const ADAPTER = {
32+
getClient: () => ({
33+
meta: {
34+
getItems: async () => [],
35+
getItem: async (_type: string, name: string) => ({ item: { name, content: `# ${name}` } }),
36+
},
37+
}),
38+
};
39+
return { ADAPTER };
40+
});
41+
42+
vi.mock('@object-ui/app-shell', async (importOriginal) => ({
43+
...(await importOriginal<Record<string, unknown>>()),
44+
useAdapter: () => ADAPTER,
45+
}));
46+
47+
vi.mock('@object-ui/i18n', async (importOriginal) => ({
48+
...(await importOriginal<Record<string, unknown>>()),
49+
useObjectTranslation: () => ({ t: (_k: string, o?: { defaultValue?: string }) => o?.defaultValue ?? _k }),
50+
}));
51+
52+
// Imported AFTER the mocks so the page picks up the mocked modules.
53+
import DocPage from './DocPage';
54+
import { BookDataContext } from './use-book-data';
55+
import { buildPortalBooks, resolveBookTree, type Book, type ResolvedBook, type ResolverDoc } from './book-nav';
56+
57+
const docs: ResolverDoc[] = [
58+
{ name: 'crm_overview', label: 'Overview', packageId: 'crm' },
59+
{ name: 'crm_sales_leads', label: 'Leads', packageId: 'crm' },
60+
{ name: 'crm_admin_access', label: 'Access control', packageId: 'crm' },
61+
{ name: 'crm_admin_automation', label: 'Automation', packageId: 'crm' },
62+
];
63+
const userManual: Book = {
64+
name: 'crm_manual',
65+
label: 'CRM Manual',
66+
packageId: 'crm',
67+
groups: [
68+
{ key: 'start', label: 'Start', include: 'crm_overview' },
69+
{ key: 'sales', label: 'Sales', include: 'crm_sales_*' },
70+
],
71+
};
72+
const adminManual: Book = {
73+
name: 'crm_admin_manual',
74+
label: 'CRM Admin Manual',
75+
packageId: 'crm',
76+
groups: [{ key: 'admin', label: 'Admin', include: 'crm_admin_*' }],
77+
};
78+
const authored = [userManual, adminManual];
79+
80+
const keysAndDocs = (r: { groups: { key: string; entries: { doc?: string }[] }[] }) =>
81+
Object.fromEntries(r.groups.map((g) => [g.key, g.entries.map((e) => e.doc)]));
82+
83+
/** What `GET /meta/book/:name/tree` answers: the spec resolver, judged by every book. */
84+
const specBook = (b: Book) => ({ name: b.name, label: b.label, groups: b.groups ?? [], packageId: b.packageId });
85+
const endpointTree = (b: Book, all: ResolverDoc[]) =>
86+
specResolveBookTree(specBook(b), all, b.packageId, authored.map(specBook));
87+
88+
afterEach(() => {
89+
cleanup();
90+
vi.restoreAllMocks();
91+
});
92+
93+
describe('objectstack#27091 — the port: a sibling book\'s pages are not Uncategorized', () => {
94+
it('HotCRM\'s two books: neither tree has an Uncategorized group, and the spec agrees', () => {
95+
const user: ResolvedBook = resolveBookTree(userManual, docs, undefined, authored);
96+
expect(keysAndDocs(user)).toEqual({ start: ['crm_overview'], sales: ['crm_sales_leads'] });
97+
expect(keysAndDocs(resolveBookTree(adminManual, docs, undefined, authored))).toEqual({
98+
admin: ['crm_admin_access', 'crm_admin_automation'],
99+
});
100+
for (const b of authored) {
101+
expect(keysAndDocs(resolveBookTree(b, docs, undefined, authored))).toEqual(keysAndDocs(endpointTree(b, docs)));
102+
}
103+
});
104+
105+
it('a doc NO book claims still rolls up under Uncategorized', () => {
106+
const withStray = [...docs, { name: 'crm_stray', label: 'Stray', packageId: 'crm' }];
107+
expect(keysAndDocs(resolveBookTree(userManual, withStray, undefined, authored)).uncategorized).toEqual(['crm_stray']);
108+
});
109+
110+
it('CONTROL: without `books` the tree is unchanged, the sibling\'s pages still in Uncategorized', () => {
111+
expect(keysAndDocs(resolveBookTree(userManual, docs)).uncategorized).toEqual([
112+
'crm_admin_access',
113+
'crm_admin_automation',
114+
]);
115+
});
116+
});
117+
118+
describe('objectstack#27091 — the reader\'s sidebar for crm_manual', () => {
119+
it('lists the book\'s own pages and no Uncategorized group holding the admin pages', async () => {
120+
const value = { authoredBooks: authored, books: buildPortalBooks(authored, docs), docs, state: 'ready' as const };
121+
render(
122+
<BookDataContext.Provider value={value}>
123+
<MemoryRouter initialEntries={['/docs/crm_manual/crm_overview']}>
124+
<Routes>
125+
<Route path="/docs/:slug/:name" element={<DocPage />} />
126+
</Routes>
127+
</MemoryRouter>
128+
</BookDataContext.Provider>,
129+
);
130+
expect((await screen.findAllByRole('link', { name: 'Leads' })).length).toBeGreaterThan(0);
131+
expect(screen.queryByText('Uncategorized')).toBeNull();
132+
expect(screen.queryByRole('link', { name: 'Access control' })).toBeNull();
133+
expect(screen.queryByRole('link', { name: 'Automation' })).toBeNull();
134+
});
135+
});

‎ui/apps/console/src/pages/book-nav.ts‎

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -182,8 +182,19 @@ function entryFromDoc(doc: ResolverDoc): ResolvedEntry {
182182
/**
183183
* Resolve a book spine against the current doc set into a rendered tree.
184184
* Faithful port of ADR-0046 §6.2.1 — see book-nav.ts header.
185+
*
186+
* `books` is every authored book: a doc one of them claims is placed, not
187+
* "left unplaced" (§6.4), so it never lands in this book's Uncategorized
188+
* group (the framework's resolver answers the same since objectstack#27091).
189+
* Pass the AUTHORED books, never the portal set: its synthetic implicit
190+
* per-package books (§6.4) are a reading fallback, not an authored claim.
185191
*/
186-
export function resolveBookTree(book: Book, docs: ResolverDoc[], bookPackage?: string): ResolvedBook {
192+
export function resolveBookTree(
193+
book: Book,
194+
docs: ResolverDoc[],
195+
bookPackage?: string,
196+
books?: readonly Book[],
197+
): ResolvedBook {
187198
const scopeDefault = bookPackage ?? book.packageId;
188199
const groupsSorted = [...(book.groups ?? [])]
189200
.map((g, i) => ({ g, i }))
@@ -265,12 +276,14 @@ export function resolveBookTree(book: Book, docs: ResolverDoc[], bookPackage?: s
265276
// asked through `include`'s own scope test (ADR-0046 §6.4; the framework's
266277
// resolver answers the same since objectstack#20980). Another package's
267278
// unplaced doc is that package's own book's orphan, not this one's. A book
268-
// that declares no package keeps every unclaimed doc.
279+
// that declares no package keeps every unclaimed doc. A doc another book in
280+
// `books` claims is not an orphan either.
269281
const ownPackages = [scopeDefault, ...groupsSorted.map((g) => g.package)].filter(
270282
(p): p is string => typeof p === 'string' && p.length > 0,
271283
);
284+
const claimedByAnyBook = books ? claimedByBooks(books, docs) : undefined;
272285
const orphans = docs
273-
.filter((d) => !claimed.has(d.name))
286+
.filter((d) => !claimed.has(d.name) && !claimedByAnyBook?.has(d.name))
274287
.filter((d) => ownPackages.length === 0 || ownPackages.some((p) => matchesInclude(d, '*', p)))
275288
.sort(byOrderThenLabel);
276289
if (orphans.length) {
@@ -285,6 +298,22 @@ export function resolveBookTree(book: Book, docs: ResolverDoc[], bookPackage?: s
285298
return { name: book.name, label: book.label, description: book.description, groups: resolvedGroups };
286299
}
287300

301+
/**
302+
* Every doc some book in `books` claims: the docs its authored groups place,
303+
* never its synthetic Uncategorized group (the framework's
304+
* `resolveBookClaimedDocs`, unioned over the books).
305+
*/
306+
function claimedByBooks(books: readonly Book[], docs: ResolverDoc[]): Set<string> {
307+
const all = new Set<string>();
308+
for (const book of books) {
309+
for (const g of resolveBookTree(book, docs).groups) {
310+
if (g.synthetic) continue;
311+
for (const e of g.entries) if (e.doc) all.add(e.doc);
312+
}
313+
}
314+
return all;
315+
}
316+
288317
/**
289318
* Synthesize the implicit per-package book (ADR-0046 §6.4): no authored book ⇒
290319
* one book keyed by the package id, a single group including every doc.

0 commit comments

Comments
 (0)