Skip to content

Commit e6fd254

Browse files
os-zhuangclaude
andauthored
feat(metadata-admin): book metadata display UI (ADR-0046 §6) (#1775)
The framework added a `book` metadata type (documentation navigation spine — ordered groups with membership derived over docs via glob/tag rules) in ADR-0046 §6, but objectui had no display surface for it. Add the standard per-type display layer the metadata-admin engine expects, mirroring the existing types: - BookPreview: visualises the authored spine — identity/access header (label, slug, audience chip), groups sorted by order, each group's include rule (glob or {tag}) and package scope, and any explicit `pages` override (doc entries, `---` separators, `...` rest, badges, external links). Defensive reads; degrades to a hint, never throws. Toolbar links to GET /meta/book/:name/tree for the live-resolved tree. - default-schemas: fallback JSONSchema for the Form tab. - i18n: type label (EN "Documentation Book" / ZH "文档手册"). - builtinComponents: registerMetadataResource (domain system, columns). - BookPreview.test: 8 cases (empty/glob/tag/override/orphan/order/bad draft). Note: the metadata-admin type list is server-driven from /meta/types, so `book` only surfaces once the running backend serves a framework build that includes it (not in the published @objectstack/spec@9.5.1 yet). This is the ready-and-waiting client display layer. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5b5f7d5 commit e6fd254

6 files changed

Lines changed: 539 additions & 0 deletions

File tree

packages/app-shell/src/services/builtinComponents.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,3 +165,39 @@ registerMetadataResource({
165165
{ key: 'route', label: 'Route' },
166166
],
167167
});
168+
169+
/* -------------------------------------------------------------------------- */
170+
/* 5) Documentation — the `book` navigation spine (ADR-0046 §6). A book is an */
171+
/* ordered set of groups whose membership over docs is DERIVED from each */
172+
/* group's include rule; the Preview tab renders that spine for authoring. */
173+
/* -------------------------------------------------------------------------- */
174+
175+
registerMetadataResource({
176+
type: 'book',
177+
label: 'Documentation Books',
178+
description: 'Documentation navigation spine — ordered groups with membership derived over docs (glob/tag rules), plus an explicit pages override for curated order.',
179+
domain: 'system',
180+
searchableFields: ['name', 'label', 'description', 'slug'],
181+
listColumns: [
182+
{ key: 'name', label: 'Name', width: '25%' },
183+
{ key: 'label', label: 'Label', width: '25%' },
184+
{
185+
key: 'audience',
186+
label: 'Audience',
187+
width: '12%',
188+
render: (v) =>
189+
v == null
190+
? 'org'
191+
: typeof v === 'object' && v && 'profile' in (v as Record<string, unknown>)
192+
? `profile: ${(v as { profile: string }).profile}`
193+
: String(v),
194+
},
195+
{
196+
key: 'groups',
197+
label: 'Groups',
198+
width: '10%',
199+
render: (v) => (Array.isArray(v) ? String(v.length) : '0'),
200+
},
201+
{ key: 'description', label: 'Description' },
202+
],
203+
});

packages/app-shell/src/views/metadata-admin/default-schemas.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,53 @@ const SCHEMAS: Record<string, Record<string, unknown>> = {
241241
},
242242
},
243243

244+
book: {
245+
type: 'object',
246+
title: 'Documentation Book',
247+
required: ['name', 'groups'],
248+
properties: {
249+
...headerProps,
250+
slug: {
251+
type: 'string',
252+
title: 'Slug',
253+
description: 'Portal URL segment; defaults to the name without its package prefix.',
254+
},
255+
icon: { type: 'string', title: 'Icon' },
256+
order: {
257+
type: 'number',
258+
title: 'Order',
259+
description: 'Orders books within the portal.',
260+
},
261+
audience: {
262+
title: 'Audience',
263+
description: "Access audience. 'org' (default) inherits the package grant; 'public' is anonymously readable; { profile } gates by role.",
264+
// Union of the two scalar literals and the { profile } object — kept lax
265+
// so the role-gated object form round-trips untouched through the form.
266+
oneOf: [
267+
{ type: 'string', enum: ['org', 'public'] },
268+
{ type: 'object', title: 'Role-gated', properties: { profile: { type: 'string', title: 'Profile' } }, required: ['profile'] },
269+
],
270+
},
271+
groups: {
272+
type: 'array',
273+
title: 'Groups (spine)',
274+
description: 'Ordered sections. Membership is derived from each group\'s include rule — edit the structure visually in the Preview tab.',
275+
items: {
276+
type: 'object',
277+
required: ['key', 'label'],
278+
properties: {
279+
key: { type: 'string', title: 'Key', pattern: '^[a-z][a-z0-9_]*$' },
280+
label: { type: 'string', title: 'Label' },
281+
order: { type: 'number', title: 'Order' },
282+
include: { title: 'Include rule', description: 'Glob over doc names (e.g. crm_guide_*) or { tag }.' },
283+
package: { type: 'string', title: 'Package scope' },
284+
pages: { type: 'array', title: 'Explicit pages (override)', items: {} },
285+
},
286+
},
287+
},
288+
},
289+
},
290+
244291
email_template: {
245292
type: 'object',
246293
title: 'Email Template',

packages/app-shell/src/views/metadata-admin/i18n.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ const TYPE_LABELS_EN: Record<string, string> = {
4949
function: 'Function',
5050
service: 'Service',
5151
email_template: 'Email Template',
52+
book: 'Documentation Book',
5253
// Security
5354
permission: 'Permission Set',
5455
profile: 'Profile',
@@ -94,6 +95,7 @@ const TYPE_LABELS_ZH: Record<string, string> = {
9495
function: '函数',
9596
service: '服务',
9697
email_template: '邮件模板',
98+
book: '文档手册',
9799
permission: '权限集',
98100
profile: '配置文件',
99101
role: '角色',
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, afterEach } from 'vitest';
4+
import { render, screen, cleanup } from '@testing-library/react';
5+
6+
import { BookPreview } from './BookPreview';
7+
8+
afterEach(cleanup);
9+
10+
/** Minimal helper — BookPreview only reads `name` + `draft`. */
11+
function renderBook(draft: Record<string, unknown>, name = '') {
12+
return render(<BookPreview type="book" name={name} draft={draft} />);
13+
}
14+
15+
describe('BookPreview', () => {
16+
it('prompts for a name before anything is authored', () => {
17+
renderBook({});
18+
expect(screen.getByText('Name your book')).toBeInTheDocument();
19+
});
20+
21+
it('warns when a named book has no groups', () => {
22+
renderBook({ name: 'crm_manual', label: 'CRM Manual' });
23+
expect(screen.getByText('CRM Manual')).toBeInTheDocument();
24+
expect(screen.getByText('No groups yet')).toBeInTheDocument();
25+
});
26+
27+
it('renders identity + a derived-membership group with its include glob', () => {
28+
renderBook({
29+
name: 'crm_manual',
30+
label: 'CRM Manual',
31+
slug: 'manual',
32+
audience: 'public',
33+
groups: [{ key: 'guides', label: 'Guides', include: 'crm_guide_*' }],
34+
});
35+
expect(screen.getByText('CRM Manual')).toBeInTheDocument();
36+
expect(screen.getByText('manual')).toBeInTheDocument();
37+
expect(screen.getByText('Public')).toBeInTheDocument(); // audience chip
38+
expect(screen.getByText('Guides')).toBeInTheDocument();
39+
expect(screen.getByText('crm_guide_*')).toBeInTheDocument(); // include glob chip
40+
expect(screen.getByText(/Members derived from the rule/)).toBeInTheDocument();
41+
});
42+
43+
it('renders a tag include rule', () => {
44+
renderBook({
45+
name: 'crm_manual',
46+
groups: [{ key: 'start', label: 'Getting started', include: { tag: 'getting-started' } }],
47+
});
48+
expect(screen.getByText('getting-started')).toBeInTheDocument();
49+
});
50+
51+
it('renders an explicit pages override incl. separator, rest, badge and external link', () => {
52+
renderBook({
53+
name: 'crm_manual',
54+
groups: [
55+
{
56+
key: 'curated',
57+
label: 'Curated',
58+
pages: [
59+
'crm_intro',
60+
'---',
61+
{ doc: 'crm_advanced', label: 'Advanced', badge: 'beta' },
62+
{ href: 'https://example.com/api', label: 'API ref' },
63+
'...',
64+
],
65+
},
66+
],
67+
});
68+
expect(screen.getByText('crm_intro')).toBeInTheDocument();
69+
expect(screen.getByText('separator')).toBeInTheDocument();
70+
expect(screen.getByText('Advanced')).toBeInTheDocument();
71+
expect(screen.getByText('beta')).toBeInTheDocument();
72+
expect(screen.getByText('API ref')).toBeInTheDocument();
73+
expect(screen.getByText(/rest/)).toBeInTheDocument();
74+
});
75+
76+
it('flags a group that can never match (no include, no pages)', () => {
77+
renderBook({
78+
name: 'crm_manual',
79+
groups: [{ key: 'orphan', label: 'Orphan' }],
80+
});
81+
expect(screen.getByText(/matches\s*nothing/)).toBeInTheDocument();
82+
});
83+
84+
it('orders groups by their `order` then authored index', () => {
85+
renderBook({
86+
name: 'crm_manual',
87+
groups: [
88+
{ key: 'second', label: 'Second', order: 2, include: 'b_*' },
89+
{ key: 'first', label: 'First', order: 1, include: 'a_*' },
90+
],
91+
});
92+
const labels = screen.getAllByText(/First|Second/).map((el) => el.textContent);
93+
expect(labels[0]).toBe('First');
94+
expect(labels[1]).toBe('Second');
95+
});
96+
97+
it('degrades gracefully on a malformed draft instead of throwing', () => {
98+
expect(() =>
99+
renderBook({ name: 'crm_manual', groups: 'not-an-array' as unknown }),
100+
).not.toThrow();
101+
expect(screen.getByText('No groups yet')).toBeInTheDocument();
102+
});
103+
});

0 commit comments

Comments
 (0)