diff --git a/.claude/workflows/docs-accuracy-audit.js b/.claude/workflows/docs-accuracy-audit.js index c43448f066..a34c5b60b3 100644 --- a/.claude/workflows/docs-accuracy-audit.js +++ b/.claude/workflows/docs-accuracy-audit.js @@ -210,6 +210,7 @@ const ALL_HANDWRITTEN = [ "content/docs/ui/pages.mdx", "content/docs/ui/public-data-collection.mdx", "content/docs/ui/react-pages.mdx", + "content/docs/ui/reports.mdx", "content/docs/ui/setup-app.mdx", "content/docs/ui/translations.mdx", "content/docs/ui/views.mdx", diff --git a/content/docs/ui/doc-pages.mdx b/content/docs/ui/doc-pages.mdx index e69772e49c..35a22a6a39 100644 --- a/content/docs/ui/doc-pages.mdx +++ b/content/docs/ui/doc-pages.mdx @@ -1,6 +1,6 @@ --- title: Doc Metadata -description: Ship package documentation as metadata — flat src/docs/*.md files compiled into the manifest and rendered in the console +description: Ship package documentation as metadata — flat src/docs/*.md files compiled into the manifest and rendered in the console, ordered by a book navigation spine --- # Doc Metadata @@ -157,6 +157,170 @@ The CRM package manages accounts, contacts, and opportunities. Saved as `src/docs/crm_index.md`, this compiles to a `crm_index` doc and renders at `/docs/crm_index`. +## Navigation: the `book` spine + +A doc is one page. A **Book** is the *spine* of a table of contents over many of them: +an ordered set of groups (sections), plus the book's own identity and access. Flat +`src/docs/*.md` files give you pages with no order; a book is what turns them into a +navigable structure — and it is the only thing that does. + +A package ships **zero or more** books, and a book never owns content: one doc may +surface in two books, or in none. Books are authored in `*.book.ts` files. + +### Membership is derived, never stored + +This is the load-bearing decision of the design ([ADR-0046](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0046-package-docs-as-metadata.md) §6.2.1), +and it is why a book has no member array to keep up to date. A group declares a **rule**; +the tree is computed against whatever docs exist at the moment it is requested. + +Precisely what derives it, in the order it runs: + +1. **Groups are ordered** by `group.order`, ties broken by declaration order. +2. **Each doc joins the first group that claims it** — the first group, in that order, + whose `include` rule matches the doc **or** whose `key` equals the doc's own `group`. + First claim wins, so a doc never appears twice. +3. **Within a group, docs sort by `doc.order`, then by label** (falling back to the doc + name). +4. **Anything claimed by nobody is appended last** in a synthetic *Uncategorized* group. + Nothing is ever dropped. + +So the AI-authoring property the design was built for holds: create a doc whose name +matches a rule and it files itself. There is no central array to read, modify and write +back — which is the edit that drops or reorders siblings when two authors do it at once, +and the one that package overlay cannot merge. + +An `include` rule takes one of two forms: + +- **A glob over doc names** — `'crm_guide_*'`. Only `*` is special and it is anchored to + the whole name. +- **A tag** — `{ tag: 'tutorial' }`, matched against the doc's `tags`. Use it for + membership that cuts across naming; prefer a name convention when one exists. + +`group.package` scopes a rule to one package id (default: the book's own), so a group can +deliberately gather docs another package ships. + +### The three per-doc keys the spine reads + +| Key | Effect | Set from | +| :--- | :--- | :--- | +| `order` | sort position within the group that claims the doc | frontmatter `order:` | +| `group` | explicit placement — the `key` of the group this doc belongs to, used when no rule expresses it | frontmatter `group:` | +| `tags` | the operand of a group's `include: { tag }` rule | **not read from frontmatter** — see below | + + +**`tags` cannot be set from a `src/docs/*.md` file today.** The frontmatter reader +extracts single-line scalars — `title`, `description`, `order`, `group` — and has no case +for a list, so a `tags:` block in a Markdown doc is not collected and the doc reaches the +resolver with no tags. The schema key and the matcher are both live, so a doc declared +programmatically in a stack's `docs` array does carry tags and does match. For the flat +Markdown path, express the grouping with a name glob instead. + + +### Identity and access + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `name` | `string` | ✅ | Machine name (`snake_case`), namespace-prefixed like every metadata name | +| `label` | `string` | — | Display title | +| `description` | `string` | — | One-line summary | +| `slug` | `string` | — | Portal URL segment; defaults to the name without its prefix | +| `icon` | `string` | — | Icon name | +| `order` | `number` | — | Orders this book among the portal's books | +| `audience` | `'org' \| 'public' \| { permissionSet }` | — | Who may read it; defaults to `'org'` | +| `groups` | `BookGroup[]` | ✅ | The spine. Two levels total — groups, then entries | + +`audience` is a reference into the permission model rather than a vocabulary of its own: +`'org'` (the default) inherits the package grant and admits any signed-in principal, +`'public'` is anonymously readable and indexable, and `{ permissionSet: 'crm_admin' }` +admits a signed-in principal holding that named set. A caller whose holdings cannot be +resolved is denied — the gate fails closed. + + +The gate is a **capability** reference, never a distribution one: packages own permission +sets but never positions, so a package gating its own Admin Guide keeps provenance and +uninstall semantics intact. (ADR-0046 §6.7 sketched this as `{ profile }`; the shipped +key is `permissionSet`, per ADR-0090.) + + +### A worked example + +Saved as `src/books/crm_docs.book.ts`, alongside the `crm_index` doc from the previous +section: + +{/* os:check */} +```typescript +import { defineBook } from '@objectstack/spec/system'; + +export const CrmDocsBook = defineBook({ + name: 'crm_docs', + label: 'CRM Documentation', + slug: 'crm', + audience: 'org', + groups: [ + { + key: 'overview', + label: 'Overview', + order: 1, + // Hand-pinned order; `...` sweeps in anything else the rule would match. + include: 'crm_index*', + pages: ['crm_index', '---', '...'], + }, + { + key: 'guides', + label: 'User Guides', + order: 2, + include: 'crm_guide_*', // crm_guide_leads, crm_guide_accounts, … + }, + { + key: 'admin', + label: 'Administration', + order: 3, + include: 'crm_admin_*', + // This section alone is gated; the rest of the book stays 'org'-visible + // because the doc's effective audience is the union over claiming books. + }, + ], +}); +``` + +A group may pin its order by hand instead of deriving it. `pages` wins over `include` for +that group and takes doc names plus two literals: `'---'` renders a separator, and `'...'` +expands to *the rest* — every doc the group's rule would have claimed but that no entry +names, in `order`-then-label sequence. An entry can also be an object to attach a `label` +override, a `badge` or an `icon`, or to point at an external `href` instead of a doc. + + +Inline `translations` on a **book** or a **book group** is rejected: no resolver ever read +it, so a localized spine shipped its authoring-locale strings to every reader. The near +neighbour that *does* work is `doc.translations`, read on every doc render path — localize +the docs themselves. + + +### How `doc` and `book` compose + +The two kinds have a clean split, and the direction of reference only goes one way: + +- A **doc** carries the content and, optionally, three scalars that let a spine place it + (`order`, `group`, `tags`). It names no book. +- A **book** carries the structure and names no docs — except in a `pages` override, which + is the deliberate escape hatch. + +The rendered tree is resolved on read, not on write: +`GET /api/v1/meta/book//tree` fetches the book and the current doc set and returns +the resolved groups and entries. Two behaviours follow from that: + +- **A name that matches no authored book is treated as a package id** and resolved against + the *implicit* per-package book — one group, `include: '*'`, audience `'org'`. There is + no "flat versus book" fork in the model; a package that authors no book still has one, + and that is what renders the flat case. +- **Access is filtered twice.** The book's `audience` gates the whole tree (401 anonymous, + 403 for a missing permission set), and then each entry is filtered by its doc's own + effective audience — the union over every book claiming it, defaulting to `'org'` for a + doc no book claims. An anonymous reader of a public book therefore never sees a nav + entry that would fail on fetch. Orphans in the *Uncategorized* group are deliberately + excluded from what a book "claims", so an unclaimed doc can never ride a public book out + of the tenant. + ## Next Steps - See the in-repo authoring reference in the showcase package: diff --git a/content/docs/ui/meta.json b/content/docs/ui/meta.json index 3643f6e4e1..1464d86d2f 100644 --- a/content/docs/ui/meta.json +++ b/content/docs/ui/meta.json @@ -8,6 +8,7 @@ "views", "actions", "dashboards", + "reports", "translations", "forms", "doc-pages", diff --git a/content/docs/ui/reports.mdx b/content/docs/ui/reports.mdx new file mode 100644 index 0000000000..24b1c919bb --- /dev/null +++ b/content/docs/ui/reports.mdx @@ -0,0 +1,258 @@ +--- +title: Report Metadata +description: Analytics reports as metadata — the four report shapes, dataset binding, drill-through, and how a report differs from a list view and a dashboard widget +--- + +# Report Metadata + +A **Report** is an analytics artifact. It groups and aggregates the rows of a +[dataset](/docs/data-modeling/analytics) into a pivot — grouped down-axis rows, +optionally pivoted across a second axis, with measures in the cells — and gives that +pivot its own page in the app. + +Three surfaces in this module look similar from a distance and are not +interchangeable. Pick by **what the reader is looking at**: + +| | A report shows | Bound to | Authored in | Reached at | +| :--- | :--- | :--- | :--- | :--- | +| **List view** | individual records, one row each | an **object** | that object's `views` | the object's nav entry | +| **Dashboard widget** | one aggregate slice, sized into a tile grid | a **dataset** | a dashboard's `widgets[]` | inside its dashboard | +| **Report** | a whole pivot — grouped rows, optional across-axis columns, aggregated cells, an optional chart, drill-through to the records | a **dataset** | `defineStack({ reports })` | its own nav entry and URL | + +The line between the first and the third is the one that actually gets crossed. A flat +list of records is an **object-bound row lens** (ADR-0017), not analytics — so it belongs +in a view, whatever it is called. The showcase package made exactly that move: its former +`TaskListReport` is now the `tabular` list view on `showcase_task`, because a report that +never aggregated anything was a view wearing the wrong kind. + +The line between the second and the third is scale, not capability: a widget is one slice +sized into a dashboard's grid, a report is the full grid with its own page. Both bind +datasets the same way, so the numbers agree by construction. + +## A report binds a dataset — and only a dataset + + +**There is one data path, not two.** Under the **ADR-0021** single-form cutover a report +is dataset-bound, full stop. The legacy inline query — `objectName` plus `columns` plus +`groupings` on the report itself — was removed in that cutover, so there is no +object-bound report to choose between. Writing `objectName`, `object`, `source` or +`dataSet` is rejected at authoring time with a pointer at `dataset`. + + +Every report except a `joined` one must declare `dataset` **and** a non-empty `values`; +the schema refuses it otherwise, with the message *a report needs `dataset` + `values` +(measure names)*. A `joined` report carries its data on `blocks[]` instead and must declare at +least one block. + +The dataset owns the base object, the joins, and the named dimensions and measures. That +is what keeps a number identical across every report, widget and dashboard that selects +it. Dataset authoring — declaring dimensions and measures, and what `rows` / `values` / +`runtimeFilter` select from them — is covered once, from the dataset side, in +[Analytics & Datasets](/docs/data-modeling/analytics). This page covers the report shape +on top of it. + +## The four report types + +`type` defaults to `tabular`. + +| `type` | Renders | Needs | +| :--- | :--- | :--- | +| `tabular` | the dataset's rows as a flat table | `dataset` + `values` | +| `summary` | rows grouped down one or more dimensions, measures aggregated per group | `dataset` + `values`, and `rows` to group by | +| `matrix` | a true pivot: `rows` down × `columns` across, measures in the cells | `dataset` + `values` + `rows` + `columns` | +| `joined` | several independent sub-reports stacked in one page | `blocks[]` (each block dataset-bound) | + +### Summary — grouped totals + +{/* os:check */} +```typescript +import { defineReport } from '@objectstack/spec/ui'; + +export const HoursByStatusReport = defineReport({ + name: 'showcase_hours_by_status', + label: 'Hours by Status', + description: 'Estimated hours grouped by task status.', + type: 'summary', + dataset: 'showcase_task_metrics', + rows: ['status'], // dimension names, down the page + values: ['est_hours'], // measure names, aggregated per group +}); +``` + +### Matrix — a cross-tab + +`columns` is the across-axis and is read only by a `matrix` report; other types ignore it. + +{/* os:check */} +```typescript +import { defineReport } from '@objectstack/spec/ui'; + +export const StatusPriorityMatrixReport = defineReport({ + name: 'showcase_status_priority_matrix', + label: 'Status × Priority', + type: 'matrix', + dataset: 'showcase_task_metrics', + rows: ['status'], // down axis + columns: ['priority'], // across axis + values: ['est_hours'], // in the cells +}); +``` + +### Joined — several sub-reports in one page + +Each block is independently queried and stacked in the container. Use it for comparative +panels over one domain — "open / completed", "new / qualified / closed" — where each panel +is a different slice rather than a different subject. + +A block is a sub-report, so it takes the same `dataset` / `rows` / `columns` / `values` / +`runtimeFilter` / `order` vocabulary. Four things are **container-level only** and are +rejected on a block: nested `blocks` (no recursion — a block's `type` enum excludes +`joined`), `drilldown`, `protection`, and — as below — `order` on a `joined` container. + +{/* os:check */} +```typescript +import { defineReport } from '@objectstack/spec/ui'; + +export const TaskOverviewReport = defineReport({ + name: 'showcase_task_overview', + label: 'Task Overview', + type: 'joined', + blocks: [ + { + name: 'open_block', + label: 'Open Tasks', + type: 'summary', + dataset: 'showcase_task_metrics', + rows: ['status'], + values: ['est_hours'], + runtimeFilter: { done: false }, + }, + { + name: 'done_block', + label: 'Completed Tasks', + type: 'summary', + dataset: 'showcase_task_metrics', + rows: ['status'], + values: ['task_count'], + runtimeFilter: { done: true }, + }, + ], +}); +``` + +## Ordering + +`order` is a **list** of sort keys, most significant first — an array rather than a map, +because the key order is the sort significance and JSON object key order is not a contract +you should have to lean on. Each key is `{ by, direction }`, `direction` defaulting to +`asc`. + +Two rules are enforced when the report is authored, not discovered when it renders: + +- **`by` must name something this report selects** — a `rows` / `columns` dimension or a + `values` measure. Anything else is an authoring error rather than an ordering that + silently does nothing. +- **A `joined` report orders per block.** `order` on the container is rejected with + *a `joined` report orders per block — move `order` onto `blocks[]`*. + +Ordering is optional: a selected date dimension already comes back chronological. What +`order` is for — sorting by a measure, reversing a time axis, ordering a non-time +dimension — and how it is applied server-side over the whole grid is covered in +[Analytics & Datasets](/docs/data-modeling/analytics), which this page does not repeat. + +## Drill-through + +`drilldown` is a **boolean**, on by default (ADR-0021 D2). It turns click-through from an +aggregated row or cell to the underlying records on or off for a `summary` / `matrix` +report; the host resolves the dataset's object and its dimension-to-field mapping. + + +`drillDown` — camelCase — is a **different capability on a different surface**: it is the +react-tier `` prop, a configuration *object* that configures a +chart segment drill. The report key is `drilldown`, all lowercase, and a plain boolean. +The two are one character apart, so a rename suggestion would walk you straight into a +second rejection — write `drilldown: true` / `false` if you mean the report. + + +## An embedded chart + +A report may carry one `chart`. Its `xAxis` and `yAxis` name the **bound dataset's** +dimension and measure — not raw object fields — and are plotted from a second dataset +query, so the chart and the grid cannot disagree. + +{/* os:check */} +```typescript +import { defineReport } from '@objectstack/spec/ui'; + +export const HoursByStatusChartReport = defineReport({ + name: 'showcase_hours_by_status_chart', + label: 'Hours by Status (Chart)', + type: 'summary', + dataset: 'showcase_task_metrics', + rows: ['status'], + values: ['est_hours'], + chart: { + type: 'bar', + xAxis: 'status', // a dataset DIMENSION + yAxis: 'est_hours', // a dataset MEASURE + }, +}); +``` + +## Making a report reachable + +A report is not reachable because it exists. Give it a navigation entry on an app: + +{/* os:check */} +```typescript +import { defineApp } from '@objectstack/spec/ui'; + +export const AnalyticsApp = defineApp({ + name: 'showcase_analytics', + label: 'Analytics', + navigation: [ + { + id: 'nav_hours_by_status', + type: 'report', + reportName: 'showcase_hours_by_status', + label: 'Hours by Status', + icon: 'chart-bar', + }, + ], +}); +``` + +`reportName` is **cross-checked against the stack**: an app navigating to a report the +stack does not define fails validation with `App '' navigation references report +'' which is not defined in reports.` (The check is skipped for a stack that declares +no reports at all, where the target may come from another package.) The entry resolves to +`/report/` in the console. + +## Permissions and protection + +The report schema carries exactly **one** access-shaped block, and it is not about +viewers: + +- **`protection`** — the ADR-0010 package-author lock policy, declared once on the report + (never per block). The loader translates it into the runtime protection envelope at + registration time. It governs what an *installing org* may modify, not who may read the + numbers. + +There is deliberately no viewer-permission key on a report. Who may see what comes from +the two layers underneath it: **row- and tenant-level security is enforced by the runtime +per joined object** when the dataset is queried — never declared on the dataset and +never on the report — and **reachability** comes from the app navigation entry and the +permissions on the app that carries it. A report is a presentation over a dataset, so it +inherits the dataset's enforcement rather than restating it. + +## Related + +- **Schema reference:** [Report](/docs/references/ui/report) — the full generated property + tables for `Report`, `JoinedReportBlock`, `ReportChart` and `ReportSort`. +- [Analytics & Datasets](/docs/data-modeling/analytics) — declaring the dataset a report + binds, and the ordering and filter-placeholder semantics shared with dashboards. +- [Dashboard Metadata](/docs/ui/dashboards) — the same dataset binding, sized into a tile + grid. +- [View Metadata](/docs/ui/views) — the object-bound row lens a flat record list belongs + in. +- [App Metadata](/docs/ui/apps) — navigation entries, including `type: 'report'`.