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
22 changes: 15 additions & 7 deletions apps/console/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ import { MetadataHmrReloader } from './components/MetadataHmrReloader';
import SharedRecordPage from './pages/SharedRecordPage';
import DocPage from './pages/DocPage';
import DocsIndex from './pages/DocsIndex';
import DocsSlug from './pages/DocsSlug';
import DocsLayout from './pages/DocsLayout';
import { LoginPage } from './pages/auth/LoginPage';
import { RegisterPage } from './pages/auth/RegisterPage';
import { ForgotPasswordPage } from './pages/auth/ForgotPasswordPage';
Expand Down Expand Up @@ -184,16 +186,22 @@ export function App() {
* lists every installed `doc` (grouped by package), and one
* viewer route renders any item; cross-references between docs
* resolve to that same viewer route. Both are app-independent. */}
{/* Docs portal (ADR-0046 §6). The layout fetches book + doc once
* and shares it with every child route via context. Children:
* index → book index
* :slug → a book landing, or a flat-doc permalink that
* redirects to its canonical /docs/<book>/<name>
* :slug/:name → in-book reader (doc identity stays single-
* coordinate; the book segment is derived nav). */}
<Route path="/docs" element={
<ProtectedRoute>
<DocsIndex />
<DocsLayout />
</ProtectedRoute>
} />
<Route path="/docs/:name" element={
<ProtectedRoute>
<DocPage />
</ProtectedRoute>
} />
}>
<Route index element={<DocsIndex />} />
<Route path=":slug" element={<DocsSlug />} />
<Route path=":slug/:name" element={<DocPage />} />
</Route>
<Route path="/home" element={
<ProtectedRoute>
<HomeRoute />
Expand Down
12 changes: 10 additions & 2 deletions apps/console/src/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ const IntegrationsPage = lazy(() => import('./pages/developer/IntegrationsPage')
// app-scoped index at /apps/:packageId/docs (AppDocsIndex).
const DocPage = lazy(() => import('./pages/DocPage'));
const AppDocsIndex = lazy(() => import('./pages/AppDocsIndex'));
const DocsSlug = lazy(() => import('./pages/DocsSlug'));
const DocsLayout = lazy(() => import('./pages/DocsLayout'));

// Note: marketplace routes (`system/marketplace`, `system/marketplace/:packageId`)
// are registered by DefaultAppContent in @object-ui/app-shell so they're
Expand Down Expand Up @@ -91,8 +93,14 @@ const systemRoutes = (
<Route path="developer/integrations" element={<Suspense fallback={<LoadingScreen />}><IntegrationsPage /></Suspense>} />
{/* ADR-0048 — package docs under the package container: app-scoped index
(/apps/:packageId/docs) + single-doc viewer (/apps/:packageId/docs/:name) */}
<Route path="docs" element={<Suspense fallback={<LoadingScreen />}><AppDocsIndex /></Suspense>} />
<Route path="docs/:name" element={<Suspense fallback={<LoadingScreen />}><DocPage /></Suspense>} />
{/* ADR-0046 §6 — the docs layout fetches book + doc once and shares it with
the app-scoped index, book landings, and reader (one segment resolves to
a book landing or redirects a flat doc to /docs/<book>/<name>). */}
<Route path="docs" element={<Suspense fallback={<LoadingScreen />}><DocsLayout /></Suspense>}>
<Route index element={<Suspense fallback={<LoadingScreen />}><AppDocsIndex /></Suspense>} />
<Route path=":slug" element={<Suspense fallback={<LoadingScreen />}><DocsSlug /></Suspense>} />
<Route path=":slug/:name" element={<Suspense fallback={<LoadingScreen />}><DocPage /></Suspense>} />
</Route>
{/* Legacy URL redirects → metadata-admin engine (zero-cost compatibility). */}
<Route path="system/objects" element={<ObjectRedirect />} />
<Route path="system/objects/:objectName" element={<ObjectRedirect />} />
Expand Down
124 changes: 40 additions & 84 deletions apps/console/src/pages/AppDocsIndex.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,77 +6,33 @@
* LICENSE file in the root directory of this source tree.
*/

import { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { AlertCircle, BookOpen, Loader2 } from 'lucide-react';
import { useAdapter } from '@object-ui/app-shell';
import { useMemo } from 'react';
import { Link, Navigate, useParams } from 'react-router-dom';
import { BookOpen, Loader2 } from 'lucide-react';
import { DocShell } from './DocShell';

interface AppDocItem {
name: string;
label?: string;
description?: string;
}
import { useBookData } from './use-book-data';
import { buildBookCards, pkgOfBook, type Book } from './book-nav';

/**
* `/apps/:appName/docs` — the app-scoped documentation index (ADR-0046/0048).
* `/apps/:appName/docs` — the app-scoped documentation index (ADR-0046 §6 /
* ADR-0048). The package-scoped sibling of the platform `/docs` portal: it
* shows the books owned by the app whose container this route renders inside
* (`:appName` is the package-id segment).
*
* The platform `/docs` portal lists *every* installed doc grouped by package;
* this is its package-scoped sibling: the docs owned by the app whose
* container this route renders inside (`:appName` is the package-id segment,
* matched against each doc's `_packageId`). It is the landing target for the
* header Help menu's "This app's docs" entry when an app ships more than one
* doc — a single-doc app deep-links straight to the viewer instead.
*
* Like the rest of the doc routes it degrades softly: an app with no docs
* shows an empty-state notice, never a hard error.
* Book-driven like the portal: a package always has at least its implicit book
* (§6.4). The common single-book case lands straight on that book's grouped
* reader; a package with several books lists them. Reads the shared book/doc
* data provided by the `/docs` layout — no separate fetch.
*/
export default function AppDocsIndex() {
// `appName` is the parent route's package-id segment (/apps/:appName/docs).
const { appName } = useParams<{ appName?: string }>();
const adapter = useAdapter();
const [docs, setDocs] = useState<AppDocItem[]>([]);
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
const [errorMessage, setErrorMessage] = useState<string>('');
const { books, docs, state } = useBookData();

useEffect(() => {
let cancelled = false;
async function load() {
if (!adapter) return;
const client: any = adapter.getClient();
if (!client?.meta?.getItems) {
setErrorMessage('meta.getItems is not available on this client');
setState('error');
return;
}
setState('loading');
try {
const result: any = await client.meta.getItems('doc');
const items: any[] = Array.isArray(result)
? result
: Array.isArray(result?.items)
? result.items
: Array.isArray(result?.value)
? result.value
: [];
if (cancelled) return;
const mine = items
.filter((it) => it && it.name && it._packageId === appName)
.map((it) => ({ name: it.name, label: it.label, description: it.description }))
.sort((a, b) => (a.label ?? a.name).localeCompare(b.label ?? b.name));
setDocs(mine);
setState('ready');
} catch (err: any) {
if (cancelled) return;
setErrorMessage(err?.message ?? 'Failed to load documentation');
setState('error');
}
}
void load();
return () => {
cancelled = true;
};
}, [adapter, appName]);
const myBooks = useMemo<Book[]>(
() => books.filter((b) => pkgOfBook(b) === appName),
[books, appName],
);
const cards = useMemo(() => buildBookCards(myBooks, docs), [myBooks, docs]);

if (state === 'loading') {
return (
Expand All @@ -86,45 +42,45 @@ export default function AppDocsIndex() {
);
}

if (state === 'error') {
return (
<div className="mx-auto flex max-w-3xl flex-col items-center gap-3 p-10 text-center">
<AlertCircle className="h-10 w-10 text-destructive" />
<h1 className="text-lg font-semibold">Failed to load documentation</h1>
<p className="text-sm text-muted-foreground">{errorMessage}</p>
</div>
);
// One book (the usual case) → land directly on its grouped reader.
if (cards.length === 1) {
return <Navigate to={`/apps/${appName}/docs/${cards[0].slug}`} replace />;
}

return (
<DocShell>
<div className="mx-auto max-w-3xl p-4 sm:p-6">
{docs.length === 0 ? (
{cards.length === 0 ? (
<div className="flex flex-col items-center gap-3 py-16 text-center text-muted-foreground">
<BookOpen className="h-10 w-10" />
<p className="text-sm">
This app does not ship any documentation yet.
</p>
<p className="text-sm">This app does not ship any documentation yet.</p>
<Link to="/docs" className="text-sm font-medium text-primary hover:underline">
Browse all documentation
</Link>
</div>
) : (
<ul className="divide-y divide-border rounded-md border border-border">
{docs.map((doc) => (
<li key={doc.name}>
<ul className="grid gap-3 sm:grid-cols-2">
{cards.map((card) => (
<li key={card.name}>
<Link
to={`/apps/${appName}/docs/${doc.name}`}
className="flex items-start gap-3 px-3 py-3 hover:bg-muted/50"
to={`/apps/${appName}/docs/${card.slug}`}
className="flex h-full items-start gap-3 rounded-md border border-border p-3 hover:bg-muted/50"
>
<BookOpen className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<BookOpen className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<div className="text-sm font-medium">{doc.label ?? doc.name}</div>
{doc.description ? (
<div className="text-sm font-semibold">{card.label}</div>
{card.description ? (
<div className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
{doc.description}
{card.description}
</div>
) : card.subtitle ? (
<div className="mt-0.5 truncate font-mono text-[11px] text-muted-foreground/70">
{card.subtitle}
</div>
) : null}
<div className="mt-1 text-xs text-muted-foreground/80">
{card.docCount} {card.docCount === 1 ? 'article' : 'articles'}
</div>
</div>
</Link>
</li>
Expand Down
68 changes: 68 additions & 0 deletions apps/console/src/pages/BookPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* 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.
*/

import { useMemo } from 'react';
import { Navigate, useParams } from 'react-router-dom';
import { BookOpen, FileQuestion, Loader2 } from 'lucide-react';
import { DocShell } from './DocShell';
import { useBookData } from './use-book-data';
import { resolveBookTree, scopeDocsToBook, bookSlug, firstDoc } from './book-nav';

/**
* `/docs/:slug` — a book landing (ADR-0046 §6). Opening a book means starting
* to read it, so this resolves the book's spine and redirects to its overview
* doc (`*_index`, else the first doc in order); the reader then shows that doc
* with the book sidebar. This avoids duplicating the table of contents (the
* sidebar already is it). A book with no readable docs shows an empty state.
*/
export default function BookPage() {
const { slug, appName } = useParams<{ slug: string; appName?: string }>();
const { books, docs, state } = useBookData();

const found = useMemo(() => books.find((b) => bookSlug(b) === slug), [books, slug]);
const opensTo = useMemo(
() => (found ? firstDoc(resolveBookTree(found, scopeDocsToBook(found, docs))) : undefined),
[found, docs],
);

if (state === 'loading') {
return (
<div className="flex h-full items-center justify-center p-10 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" aria-label="Loading documentation" />
</div>
);
}

if (found && opensTo) {
const base = appName ? `/apps/${appName}/docs` : '/docs';
return <Navigate to={`${base}/${slug}/${opensTo}`} replace />;
}

// Unknown book, or a book with no readable docs yet.
return (
<DocShell breadcrumb={found ? (found.label ?? slug) : slug}>
<div className="mx-auto flex max-w-3xl flex-col items-center gap-3 p-10 text-center">
{found ? (
<>
<BookOpen className="h-10 w-10 text-muted-foreground" />
<h1 className="text-lg font-semibold">{found.label ?? slug}</h1>
<p className="text-sm text-muted-foreground">This book has no documents yet.</p>
</>
) : (
<>
<FileQuestion className="h-10 w-10 text-muted-foreground" />
<h1 className="text-lg font-semibold">Book not found</h1>
<p className="text-sm text-muted-foreground">
No book named <code className="rounded bg-muted px-1 py-0.5">{slug}</code> is installed.
</p>
</>
)}
</div>
</DocShell>
);
}
75 changes: 75 additions & 0 deletions apps/console/src/pages/BookSidebar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* 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.
*/

import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup, within } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { BookSidebar } from './BookSidebar';
import type { ResolvedBook } from './book-nav';

afterEach(cleanup);

const book: ResolvedBook = {
name: 'crm_manual',
label: 'CRM Manual',
groups: [
{
key: 'start',
label: 'Getting started',
entries: [
{ doc: 'crm_intro', label: 'Intro' },
{ separator: true },
{ href: 'https://example.com', label: 'External' },
],
},
{ key: 'extra', label: 'Extra', synthetic: true, entries: [{ doc: 'misc_note', label: 'Note' }] },
],
};

function renderSidebar(activeDoc?: string) {
return render(
<MemoryRouter>
<BookSidebar book={book} activeDoc={activeDoc} docHref={(n) => `/docs/${n}`} />
</MemoryRouter>,
);
}

describe('BookSidebar', () => {
it('renders the book label and group headings', () => {
renderSidebar();
expect(screen.getByText('CRM Manual')).toBeInTheDocument();
expect(screen.getByText('Getting started')).toBeInTheDocument();
expect(screen.getByText('Extra')).toBeInTheDocument();
});

it('renders doc links to the resolved href', () => {
renderSidebar();
const link = screen.getByRole('link', { name: 'Intro' });
expect(link).toHaveAttribute('href', '/docs/crm_intro');
});

it('marks the active doc with aria-current', () => {
renderSidebar('crm_intro');
expect(screen.getByRole('link', { name: 'Intro' })).toHaveAttribute('aria-current', 'page');
expect(screen.getByRole('link', { name: 'Note' })).not.toHaveAttribute('aria-current');
});

it('renders external links with target=_blank', () => {
renderSidebar();
const ext = screen.getByRole('link', { name: /External/ });
expect(ext).toHaveAttribute('href', 'https://example.com');
expect(ext).toHaveAttribute('target', '_blank');
});

it('renders a separator as a non-interactive divider', () => {
renderSidebar();
// The "Getting started" group has 2 links (Intro + External) and 1 separator.
const startNav = screen.getByLabelText('CRM Manual');
expect(within(startNav).getAllByRole('link').length).toBe(3); // Intro, External, Note
});
});
Loading
Loading