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
15 changes: 15 additions & 0 deletions .changeset/11340-book-tree-prefilter.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions apps/console/src/pages/BookPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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],
);

Expand Down
4 changes: 2 additions & 2 deletions apps/console/src/pages/DocPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -157,7 +157,7 @@ export default function DocPage() {
const base = appName ? `/apps/${appName}/docs` : '/docs';
const resolvedBook = useMemo<ResolvedBook | null>(() => {
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
Expand Down
12 changes: 6 additions & 6 deletions apps/console/src/pages/book-nav.explicitGroup-11245.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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[]) =>
Expand Down
7 changes: 4 additions & 3 deletions apps/console/src/pages/book-nav.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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']);
});
});

Expand Down
59 changes: 22 additions & 37 deletions apps/console/src/pages/book-nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) ────────────────
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<string> {
const pkgs = new Set<string>();
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]
Expand All @@ -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<string>();
for (const g of resolved.groups) {
if (g.synthetic) continue;
Expand Down Expand Up @@ -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(
Expand Down
129 changes: 129 additions & 0 deletions apps/console/src/pages/book-nav.uncategorizedScope-11340.test.ts
Original file line number Diff line number Diff line change
@@ -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)));
});
});
Loading
Loading