diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index b8ae9ab06c..0cd7476b7e 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -22,7 +22,7 @@ import { ChartGalleryDashboard, OpsDashboard } from './src/ui/dashboards/index.j import { ShowcaseTaskDataset, ShowcaseProjectDataset } from './src/ui/datasets/index.js'; import { allReports } from './src/ui/reports/index.js'; import { allActions } from './src/ui/actions/index.js'; -import { CapabilityMapPage, StartHerePage, ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage, TaskTriagePage, TaskBoardPage, TaskCalendarPage, TaskGalleryPage, TaskSchedulePage, TaskTimelinePage, TaskMapPage, TaskAllViewsPage, ActiveProjectsPage, TaskDetailPage, ReviewQueuePage, NewProjectWizardPage, MyWorkPage, SettingsPage, StylingGalleryPage, CommandCenterPage, CommandCenterJsxPage, CrmWorkbenchPage, AccountCockpitPage, TaskDeskPage, PageVariablesPage, ContactFormPage, RenewalsPipelinePage } from './src/ui/pages/index.js'; +import { CapabilityMapPage, StartHerePage, ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage, TaskTriagePage, TaskBoardPage, TaskCalendarPage, TaskGalleryPage, TaskSchedulePage, TaskTimelinePage, TaskMapPage, TaskAllViewsPage, ActiveProjectsPage, TaskDetailPage, ReviewQueuePage, NewProjectWizardPage, MyWorkPage, SettingsPage, StylingGalleryPage, CommandCenterPage, CommandCenterJsxPage, CrmWorkbenchPage, TaskDeskPage, PageVariablesPage, ContactFormPage, RenewalsPipelinePage } from './src/ui/pages/index.js'; import { allFlows } from './src/automation/flows/index.js'; import { allWebhooks } from './src/automation/webhooks/index.js'; import { allHooks } from './src/data/hooks/index.js'; @@ -168,7 +168,7 @@ export default defineStack({ apps: [ShowcaseApp], portals: allPortals, views: [TaskViews, ProjectViews, InquiryViews, BusinessUnitViews], - pages: [CapabilityMapPage, StartHerePage, ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage, TaskTriagePage, TaskBoardPage, TaskCalendarPage, TaskGalleryPage, TaskSchedulePage, TaskTimelinePage, TaskMapPage, TaskAllViewsPage, ActiveProjectsPage, TaskDetailPage, ReviewQueuePage, NewProjectWizardPage, MyWorkPage, SettingsPage, StylingGalleryPage, CommandCenterPage, CommandCenterJsxPage, CrmWorkbenchPage, AccountCockpitPage, TaskDeskPage, PageVariablesPage, ContactFormPage, RenewalsPipelinePage], + pages: [CapabilityMapPage, StartHerePage, ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage, TaskTriagePage, TaskBoardPage, TaskCalendarPage, TaskGalleryPage, TaskSchedulePage, TaskTimelinePage, TaskMapPage, TaskAllViewsPage, ActiveProjectsPage, TaskDetailPage, ReviewQueuePage, NewProjectWizardPage, MyWorkPage, SettingsPage, StylingGalleryPage, CommandCenterPage, CommandCenterJsxPage, CrmWorkbenchPage, TaskDeskPage, PageVariablesPage, ContactFormPage, RenewalsPipelinePage], dashboards: [ChartGalleryDashboard, OpsDashboard], books: allBooks, datasets: [ShowcaseTaskDataset, ShowcaseProjectDataset], diff --git a/examples/app-showcase/src/automation/flows/index.ts b/examples/app-showcase/src/automation/flows/index.ts index 629af8b944..06c6430a32 100644 --- a/examples/app-showcase/src/automation/flows/index.ts +++ b/examples/app-showcase/src/automation/flows/index.ts @@ -1204,9 +1204,69 @@ export const InboundTaskWebhookFlow = defineFlow({ ], }); +/** + * Inquiry Purge — the worked `get_record` + `delete_record` example, closing + * the CRUD node quartet (create: InboundTaskWebhookFlow · update: + * ReassignWizardFlow · get + delete: here). A janitor flow: fetch the + * already-closed inquiries (records mode), gate on whether any exist, delete + * by the same filter, and report. Config keys follow the executor contract + * exactly — `objectName` + `filter` (Prime Directive #12: no + * `object`/`filters` aliases). `runAs: 'system'` because a janitor acts + * across owners; autolaunched with no record trigger — invoke it on demand + * (API/subflow) rather than on every write. + */ +export const InquiryPurgeFlow = defineFlow({ + name: 'showcase_inquiry_purge', + label: 'Purge Closed Inquiries', + description: 'Deletes inquiries already marked closed — demonstrates get_record + delete_record.', + type: 'autolaunched', + runAs: 'system', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'purge_check', + type: 'get_record', + label: 'Find closed inquiries', + // records mode + outputVariable: the result lands in the variable + // context, where the out-edge CEL below reads it (the engine routes on + // EDGE conditions — same contract as BudgetApprovalFlow's gate). + config: { + objectName: 'showcase_inquiry', + filter: { status: 'closed' }, + mode: 'records', + limit: 200, + outputVariable: 'closedInquiries', + }, + }, + { id: 'any_found', type: 'decision', label: 'Anything to purge?' }, + { + id: 'purge', + type: 'delete_record', + label: 'Delete them', + config: { objectName: 'showcase_inquiry', filter: { status: 'closed' } }, + }, + { + id: 'report', + type: 'notify', + label: 'Report cleanup', + config: { topic: 'inquiry_purge', message: 'Closed inquiries purged.' }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'purge_check' }, + { id: 'e2', source: 'purge_check', target: 'any_found' }, + { id: 'e3', source: 'any_found', target: 'purge', label: 'yes', condition: 'size(closedInquiries) > 0' }, + { id: 'e4', source: 'any_found', target: 'end', label: 'no', condition: 'size(closedInquiries) == 0' }, + { id: 'e5', source: 'purge', target: 'report' }, + { id: 'e6', source: 'report', target: 'end' }, + ], +}); + export const allFlows = [ TaskCompletedFlow, ReassignWizardFlow, + InquiryPurgeFlow, BudgetApprovalFlow, InvoiceDualSignoffFlow, OneTaskSignoffSubflow, diff --git a/examples/app-showcase/src/coverage.ts b/examples/app-showcase/src/coverage.ts index d8efbd733b..e55b711950 100644 --- a/examples/app-showcase/src/coverage.ts +++ b/examples/app-showcase/src/coverage.ts @@ -69,9 +69,14 @@ export const KIND_COVERAGE: Record = { }, validation: { status: 'demonstrated', - files: ['src/data/objects/account.object.ts', 'src/data/objects/task.object.ts'], + files: [ + 'src/data/objects/account.object.ts', + 'src/data/objects/task.object.ts', + 'src/data/objects/project.object.ts', + 'src/data/objects/invoice.object.ts', + ], notes: - 'Authored inline via object `validations`. Only the runtime-enforced rule types (state_machine/script/cross_field) are demonstrated; the 6 unenforced types are tracked in https://github.com/objectstack-ai/framework/issues/1475 (Prime Directive #10).', + 'Authored inline via object `validations`. Every declared rule type is now write-path enforced (rule-validator dispatches all of state_machine/script/cross_field/format/json_schema/conditional — ADR-0020 "no silent no-ops", closing the #1475 gap) and each is demonstrated: state_machine (task/project), script+cross_field (project), format/json_schema/conditional (account). Field-level requiredWhen/readonlyWhen are likewise enforced and demonstrated on invoice.', }, hook: { status: 'demonstrated', files: ['src/data/hooks/index.ts'] }, seed: { status: 'demonstrated', files: ['src/data/seed/index.ts'] }, @@ -241,6 +246,11 @@ export const COVERAGE = { source: 'ActionType + ACTION_LOCATIONS', coveredBy: 'ui/actions/index.ts (script/url/flow/modal/api/form across all locations)', }, + flowNodeTypes: { + source: 'FlowNodeAction', + coveredBy: + 'automation/flows/index.ts — CRUD quartet (create: InboundTaskWebhookFlow, update: ReassignWizardFlow, get+delete: InquiryPurgeFlow), screen/approval/wait/subflow/map/connector_action across the chain; BPMN gateway/boundary forms waived (FLOW_NODE_WAIVERS) in favor of the ADR-0031 structured containers.', + }, capabilityChains: { security: 'security/index.ts — roles + permission set (CRUD + FLS + RLS) + sharing + policy', automation: 'automation/flows/index.ts (incl. approval nodes) + automation/webhooks/index.ts + automation/jobs/index.ts + system/emails/index.ts', @@ -254,6 +264,52 @@ export const COVERAGE = { }, } as const; +/** + * Built-in flow node types (FlowNodeAction) the showcase deliberately does + * not author, with the reason. The coverage test asserts every OTHER member + * of the enum appears in at least one flow, and that each waiver names a + * real enum member with a substantive reason — same demonstrated-or-waived + * contract as the metadata kinds. + */ +export const FLOW_NODE_WAIVERS: Record = { + parallel_gateway: + 'BPMN-interop lowering target — the author-facing form is the ADR-0031 structured `parallel` container (FanOutNotifyFlow); bpmn-mapping lowers it to the gateway pair.', + join_gateway: + 'The AND-join half of the pair bpmn-mapping derives from a structured `parallel` container — never hand-authored in examples.', + boundary_event: + 'BPMN-interop form; the author-facing equivalents the showcase demos are the ADR-0031 `try_catch` container (ResilientSyncFlow) and `wait` timers (TaskFollowUpFlow).', +}; + +/** + * Collect every node `type` used across a set of flow definitions, including + * nodes nested inside structured-container regions (ADR-0031: a `parallel` / + * `loop` / `try_catch` container carries sub-graphs in its config). + */ +export function collectFlowNodeTypes(flows: Array<{ nodes?: Array> }>): Set { + const used = new Set(); + const visitNodes = (nodes: unknown): void => { + if (!Array.isArray(nodes)) return; + for (const node of nodes) { + if (!node || typeof node !== 'object') continue; + const n = node as Record; + if (typeof n.type === 'string') used.add(n.type); + if (n.config) visitContainer(n.config); + } + }; + // Walk any nested { nodes: [...] } region a container config may carry. + const visitContainer = (value: unknown): void => { + if (!value || typeof value !== 'object') return; + if (Array.isArray(value)) { for (const v of value) visitContainer(v); return; } + const obj = value as Record; + for (const [key, v] of Object.entries(obj)) { + if (key === 'nodes') visitNodes(v); + else visitContainer(v); + } + }; + for (const flow of flows) visitNodes(flow.nodes); + return used; +} + /** Collect every field `type` used across a set of object definitions. */ export function collectFieldTypes(objects: Array<{ fields?: Record }>): Set { const used = new Set(); diff --git a/examples/app-showcase/src/data/seed/index.ts b/examples/app-showcase/src/data/seed/index.ts index 41b92c056d..3efb3d1237 100644 --- a/examples/app-showcase/src/data/seed/index.ts +++ b/examples/app-showcase/src/data/seed/index.ts @@ -11,6 +11,7 @@ import { BusinessUnit } from '../objects/business-unit.object.js'; import { Team, ProjectMembership } from '../objects/team.object.js'; import { Product, Invoice, InvoiceLine } from '../objects/invoice.object.js'; import { Contact } from '../objects/contact.object.js'; +import { Inquiry } from '../objects/inquiry.object.js'; import { FieldZoo } from '../objects/field-zoo.object.js'; /** @@ -222,6 +223,19 @@ const invoiceLines = defineSeed(InvoiceLine, { ], }); +// Inquiries so the staff triage list (inquiry views + Contact Form page) +// renders on first boot — the "every view renders real data" principle. One +// per status; the `closed` row doubles as live prey for InquiryPurgeFlow. +const inquiries = defineSeed(Inquiry, { + mode: 'upsert', + externalId: 'email', + records: [ + { name: 'Priya Raman', email: 'priya@meridian.example', company: 'Meridian Labs', message: 'Interested in the delivery workspace for a 40-person team.', status: 'new', source: 'website' }, + { name: 'Tom Okafor', email: 'tom@brightline.example', company: 'Brightline Co', message: 'Following up on the demo — can we scope an invoicing pilot?', status: 'contacted', source: 'referral' }, + { name: 'Lena Fischer', email: 'lena@oldrequest.example', company: 'Archived GmbH', message: 'Old request, already resolved by support.', status: 'closed', source: 'website' }, + ], +}); + const preferences = defineSeed(Preference, { mode: 'upsert', externalId: 'name', @@ -230,4 +244,4 @@ const preferences = defineSeed(Preference, { ], }); -export const ShowcaseSeedData = [accounts, contacts, products, projects, tasks, categories, businessUnits, teams, memberships, fieldZoo, invoices, invoiceLines, preferences]; +export const ShowcaseSeedData = [accounts, contacts, inquiries, products, projects, tasks, categories, businessUnits, teams, memberships, fieldZoo, invoices, invoiceLines, preferences]; diff --git a/examples/app-showcase/src/docs/showcase_index.zh.md b/examples/app-showcase/src/docs/showcase_index.zh.md index abd338c303..1084ce1b14 100644 --- a/examples/app-showcase/src/docs/showcase_index.zh.md +++ b/examples/app-showcase/src/docs/showcase_index.zh.md @@ -14,3 +14,22 @@ ObjectStack 协议的活体一致性夹具:每种字段类型、视图类型、 本页自己必须遵守的撰写规则,见 [文档撰写指南](./showcase_docs_guide.md)。 + +## 分域导览 + +每个协议域一篇走查,与 `src/` 目录结构一一对应: + +- [数据](./showcase_tour_data.md) — 对象、字段、校验规则、钩子、种子数据、 + 对象扩展、分析 cube +- [界面](./showcase_tour_ui.md) — 应用、视图、页面、仪表盘、报表、数据集、 + 动作、主题、门户 +- [自动化](./showcase_tour_automation.md) — 流程与审批、定时任务、Webhook、 + 连接器 +- [系统](./showcase_tour_system.md) — 数据源与联邦、国际化、邮件、 + 文档即元数据、自定义端点 +- [安全](./showcase_tour_security.md) — 角色、权限集、Profile、共享规则、 + 行级安全 + +**AI(agent / tool / skill)**是第六个协议域,这里刻意没有演示:agent 由平台 +自有(ADR-0063),开源框架只经 MCP 暴露 AI 能力。覆盖清单以豁免条目如实记账—— +见 [framework#2610](https://github.com/objectstack-ai/framework/issues/2610)。 diff --git a/examples/app-showcase/src/docs/showcase_metadata_views_guide.md b/examples/app-showcase/src/docs/showcase_metadata_views_guide.md index c972b4147c..3b6a562678 100644 --- a/examples/app-showcase/src/docs/showcase_metadata_views_guide.md +++ b/examples/app-showcase/src/docs/showcase_metadata_views_guide.md @@ -1,6 +1,6 @@ --- title: Live Metadata Views in Docs -description: Embed live, read-only views of state machines, flows, and permissions directly in the prose. +description: How to embed live, read-only views of state machines, flows, and permissions directly in the prose. --- # Live Metadata Views in Docs @@ -11,54 +11,49 @@ ever shows *their own slice* of the system. It never shows the whole shape of a process, the full set of legal state transitions, or who can do what across an object. -This page embeds those **live, read-only views** straight into the prose with a -` ```metadata ` fenced block. Each view is **resolved at read time** from the -current metadata — change the underlying state machine and the diagram below -changes with it. Nothing here is a screenshot (ADR-0051). +A ` ```metadata ` fenced block embeds that **live, read-only view** straight +into the prose. Each view is **resolved at read time** from the current +metadata — change the underlying rule and the diagram changes with it. Nothing +is a screenshot (ADR-0051). -## A record lifecycle — state machine +## The mechanism -A Task moves across a board. Which moves are legal is governed by the -`task_status_flow` state machine on the `showcase_task` object. Here it is, -rendered live from that rule: +A fence body is flat `key: value` data (not code): -```metadata -type: state_machine -object: showcase_task -name: task_status_flow +```md +```metadata +type: state_machine # one of: state_machine · flow · permission +object: showcase_task # state_machine only — the rule lives on an object +name: task_status_flow # the metadata name; linted for liveness at build +detail: business # flow only — fold technical nodes away +``` ``` -Projects have their own lifecycle, including terminal (dead-end) states: - -```metadata -type: state_machine -object: showcase_project -name: project_status_flow -``` +Two guarantees keep embeds honest: -## A process — flow +- **Build-time liveness lint** — a dead same-package reference (typo'd name, + deleted rule) fails `os build`, exactly like a broken doc link. +- **Read-time resolution** — the rendered view is projected from the metadata + the server is running *now*, never a stale copy. -The reassignment wizard, shown at the **business altitude** — purely technical -steps (scripts, record I/O) are folded away so the reader sees the process, not -the plumbing: +Here is one live sample — the `task_status_flow` state machine on +`showcase_task`, the rule that governs which board moves are legal: ```metadata -type: flow -name: showcase_reassign_wizard -detail: business +type: state_machine +object: showcase_task +name: task_status_flow ``` -## Who can do what — permission +## Where the embeds live now -The `showcase_contributor` permission set, as an object-access matrix: +The guided tour embeds each view type **in the context that explains it**: -```metadata -type: permission -name: showcase_contributor -``` - ---- +- state machines (task and the project lifecycle with terminal states) — in + the [Data tour](./showcase_tour_data.md) +- a flow at business altitude — in the + [Automation tour](./showcase_tour_automation.md) +- a permission access-matrix — in the + [Security tour](./showcase_tour_security.md) -> These four views are not authored prose — they are the live metadata, -> projected read-only into the document. See -> [the showcase overview](./showcase_index.md) for the rest of the workspace. +Back to the [overview](./showcase_index.md). diff --git a/examples/app-showcase/src/docs/showcase_tour_automation.md b/examples/app-showcase/src/docs/showcase_tour_automation.md index f763f4fdb1..8e1f427870 100644 --- a/examples/app-showcase/src/docs/showcase_tour_automation.md +++ b/examples/app-showcase/src/docs/showcase_tour_automation.md @@ -28,6 +28,12 @@ Trigger the automation yourself: complete a Task (Mark Done) and watch the threshold and the `showcase_budget_approval` chain lands in **Workspace → Approvals**. +> **Heads-up:** completing one task fires **six** flows on purpose — each +> teaches a different mechanism for the same trigger (script side-effect, +> Slack connector, REST connector, subflow reuse, parallel fan-out, +> try/catch resilience). Watch the Runs panel to see them all land from a +> single record change; a real app would consolidate these. + ## Jobs, webhooks, connectors - `src/automation/jobs/` — interval/cron jobs behind the schedule trigger diff --git a/examples/app-showcase/src/docs/showcase_tour_data.md b/examples/app-showcase/src/docs/showcase_tour_data.md index 5161b4ec6c..4512d461a9 100644 --- a/examples/app-showcase/src/docs/showcase_tour_data.md +++ b/examples/app-showcase/src/docs/showcase_tour_data.md @@ -37,6 +37,14 @@ object: showcase_task name: task_status_flow ``` +Projects carry their own lifecycle, including terminal (dead-end) states: + +```metadata +type: state_machine +object: showcase_project +name: project_status_flow +``` + ## Hooks & seed data - `src/data/hooks/` — data-layer lifecycle hooks (before/after CRUD). diff --git a/examples/app-showcase/src/ui/apps/index.ts b/examples/app-showcase/src/ui/apps/index.ts index 8c8572c230..9e11d300c6 100644 --- a/examples/app-showcase/src/ui/apps/index.ts +++ b/examples/app-showcase/src/ui/apps/index.ts @@ -123,8 +123,7 @@ export const ShowcaseApp = App.create({ children: [ { id: 'nav_crm_workbench', type: 'page', pageName: 'showcase_crm_workbench', label: 'CRM Workbench · master/detail', icon: 'layout-dashboard' }, { id: 'nav_task_desk', type: 'page', pageName: 'showcase_task_desk', label: 'Task Desk · drawer & modal', icon: 'panel-right-open' }, - { id: 'nav_account_cockpit', type: 'page', pageName: 'showcase_account_cockpit', label: 'Account Cockpit · live rollup', icon: 'satellite' }, - { id: 'nav_renewals_pipeline', type: 'page', pageName: 'showcase_renewals_pipeline', label: 'Renewals Pipeline · record blocks', icon: 'refresh-cw' }, + { id: 'nav_renewals_pipeline', type: 'page', pageName: 'showcase_renewals_pipeline', label: 'Renewals Pipeline · rollups & blocks', icon: 'refresh-cw' }, ], }, ], diff --git a/examples/app-showcase/src/ui/pages/account-cockpit.page.ts b/examples/app-showcase/src/ui/pages/account-cockpit.page.ts deleted file mode 100644 index dd7d7d2fcb..0000000000 --- a/examples/app-showcase/src/ui/pages/account-cockpit.page.ts +++ /dev/null @@ -1,91 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { definePage } from '@objectstack/spec/ui'; - -/** - * Account Cockpit — a `kind:'react'` business scenario (ADR-0081). - * - * Customer-360 over `showcase_account`: a live search filters a real - * ``, selecting an account loads it into an `` editor and - * rolls up related projects & invoices via `useAdapter()` cross-object queries. - * - * Styling (ADR-0065): no Tailwind — inline `style={{}}` with `hsl(var(--token))`. - */ -export const AccountCockpitPage = definePage({ - name: 'showcase_account_cockpit', - label: 'Account Cockpit (React)', - type: 'home', - kind: 'react', - source: ` -function Page() { - const adapter = useAdapter(); - const [q, setQ] = React.useState(''); - const [sel, setSel] = React.useState(null); - const [reload, setReload] = React.useState(0); - const [related, setRelated] = React.useState({ projects: 0, invoices: 0, openInvoices: 0 }); - - React.useEffect(() => { - let alive = true; - (async () => { - if (!adapter || !sel) { setRelated({ projects: 0, invoices: 0, openInvoices: 0 }); return; } - const pr = await adapter.find('showcase_project', { $filter: ['account', '=', sel.id], top: 500 }); - const iv = await adapter.find('showcase_invoice', { $filter: ['account', '=', sel.id], top: 500 }); - const projects = Array.isArray(pr) ? pr : (pr && pr.records) || []; - const invoices = Array.isArray(iv) ? iv : (iv && iv.records) || []; - if (alive) setRelated({ projects: projects.length, invoices: invoices.length, openInvoices: invoices.filter((r) => r.status !== 'paid' && r.status !== 'void').length }); - })(); - return () => { alive = false; }; - }, [adapter, sel, reload]); - - const filters = q.trim() ? ['name', 'contains', q.trim()] : undefined; - const card = { background: 'hsl(var(--card))', border: '1px solid hsl(var(--border))', borderRadius: 'var(--radius)' }; - const Stat = ({ label, value, accent }) => ( -
-
{label}
-
{value}
-
- ); - - return ( -
-
-
-

Account Cockpit

-

Customer-360 over showcase_account — search, edit, and roll up related projects & invoices.

-
- { setQ(e.target.value); setSel(null); }} placeholder="Search accounts…" - style={{ width: 256, borderRadius: 'var(--radius)', border: '1px solid hsl(var(--border))', background: 'hsl(var(--background))', color: 'hsl(var(--foreground))', padding: '8px 12px', fontSize: 14, outline: 'none' }} /> -
- -
-
- setSel(r)} /> -
-
- {sel ? ( - -
- - - -
-
- { setSel(null); setReload((k) => k + 1); }} - onCancel={() => setSel(null)} /> -
-
- ) : ( -
-
🛰️
-

Search and select an account.

-
- )} -
-
-
- ); -}`, -}); diff --git a/examples/app-showcase/src/ui/pages/command-center-jsx.page.ts b/examples/app-showcase/src/ui/pages/command-center-jsx.page.ts index 340a4c66a0..782320ce93 100644 --- a/examples/app-showcase/src/ui/pages/command-center-jsx.page.ts +++ b/examples/app-showcase/src/ui/pages/command-center-jsx.page.ts @@ -13,6 +13,12 @@ import { definePage } from '@objectstack/spec/ui'; * ``), and any custom CSS is a JSON `style` object with * `hsl(var(--token))` theme colors (quoted keys/values — a JS-style object is * parsed as a deferred expression and won't apply). + * + * The KPI numbers are STATIC SAMPLE COPY, and the page says so on screen — + * hand-authored html JSX carries no data bindings. Its data-bound twin + * (command-center.page.ts, kind:'full' with `object-metric`/`object-chart`) + * shows the same board with live numbers; together they demo the tier + * trade-off honestly. */ export const CommandCenterJsxPage = definePage({ name: 'showcase_command_center_jsx', @@ -25,7 +31,7 @@ export const CommandCenterJsxPage = definePage({
Operations · HTML-source page
Command Center
-
Authored as constrained JSX and compiled to the SDUI tree — parsed, never executed. Layout is structured component props; color is an inline style object with theme tokens. No Tailwind.
+
Authored as constrained JSX and compiled to the SDUI tree — parsed, never executed. Layout is structured component props; color is an inline style object with theme tokens. No Tailwind. The KPI numbers are static sample copy — for the live, data-bound version of this board see Command Center (大屏) in Analytics.
diff --git a/examples/app-showcase/src/ui/pages/index.ts b/examples/app-showcase/src/ui/pages/index.ts index 4fc6846f78..ac4e9acca3 100644 --- a/examples/app-showcase/src/ui/pages/index.ts +++ b/examples/app-showcase/src/ui/pages/index.ts @@ -18,7 +18,6 @@ export { StylingGalleryPage } from './styling-gallery.page.js'; export { CommandCenterPage } from './command-center.page.js'; export { CommandCenterJsxPage } from './command-center-jsx.page.js'; export { CrmWorkbenchPage } from './crm-workbench.page.js'; -export { AccountCockpitPage } from './account-cockpit.page.js'; export { TaskDeskPage } from './task-desk.page.js'; export { PageVariablesPage } from './page-variables.page.js'; export { ContactFormPage } from './contact-form.page.js'; diff --git a/examples/app-showcase/src/ui/pages/renewals-pipeline.page.ts b/examples/app-showcase/src/ui/pages/renewals-pipeline.page.ts index 7ad96726d3..815b8ca2f1 100644 --- a/examples/app-showcase/src/ui/pages/renewals-pipeline.page.ts +++ b/examples/app-showcase/src/ui/pages/renewals-pipeline.page.ts @@ -11,8 +11,17 @@ import { definePage } from '@objectstack/spec/ui'; * Every block prop is taken straight from the react-tier contract * (skills/objectstack-ui/references/react-blocks.md). * + * The 360 panel deliberately shows BOTH rollup styles side by side: + * • hand-rolled — a `useAdapter()` effect counts related projects/invoices + * into a KPI strip (full control, you own loading/refresh), vs + * • framework blocks — ``/`` do the same + * cross-object reads declaratively (zero data code). + * (This comparison absorbed the former Account Cockpit page.) + * * Styling (ADR-0065): no Tailwind — inline `style={{}}` with `hsl(var(--token))`; - * data blocks and the drawer bring their own compiled styling. + * data blocks and the drawer bring their own compiled styling. The drawer sets + * NO pixel width: per #2578 pixel widths are deprecated (the author can't know + * the client viewport) — omit and let the renderer derive the size. */ export const RenewalsPipelinePage = definePage({ name: 'showcase_renewals_pipeline', @@ -21,10 +30,34 @@ export const RenewalsPipelinePage = definePage({ kind: 'react', source: ` function Page() { + const adapter = useAdapter(); const [sel, setSel] = React.useState(null); const [editing, setEditing] = React.useState(false); const [reload, setReload] = React.useState(0); const [stage, setStage] = React.useState('active'); + const [related, setRelated] = React.useState({ projects: 0, invoices: 0, openInvoices: 0 }); + + // Hand-rolled rollup: the imperative counterpart of the framework blocks + // below. You own the queries, loading, and refresh (reload bumps re-run it). + React.useEffect(() => { + let alive = true; + (async () => { + if (!adapter || !sel) { setRelated({ projects: 0, invoices: 0, openInvoices: 0 }); return; } + const pr = await adapter.find('showcase_project', { $filter: ['account', '=', sel], top: 500 }); + const iv = await adapter.find('showcase_invoice', { $filter: ['account', '=', sel], top: 500 }); + const projects = Array.isArray(pr) ? pr : (pr && pr.records) || []; + const invoices = Array.isArray(iv) ? iv : (iv && iv.records) || []; + if (alive) setRelated({ projects: projects.length, invoices: invoices.length, openInvoices: invoices.filter((r) => r.status !== 'paid' && r.status !== 'void').length }); + })(); + return () => { alive = false; }; + }, [adapter, sel, reload]); + + const Stat = ({ label, value, accent }) => ( +
+
{label}
+
{value}
+
+ ); const STAGES = [ { id: 'active', label: 'Active' }, @@ -75,13 +108,19 @@ function Page() { +
+ + + +
+ {editing ? ( { if (!o) setEditing(false); }} onSuccess={() => { setEditing(false); setReload((n) => n + 1); }} onCancel={() => setEditing(false)} /> diff --git a/examples/app-showcase/src/ui/pages/start-here.page.ts b/examples/app-showcase/src/ui/pages/start-here.page.ts index 54d3eef183..56862580bc 100644 --- a/examples/app-showcase/src/ui/pages/start-here.page.ts +++ b/examples/app-showcase/src/ui/pages/start-here.page.ts @@ -3,7 +3,9 @@ import { definePage } from '@objectstack/spec/ui'; /** - * Start Here — the showcase's teaching index and default landing. + * Start Here — the page-authoring teaching index (nav label "Page Authoring"). + * The app's default landing is the Capability Map (capability-map.page.ts, + * first nav item); this page is the deep-dive on ONE of its domains. * * A page has TWO orthogonal axes: * • type — the surface ROLE: home · record · list · app diff --git a/examples/app-showcase/src/ui/views/task.view.ts b/examples/app-showcase/src/ui/views/task.view.ts index 8d6d3b4c79..43b66171cc 100644 --- a/examples/app-showcase/src/ui/views/task.view.ts +++ b/examples/app-showcase/src/ui/views/task.view.ts @@ -242,7 +242,10 @@ export const TaskViews = defineView({ // View-level conditional visibility (FormField.visibleOn, CEL): // the notes box only appears while the task is Urgent. Data-level // counterpart is `visibleWhen` on invoice.paid_on. - { field: 'notes', visibleOn: P`record.priority == 'urgent'`, colSpan: 2 }, + // Width via the semantic `span` (#2578): 'full' = whole row at any + // derived column count — the primary primitive; absolute colSpan + // is legacy and lint-discouraged. + { field: 'notes', visibleOn: P`record.priority == 'urgent'`, span: 'full' }, ], }, ], diff --git a/examples/app-showcase/test/coverage.test.ts b/examples/app-showcase/test/coverage.test.ts index f38d598da2..0f6cc9a229 100644 --- a/examples/app-showcase/test/coverage.test.ts +++ b/examples/app-showcase/test/coverage.test.ts @@ -3,11 +3,13 @@ import { existsSync } from 'node:fs'; import { describe, it, expect } from 'vitest'; +import { FlowNodeAction } from '@objectstack/spec/automation'; import { FieldType } from '@objectstack/spec/data'; import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; import * as ui from '@objectstack/spec/ui'; import * as objects from '../src/data/objects/index.js'; +import { allFlows } from '../src/automation/flows/index.js'; import { TaskViews, ProjectViews } from '../src/ui/views/index.js'; import { ChartGalleryDashboard } from '../src/ui/dashboards/index.js'; import { allReports } from '../src/ui/reports/index.js'; @@ -15,9 +17,11 @@ import { allActions } from '../src/ui/actions/index.js'; import { KIND_COVERAGE, STACK_COLLECTION_COVERAGE, + FLOW_NODE_WAIVERS, LIST_VIEW_TYPES, FORM_VIEW_TYPES, collectFieldTypes, + collectFlowNodeTypes, collectListViewTypes, collectFormViewTypes, } from '../src/coverage.js'; @@ -122,6 +126,17 @@ describe('showcase coverage (introspected against the spec)', () => { } }); + it('covers every built-in flow node type — or waives it with a reason', () => { + const all = enumValues(FlowNodeAction); + // Every waiver must name a real enum member and carry a substantive reason. + for (const [type, reason] of Object.entries(FLOW_NODE_WAIVERS)) { + expect(all, `FLOW_NODE_WAIVERS names unknown node type '${type}'`).toContain(type); + expect(reason.length, `flow-node waiver '${type}' needs a substantive reason`).toBeGreaterThan(20); + } + const expected = all.filter((t) => !(t in FLOW_NODE_WAIVERS)); + expectFullCoverage('FlowNodeAction', expected, collectFlowNodeTypes(allFlows as never)); + }); + it('covers every action type and location', () => { const types = enumValues((ui as Record).ActionType ?? (ui as Record).ActionTypeSchema); const locations = enumValues((ui as Record).ACTION_LOCATIONS ?? (ui as Record).ActionLocationSchema);