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
36 changes: 36 additions & 0 deletions packages/app-shell/src/services/builtinComponents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,3 +165,39 @@ registerMetadataResource({
{ key: 'route', label: 'Route' },
],
});

/* -------------------------------------------------------------------------- */
/* 5) Documentation — the `book` navigation spine (ADR-0046 §6). A book is an */
/* ordered set of groups whose membership over docs is DERIVED from each */
/* group's include rule; the Preview tab renders that spine for authoring. */
/* -------------------------------------------------------------------------- */

registerMetadataResource({
type: 'book',
label: 'Documentation Books',
description: 'Documentation navigation spine — ordered groups with membership derived over docs (glob/tag rules), plus an explicit pages override for curated order.',
domain: 'system',
searchableFields: ['name', 'label', 'description', 'slug'],
listColumns: [
{ key: 'name', label: 'Name', width: '25%' },
{ key: 'label', label: 'Label', width: '25%' },
{
key: 'audience',
label: 'Audience',
width: '12%',
render: (v) =>
v == null
? 'org'
: typeof v === 'object' && v && 'profile' in (v as Record<string, unknown>)
? `profile: ${(v as { profile: string }).profile}`
: String(v),
},
{
key: 'groups',
label: 'Groups',
width: '10%',
render: (v) => (Array.isArray(v) ? String(v.length) : '0'),
},
{ key: 'description', label: 'Description' },
],
});
47 changes: 47 additions & 0 deletions packages/app-shell/src/views/metadata-admin/default-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,53 @@ const SCHEMAS: Record<string, Record<string, unknown>> = {
},
},

book: {
type: 'object',
title: 'Documentation Book',
required: ['name', 'groups'],
properties: {
...headerProps,
slug: {
type: 'string',
title: 'Slug',
description: 'Portal URL segment; defaults to the name without its package prefix.',
},
icon: { type: 'string', title: 'Icon' },
order: {
type: 'number',
title: 'Order',
description: 'Orders books within the portal.',
},
audience: {
title: 'Audience',
description: "Access audience. 'org' (default) inherits the package grant; 'public' is anonymously readable; { profile } gates by role.",
// Union of the two scalar literals and the { profile } object — kept lax
// so the role-gated object form round-trips untouched through the form.
oneOf: [
{ type: 'string', enum: ['org', 'public'] },
{ type: 'object', title: 'Role-gated', properties: { profile: { type: 'string', title: 'Profile' } }, required: ['profile'] },
],
},
groups: {
type: 'array',
title: 'Groups (spine)',
description: 'Ordered sections. Membership is derived from each group\'s include rule — edit the structure visually in the Preview tab.',
items: {
type: 'object',
required: ['key', 'label'],
properties: {
key: { type: 'string', title: 'Key', pattern: '^[a-z][a-z0-9_]*$' },
label: { type: 'string', title: 'Label' },
order: { type: 'number', title: 'Order' },
include: { title: 'Include rule', description: 'Glob over doc names (e.g. crm_guide_*) or { tag }.' },
package: { type: 'string', title: 'Package scope' },
pages: { type: 'array', title: 'Explicit pages (override)', items: {} },
},
},
},
},
},

email_template: {
type: 'object',
title: 'Email Template',
Expand Down
2 changes: 2 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ const TYPE_LABELS_EN: Record<string, string> = {
function: 'Function',
service: 'Service',
email_template: 'Email Template',
book: 'Documentation Book',
// Security
permission: 'Permission Set',
profile: 'Profile',
Expand Down Expand Up @@ -94,6 +95,7 @@ const TYPE_LABELS_ZH: Record<string, string> = {
function: '函数',
service: '服务',
email_template: '邮件模板',
book: '文档手册',
permission: '权限集',
profile: '配置文件',
role: '角色',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';

import { BookPreview } from './BookPreview';

afterEach(cleanup);

/** Minimal helper — BookPreview only reads `name` + `draft`. */
function renderBook(draft: Record<string, unknown>, name = '') {
return render(<BookPreview type="book" name={name} draft={draft} />);
}

describe('BookPreview', () => {
it('prompts for a name before anything is authored', () => {
renderBook({});
expect(screen.getByText('Name your book')).toBeInTheDocument();
});

it('warns when a named book has no groups', () => {
renderBook({ name: 'crm_manual', label: 'CRM Manual' });
expect(screen.getByText('CRM Manual')).toBeInTheDocument();
expect(screen.getByText('No groups yet')).toBeInTheDocument();
});

it('renders identity + a derived-membership group with its include glob', () => {
renderBook({
name: 'crm_manual',
label: 'CRM Manual',
slug: 'manual',
audience: 'public',
groups: [{ key: 'guides', label: 'Guides', include: 'crm_guide_*' }],
});
expect(screen.getByText('CRM Manual')).toBeInTheDocument();
expect(screen.getByText('manual')).toBeInTheDocument();
expect(screen.getByText('Public')).toBeInTheDocument(); // audience chip
expect(screen.getByText('Guides')).toBeInTheDocument();
expect(screen.getByText('crm_guide_*')).toBeInTheDocument(); // include glob chip
expect(screen.getByText(/Members derived from the rule/)).toBeInTheDocument();
});

it('renders a tag include rule', () => {
renderBook({
name: 'crm_manual',
groups: [{ key: 'start', label: 'Getting started', include: { tag: 'getting-started' } }],
});
expect(screen.getByText('getting-started')).toBeInTheDocument();
});

it('renders an explicit pages override incl. separator, rest, badge and external link', () => {
renderBook({
name: 'crm_manual',
groups: [
{
key: 'curated',
label: 'Curated',
pages: [
'crm_intro',
'---',
{ doc: 'crm_advanced', label: 'Advanced', badge: 'beta' },
{ href: 'https://example.com/api', label: 'API ref' },
'...',
],
},
],
});
expect(screen.getByText('crm_intro')).toBeInTheDocument();
expect(screen.getByText('separator')).toBeInTheDocument();
expect(screen.getByText('Advanced')).toBeInTheDocument();
expect(screen.getByText('beta')).toBeInTheDocument();
expect(screen.getByText('API ref')).toBeInTheDocument();
expect(screen.getByText(/…rest/)).toBeInTheDocument();
});

it('flags a group that can never match (no include, no pages)', () => {
renderBook({
name: 'crm_manual',
groups: [{ key: 'orphan', label: 'Orphan' }],
});
expect(screen.getByText(/matches\s*nothing/)).toBeInTheDocument();
});

it('orders groups by their `order` then authored index', () => {
renderBook({
name: 'crm_manual',
groups: [
{ key: 'second', label: 'Second', order: 2, include: 'b_*' },
{ key: 'first', label: 'First', order: 1, include: 'a_*' },
],
});
const labels = screen.getAllByText(/First|Second/).map((el) => el.textContent);
expect(labels[0]).toBe('First');
expect(labels[1]).toBe('Second');
});

it('degrades gracefully on a malformed draft instead of throwing', () => {
expect(() =>
renderBook({ name: 'crm_manual', groups: 'not-an-array' as unknown }),
).not.toThrow();
expect(screen.getByText('No groups yet')).toBeInTheDocument();
});
});
Loading
Loading