Skip to content

Commit f16c01e

Browse files
fix(console): the docs portal's book sidebar keeps a doc placed by its own group key from outside the book's package (objectui#11245) (#11274)
Fixes #11245 Clause-②: no ## What `scopeDocsToBook` in `apps/console/src/pages/book-nav.ts` now keeps a doc whose own `group` names one of the book's group keys, whatever package the doc lives in. Everything else about the scoping step is unchanged. When the book declares a package, docs from other packages are still dropped before resolving, so they never reach the synthetic Uncategorized group or an `include` rule. `include` is still scoped by the local `resolveBookTree` port itself, to `group.package` or else the book's package. `resolveBookTree`, `homeBook` and the direct-route refusal are not touched. There is no new helper. `DocPage.tsx`, `BookPage.tsx` and `countBookDocs` pick up the fix through the helper they already call. ## Why The spec's `resolveBookTree` (`packages/spec/src/system/book.zod.ts` in objectstack, byte-identical at the `@objectstack/spec@17.5.0` tag this repo installs and on objectstack `main`) places a doc in a group by `d.group === group.key` with no package scope. `GET /meta/book/:name/tree` answers that resolver over every doc (`createMetaBookTreeAnswer` → `readableTree`, with `bookPackage = book._packageId`). The portal dropped the doc before resolving, so a doc an admin placed from the doc editor was listed by the endpoint and missing from the sidebar. ## Measured (the order's Zone 2) These readings come from a scratch suite that ran the port and the spec resolver on the same fixtures. The suite was not committed. 1. **Red first.** Before the fix, through `scopeDocsToBook` → port, the book gave `start=[docprobe_gs_welcome] reference=[]`. The spec resolver on the endpoint's inputs gave `reference=[live_guide,probe_curl]`. After the fix the two agree. 2. **The port's own scope. The PM's assumption is partly falsified.** With the scope passed and no pre-filter, the port matches the spec on the explicit-`group` leg. But neither the port nor the spec scopes the orphan catch-all. Both put another package's ungrouped doc into Uncategorized: `uncategorized=[other_note]` from each. So "stop pre-filtering and pass the scope" would break the ruled control. The fix therefore keeps the pre-filter and lets explicit members through it, as the ruling words it: "`scopeDocsToBook` keeps any doc whose `group` names one of the book's group keys". The portal's scoped Uncategorized is a deliberate, ruled narrowing of the endpoint's. The pins compare the **authored** groups with the spec's answer. 3. **Group-level `package` override.** In both resolvers the override **replaces** the book's scope for that group's `include`; it does not add to it. Fixture: `own` gets only package a, `ext` gets package b through `include` plus c_1 through its own `group`. The portal's answer equals the spec's, and one pin holds it. 4. **`homeBook` / `findBookContainingDoc`.** Nothing changes for a book that carries a stamped `packageId`. `homeBook` keeps only books of the doc's own package, and that package is already in scope for them. One edge case moves. The book must carry no stamped `packageId`, its name prefix must equal the doc's package, and its only package must be a group `package` override. If such a doc names one of that book's groups, it is now claimed by that book. `homeBook` then answers that book instead of the package's first book (the measured fixture moved from `q_other` to `q_manual`). That follows `homeBook`'s documented rule (the home is the book that claims the doc), and `homeBook` was not changed. Divergences beyond the explicit-`group` leg, reported and not widened: - **An unstamped doc under a scoped `include`.** The spec admits a doc with no `packageId` into any scoped `include`. The port falls back to the name prefix (`pkgOf`) and refuses it. Measured: port `all=[x_a]`, spec `all=[x_a,y_unstamped]`. - **A `pages` pin naming a doc from another package.** The pre-filter drops that doc's header, so the entry keeps its place but loses its label and description. Measured: port `{"doc":"c_9"}`, spec `{"doc":"c_9","label":"C9 Label"}`. ## Tests - `book-nav.explicitGroup-11245.test.ts`: the explicit member is listed in the group it names; the authored groups equal the spec resolver's answer (`@objectstack/spec/system`) for the same docs; `countBookDocs` counts it; the group-override case; control: another package's ungrouped doc, and one naming a key the book lacks, stay out, with no Uncategorized. - `docs-portal.explicitGroup-11245.test.tsx` renders through the real route table. The Reference group of the rendered sidebar links the placed doc, and marks it current when reading it. A book whose only member is placed that way opens to it instead of "This book has no documents yet". Control: another package's ungrouped doc is absent from the sidebar. - **Reverse verification**, run from the committed fix through `ablation-replace.mjs` with a disk-verified mutation and restore: - Drop the explicit-member leg: 7 of the 9 new tests go red and both controls stay green. - Drop the package filter entirely: both new controls go red, plus three existing `docs-portal.test.tsx` cases (that is the existing pin the triage names). - Each restore was proven by blob equal to HEAD and an empty `git diff HEAD`. Gate verdicts and the head they ran on are in the `os-dev-report` comment on the card. ## Acceptance notes - `homeBook` edge case (reading 4 above): the canonical URL moves for a doc placed into an own-package book that has no stamped `packageId` and a group-level `package` override. - The two port divergences above: `pkgOf` falls back to the name prefix on an unstamped doc, and a `pages` pin from another package loses its label. Carrier: none. - A common group key such as `reference` places a doc in every book that has a group with that key. The spec resolver and the tree endpoint do the same; the pins do not cover it. --- _Generated by [Claude Code](https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent f3135a4 commit f16c01e

4 files changed

Lines changed: 291 additions & 1 deletion

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@object-ui/console': patch
3+
---
4+
5+
fix(console): the docs portal's book sidebar keeps a doc placed by its own `group` key from outside the book's package (objectui#11245)
6+
7+
A doc names the book group it belongs to through its own `group` key. The framework's
8+
`resolveBookTree` places such a doc in that group with no package scope (only a group's
9+
`include` is scoped), and `GET /meta/book/:name/tree` answers that resolver. The portal
10+
dropped every doc outside the book's package BEFORE resolving, so a doc an admin placed
11+
from the doc editor was listed by the tree endpoint and missing from the book sidebar:
12+
its group rendered empty, the book card did not count it, and a book with nothing else
13+
to open showed "This book has no documents yet".
14+
15+
`scopeDocsToBook` now keeps a doc whose `group` names one of the book's group keys,
16+
whatever its package. The package filter still narrows what `include` and the synthetic
17+
Uncategorized group can collect, so another package's ungrouped docs stay out of the
18+
book, and a group-level `package` override still scopes that group's `include`.
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
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+
* objectui#11245 — a doc placed by its own `group` key keeps its place in the
11+
* book sidebar even when it lives outside the book's package.
12+
*
13+
* The framework's `resolveBookTree` places a doc in a group when the doc's own
14+
* `group` names that group's `key`, with no package scope; only `include` is
15+
* scoped (to `group.package`, else the book's package). `GET
16+
* /meta/book/:name/tree` answers that resolver over every doc. The portal's
17+
* `scopeDocsToBook` dropped every doc outside the book's packages BEFORE
18+
* resolving, so a doc the admin placed from the doc editor (objectui#11241)
19+
* was listed by the endpoint and missing from the sidebar.
20+
*
21+
* The package filter still narrows what the synthetic Uncategorized group can
22+
* collect — the controls below hold that — and `include` stays scoped by the
23+
* resolver itself.
24+
*/
25+
26+
import { describe, it, expect } from 'vitest';
27+
import { resolveBookTree as specResolveBookTree } from '@objectstack/spec/system';
28+
import {
29+
resolveBookTree,
30+
scopeDocsToBook,
31+
countBookDocs,
32+
type Book,
33+
type ResolvedBook,
34+
type ResolverDoc,
35+
} from './book-nav';
36+
37+
const BOOK_PKG = 'com.example.docprobe';
38+
const OTHER_PKG = 'com.example.other';
39+
40+
/** The shape of the card's probe: a book with an `include` group and a `reference` group. */
41+
const book: Book = {
42+
name: 'docprobe_manual',
43+
label: 'Probe Manual',
44+
packageId: BOOK_PKG,
45+
groups: [
46+
{ key: 'start', label: 'Getting started', order: 1, include: 'docprobe_gs_*' },
47+
{ key: 'reference', label: 'Reference', order: 2, include: 'docprobe_ref_*' },
48+
],
49+
};
50+
51+
const docs: ResolverDoc[] = [
52+
{ name: 'docprobe_gs_welcome', label: 'Welcome', packageId: BOOK_PKG },
53+
// Outside the book's package, placed by its own `group` — one read back with no
54+
// stamped package, one stamped with a package that is not the book's.
55+
{ name: 'live_guide', label: 'Live Guide', group: 'reference' },
56+
{ name: 'probe_curl', label: 'Probe Curl', group: 'reference', packageId: 'sys_metadata' },
57+
// Controls: another package's ungrouped doc, and one naming a key this book lacks.
58+
{ name: 'other_note', label: 'Other Note', packageId: OTHER_PKG },
59+
{ name: 'other_stray', label: 'Other Stray', group: 'appendix', packageId: OTHER_PKG },
60+
];
61+
62+
/** What the portal renders for a book: the same two calls `DocPage` / `BookPage` make. */
63+
const portalTree = (b: Book, all: ResolverDoc[]): ResolvedBook => resolveBookTree(b, scopeDocsToBook(b, all));
64+
65+
/** What `GET /meta/book/:name/tree` answers: the spec resolver over every doc, scoped by the book's package. */
66+
const endpointTree = (b: Book, all: ResolverDoc[]) =>
67+
specResolveBookTree({ name: b.name, label: b.label, groups: b.groups ?? [] }, all, b.packageId);
68+
69+
/** The authored groups only, as `key → [doc…]`. */
70+
const authored = (groups: { key: string; entries: { doc?: string }[] }[]) =>
71+
groups.filter((g) => g.key !== 'uncategorized').map((g) => ({ key: g.key, docs: g.entries.map((e) => e.doc) }));
72+
73+
const members = (r: ResolvedBook, key: string) => r.groups.find((g) => g.key === key)?.entries.map((e) => e.doc);
74+
75+
describe('objectui#11245 — explicit `group` placement survives the book package scope', () => {
76+
it('a doc outside the book\'s package that names one of its groups is listed in that group', () => {
77+
expect(members(portalTree(book, docs), 'reference')).toEqual(['live_guide', 'probe_curl']);
78+
});
79+
80+
it('the authored groups equal the tree endpoint\'s answer for the same docs', () => {
81+
expect(authored(portalTree(book, docs).groups)).toEqual(authored(endpointTree(book, docs).groups));
82+
});
83+
84+
it('countBookDocs counts the explicit members', () => {
85+
expect(countBookDocs(book, docs)).toBe(3); // docprobe_gs_welcome + live_guide + probe_curl
86+
});
87+
88+
it('control: another package\'s ungrouped doc, and one naming a key the book lacks, stay out of Uncategorized', () => {
89+
const tree = portalTree(book, docs);
90+
const everywhere = tree.groups.flatMap((g) => g.entries.map((e) => e.doc));
91+
expect(everywhere).not.toContain('other_note');
92+
expect(everywhere).not.toContain('other_stray');
93+
expect(tree.groups.some((g) => g.synthetic)).toBe(false);
94+
});
95+
96+
it('a group-level `package` override scopes that group\'s `include`, and the explicit member still joins it', () => {
97+
const scoped: Book = {
98+
name: 'a_manual',
99+
label: 'A Manual',
100+
packageId: 'a',
101+
groups: [
102+
{ key: 'own', label: 'Own', order: 1, include: '*' },
103+
{ key: 'ext', label: 'Ext', order: 2, include: '*', package: 'b' },
104+
],
105+
};
106+
const mixed: ResolverDoc[] = [
107+
{ name: 'a_1', packageId: 'a' },
108+
{ name: 'b_1', packageId: 'b' },
109+
{ name: 'c_1', packageId: 'c', group: 'ext' },
110+
{ name: 'c_2', packageId: 'c' },
111+
];
112+
const tree = portalTree(scoped, mixed);
113+
// The override REPLACES the book's scope for `ext`: `own` takes only package a,
114+
// `ext` only package b by `include`, and c_1 by its own `group`.
115+
expect(members(tree, 'own')).toEqual(['a_1']);
116+
expect(members(tree, 'ext')).toEqual(['b_1', 'c_1']);
117+
expect(authored(tree.groups)).toEqual(authored(endpointTree(scoped, mixed).groups));
118+
// c_2 (package c, no `group`) stays out, as other packages' ungrouped docs do.
119+
expect(tree.groups.flatMap((g) => g.entries.map((e) => e.doc))).not.toContain('c_2');
120+
});
121+
});

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,11 +313,20 @@ function bookPackages(book: Book): Set<string> {
313313
* up every other package's docs. When a book declares no package (its own or a
314314
* group override) we can't scope safely, so all docs are kept and membership
315315
* falls to each group's `include` glob.
316+
*
317+
* A doc whose own `group` names one of this book's group keys is kept whatever
318+
* its package (objectui#11245): the framework's `resolveBookTree` places a doc
319+
* by its explicit `group` with no package scope, and the book tree endpoint
320+
* answers that resolver, so the sidebar lists the doc where the endpoint does.
321+
* The package filter narrows only what `include` and the synthetic
322+
* Uncategorized group can collect; `include` stays scoped by the resolver
323+
* itself (`group.package`, else the book's package).
316324
*/
317325
export function scopeDocsToBook(book: Book, docs: ResolverDoc[]): ResolverDoc[] {
318326
const pkgs = bookPackages(book);
319327
if (pkgs.size === 0) return docs;
320-
return docs.filter((d) => pkgs.has(pkgOf(d)));
328+
const groupKeys = new Set((book.groups ?? []).map((g) => g.key));
329+
return docs.filter((d) => pkgs.has(pkgOf(d)) || (d.group != null && groupKeys.has(d.group)));
321330
}
322331

323332
/** Sort books for the index: by `order`, then label, then name (stable). */
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
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+
* objectui#11245 — the rendered book sidebar, through the REAL route table
11+
* (DocsLayout → DocsSlug / DocPage → BookSidebar), lists a doc that names one of
12+
* the book's groups through its own `group` key from outside the book's package.
13+
*
14+
* The world is the card's probe: book `docprobe_manual` (package
15+
* `com.example.docprobe`) with a "Getting started" and a "Reference" group, and a
16+
* doc `live_guide` saved from the doc editor with `group: 'reference'` into it —
17+
* a runtime row, not the book's package. `GET /meta/book/docprobe_manual/tree`
18+
* lists it under Reference; the sidebar now does too. The book landing opens a
19+
* book whose only member is placed that way. Another package's ungrouped doc
20+
* stays out of the sidebar (the control).
21+
*/
22+
23+
import { describe, it, expect, vi, afterEach } from 'vitest';
24+
import { render, screen, cleanup, within } from '@testing-library/react';
25+
import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom';
26+
27+
const { ADAPTER } = vi.hoisted(() => {
28+
const PKG = 'com.example.docprobe';
29+
const DOCS = [
30+
{ name: 'docprobe_gs_welcome', label: 'Welcome', _packageId: PKG },
31+
{ name: 'live_guide', label: 'Live Guide', group: 'reference', _packageId: 'sys_metadata' },
32+
{ name: 'other_note', label: 'Other Note', _packageId: 'com.example.other' },
33+
{ name: 'live_note', label: 'Live Note', group: 'notes', _packageId: 'sys_metadata' },
34+
];
35+
const BOOKS = [
36+
{ name: 'docprobe_manual', label: 'Probe Manual', _packageId: PKG,
37+
groups: [
38+
{ key: 'start', label: 'Getting started', order: 1, include: 'docprobe_gs_*' },
39+
{ key: 'reference', label: 'Reference', order: 2 },
40+
] },
41+
// A book whose ONLY member is placed by its own `group` from another package.
42+
{ name: 'notes_manual', label: 'Notes', _packageId: 'com.example.notes',
43+
groups: [{ key: 'notes', label: 'Notes' }] },
44+
];
45+
const CONTENT: Record<string, string> = {
46+
docprobe_gs_welcome: 'WELCOME BODY',
47+
live_guide: 'LIVE BODY',
48+
other_note: 'OTHER BODY',
49+
live_note: 'NOTE BODY',
50+
};
51+
const ADAPTER = {
52+
getClient: () => ({
53+
meta: {
54+
getItems: async (type: string) => (type === 'doc' ? DOCS : type === 'book' ? BOOKS : []),
55+
getItem: async (_type: string, name: string) => ({ item: { name, content: CONTENT[name] } }),
56+
},
57+
}),
58+
};
59+
return { ADAPTER };
60+
});
61+
62+
vi.mock('@object-ui/app-shell', async (importOriginal) => ({
63+
...(await importOriginal<Record<string, unknown>>()),
64+
useAdapter: () => ADAPTER,
65+
}));
66+
67+
vi.mock('@object-ui/plugin-markdown', async (importOriginal) => ({
68+
...(await importOriginal<Record<string, unknown>>()),
69+
MarkdownRenderer: ({ schema }: { schema: { content?: string } }) => (
70+
<div data-testid="doc-content">{schema.content}</div>
71+
),
72+
extractToc: () => [],
73+
}));
74+
75+
vi.mock('@object-ui/i18n', async (importOriginal) => ({
76+
...(await importOriginal<Record<string, unknown>>()),
77+
useObjectTranslation: () => ({ t: (_k: string, o?: { defaultValue?: string }) => o?.defaultValue ?? _k }),
78+
}));
79+
80+
// Imported AFTER the mocks so the pages pick up the mocked modules.
81+
import DocsLayout from './DocsLayout';
82+
import DocsSlug from './DocsSlug';
83+
import DocPage from './DocPage';
84+
85+
function Where() {
86+
return <div data-testid="where">{useLocation().pathname}</div>;
87+
}
88+
89+
function Portal({ entry }: { entry: string }) {
90+
return (
91+
<MemoryRouter initialEntries={[entry]}>
92+
<Where />
93+
<Routes>
94+
<Route path="/docs" element={<DocsLayout />}>
95+
<Route path=":slug" element={<DocsSlug />} />
96+
<Route path=":slug/:name" element={<DocPage />} />
97+
</Route>
98+
</Routes>
99+
</MemoryRouter>
100+
);
101+
}
102+
103+
/** Each rendered copy of a sidebar group (wide nav + narrow disclosure), by its label. */
104+
const sidebarGroups = async (label: string) =>
105+
(await screen.findAllByText(label)).map((el) => el.parentElement as HTMLElement);
106+
107+
afterEach(cleanup);
108+
109+
describe('objectui#11245 — the book sidebar lists a doc placed by its own `group` from another package', () => {
110+
it('reading another page of the book, the Reference group links the placed doc', async () => {
111+
render(<Portal entry="/docs/docprobe_manual/docprobe_gs_welcome" />);
112+
expect(await screen.findByTestId('doc-content')).toHaveTextContent('WELCOME BODY');
113+
const groups = await sidebarGroups('Reference');
114+
expect(groups.length).toBeGreaterThanOrEqual(1);
115+
for (const group of groups) {
116+
const link = within(group).getByRole('link', { name: 'Live Guide' });
117+
expect(link).toHaveAttribute('href', '/docs/docprobe_manual/live_guide');
118+
}
119+
});
120+
121+
it('reading the placed doc, the sidebar marks it current inside its group', async () => {
122+
render(<Portal entry="/docs/docprobe_manual/live_guide" />);
123+
expect(await screen.findByTestId('doc-content')).toHaveTextContent('LIVE BODY');
124+
for (const group of await sidebarGroups('Reference')) {
125+
expect(within(group).getByRole('link', { name: 'Live Guide' })).toHaveAttribute('aria-current', 'page');
126+
}
127+
});
128+
129+
it('a book whose only member is placed that way opens to it, not to "no documents yet"', async () => {
130+
render(<Portal entry="/docs/notes_manual" />);
131+
expect(await screen.findByTestId('doc-content')).toHaveTextContent('NOTE BODY');
132+
expect(screen.getByTestId('where').textContent).toBe('/docs/notes_manual/live_note');
133+
});
134+
135+
it('control: another package\'s ungrouped doc stays out of the sidebar (no Uncategorized)', async () => {
136+
render(<Portal entry="/docs/docprobe_manual/docprobe_gs_welcome" />);
137+
expect(await screen.findByTestId('doc-content')).toHaveTextContent('WELCOME BODY');
138+
await sidebarGroups('Getting started'); // the sidebar has rendered
139+
expect(screen.queryAllByRole('link', { name: 'Other Note' })).toEqual([]);
140+
expect(screen.queryAllByText('Uncategorized')).toEqual([]);
141+
});
142+
});

0 commit comments

Comments
 (0)