diff --git a/README.md b/README.md index 92e65c6a44..3ca74b840d 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ npm install @object-ui/react @object-ui/components ```tsx import React from 'react' -import { SchemaRenderer } from '@object-ui/react' +import { PredicateScopeProvider, SchemaRenderer } from '@object-ui/react' // Importing the package registers every default renderer as a side effect — // there is no separate registration call. import '@object-ui/components' @@ -92,16 +92,28 @@ const schema = { } function App() { - const data = { + // Every key of this object becomes a root the schema's expressions can read — + // `stats` here is what answers `${stats.users}`. + const scope = { stats: { users: 1234, revenue: "$56,789", orders: 432 } } - return + return ( + + + + ) } export default App ``` +Expression scope reaches the renderer through the provider, never through a prop on the +element. `SchemaRenderer` declares exactly one prop, `schema`, and forwards every other prop +it is handed to the component the schema names — so a value passed as `data={…}` is neither +read nor refused, and the expression that wanted it is returned as its own source text, with +nothing thrown and one line on the console. + ### Bring your own backend Use the shell and views without the full console infrastructure — your routing, your auth, your API: @@ -171,6 +183,10 @@ docs render, a smoke test mounts, and AI agents use as a few-shot corpus. ## Copy-Paste Schemas +A `${name.…}` in any of these reads the root `name` off the scope the host published — see +["Basic Usage"](#basic-usage) for the provider that publishes one. A head name nothing +published is not an error: the expression is returned as its own source text. + #### 📝 Contact Form ```json @@ -258,9 +274,9 @@ Object UI talks to any backend through one `DataSource` interface. npm install @object-ui/data-objectstack ``` -```typescript +```tsx import { createObjectStackAdapter } from '@object-ui/data-objectstack'; -import { SchemaRenderer } from '@object-ui/react'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; import type { BaseSchema } from '@object-ui/types'; // Your page schema — "Render a schema" above writes one out in full. @@ -271,10 +287,18 @@ const dataSource = createObjectStackAdapter({ token: 'your-auth-token' }); -// Use with any component - +// The adapter is injected through the provider — `SchemaRenderer` does not read +// a `dataSource` prop, it forwards it to the component the schema names. + + + ``` +⛔ `dataSource` is the **adapter** — the object data renderers call `find()` on — and not an +expression root. It is a different channel from the expression scope above: publish the values +your `${…}` expressions read with `PredicateScopeProvider`, and inject the adapter your data +components query with `SchemaRendererProvider`. + ### Custom Data Sources Adapt any backend (REST, GraphQL, Firebase, …) by implementing `DataSource`: diff --git a/content/docs/guide/architecture.md b/content/docs/guide/architecture.md index b596cb74e7..5ae2d81fc6 100644 --- a/content/docs/guide/architecture.md +++ b/content/docs/guide/architecture.md @@ -52,7 +52,7 @@ ObjectUI is organized as a PNPM monorepo with clear separation of concerns: - **Contains**: Schema validation, expression evaluation, registries - **Constraint**: No UI library dependencies, logic only - **Features**: - - Expression engine (`visible: "${data.age > 18}"`) + - Expression engine (`visible: "${record.age > 18}"`) - Schema registry and validation - Event system @@ -132,14 +132,19 @@ A backend system sends a JSON schema: The `SchemaRenderer` component: -1. Receives the schema + data context +1. Receives the schema, and reads the expression scope off the context above it 2. Evaluates expressions (`${user.name}`) 3. Looks up the component type in the registry 4. Recursively renders child schemas 5. Handles events and state updates +The scope does **not** arrive as a prop. `SchemaRenderer` declares exactly one prop, `schema`, +and forwards everything else it is handed to the component the schema names — so a `data={…}` +written on the element is neither read nor refused. The host publishes its values with +`PredicateScopeProvider`, and every key it publishes becomes a root the expressions can read: + ```tsx -import { SchemaRenderer } from '@object-ui/react' +import { PredicateScopeProvider, SchemaRenderer } from '@object-ui/react' import type { BaseSchema } from '@object-ui/types' // The schema from step 1, as the object the renderer receives. @@ -153,12 +158,25 @@ const schema: BaseSchema = { } function App() { - const data = { user: { name: 'Alice' } } + const scope = { user: { name: 'Alice' } } - return + return ( + + + + ) } ``` +An app built on `@object-ui/app-shell` does not mount this provider itself: the shell's +`ExpressionProvider` already feeds the same channel with the signed-in `user` (also readable as +`current_user`) and `features`. On top of what the host published, the renderer supplies +`record` — the row a record surface is bound to — and `page`, the page-local variables. + +⛔ `SchemaRendererProvider`'s `dataSource` is **not** an expression root. It carries the host's +`DataSource` *adapter*, the object data renderers call `find()` on; the two are different +channels on purpose. + ### 3. Component Registry Lookup The registry maps type strings to React components: @@ -244,11 +262,17 @@ ObjectUI includes a powerful expression engine for dynamic UIs: { "type": "button", "label": "Submit", - "visible": "${form.isValid && !form.isSubmitting}", - "disabled": "${form.isSubmitting}" + "visible": "${current_user.role === 'admin'}", + "disabled": "${record.status === 'locked'}" } ``` +`current_user` is the signed-in user the host's `ExpressionProvider` publishes; `record` is the +row a record surface is bound to, and is the only spelling a row field has — the bare shorthand +(`status`) and the wrong-layer `data.status` were both retired on runtime record surfaces +(objectui#5330 phase 2). A head name outside the scope is not refused: the predicate is +unevaluable, this surface fails soft, and the node is shown on every row. + A button's text key is `label`, and `text` is not a `ButtonSchema` key at all. Nothing refuses the misspelling either: `BaseSchema` is `.passthrough()`, so the validator KEEPS the unknown key, and the renderer — which reads `schema.label` — never looks at it. diff --git a/content/docs/guide/expressions.md b/content/docs/guide/expressions.md index b890795763..29d9819081 100644 --- a/content/docs/guide/expressions.md +++ b/content/docs/guide/expressions.md @@ -15,12 +15,13 @@ Expressions are JavaScript-like code snippets embedded in schemas using the `${} } ``` -With data: +With this published as the expression scope: ```tsx -const data = { user: { name: "Alice" } } +const scope = { user: { name: "Alice" } } ``` -This renders: **"Hello, Alice!"** +This renders: **"Hello, Alice!"** — `user` is a root because the host published a key by that +name. [Data Context](#data-context) below shows the provider that publishes one. ## Basic Syntax @@ -145,25 +146,44 @@ Disable component when expression is true: { "type": "button", "label": "Submit", - "disabledOn": "${form.submitting || !form.isValid}" + "disabledOn": "${record.status === 'submitted'}" } ``` ## Data Context -### Accessing Root Data +### Where the names come from -The root data object is available directly: +Expression scope does **not** arrive as a prop. `SchemaRenderer` declares exactly one prop, +`schema`, and forwards every other prop it is handed straight through to the component the +schema names — so a `data`, `dataSource` or `debug` written on the element is neither read nor +refused. Nothing throws, and there is one line on the console; the expression simply never +resolves, and an unresolvable template is returned as its own source text, so the characters +you typed are what the reader sees. - +The host publishes its values with `PredicateScopeProvider`, and every key it publishes becomes +a root: ```tsx -const data = { - user: { name: "Alice" }, - settings: { theme: "dark" } +import { PredicateScopeProvider, SchemaRenderer } from '@object-ui/react' +import type { BaseSchema } from '@object-ui/types' + +// The page schema — your own document. +declare const schema: BaseSchema + +// Every name here becomes a root the schema's expressions can read. +const scope = { + user: { name: 'Alice' }, + settings: { theme: 'dark' }, } - +function App() { + return ( + + + + ) +} ``` ```json @@ -173,6 +193,29 @@ const data = { } ``` +The scope the evaluator builds is what you published, plus two names the renderer supplies: + +| name | what it holds | +|---|---| +| every key of `scope` | exactly what you put there — `user`, `settings`, whatever the page needs | +| `record` | the row a record surface is bound to, when there is one | +| `page` | page-local variables, for predicates that gate on another component's state | + +`current_user` is an alias of whatever you published as `user`; an app built on +`@object-ui/app-shell` does not mount the provider itself, because the shell's +`ExpressionProvider` already feeds the same channel with the signed-in `user` and `features`. + +> **`dataSource` is not an expression root.** `SchemaRendererProvider`'s `dataSource` carries +> the host's `DataSource` *adapter* — the object renderers call `find()` on. The renderer used +> to publish that adapter under the name `data`; an adapter answers no `data.*` path, so the +> root was constant for every conformant host, and objectui#9308 removed it. A `${data.…}` +> expression now reads whatever *you* published under `data`, and nothing if you published +> none. **This changes the verdict of a gate already in the field**: `visible: "${data.x}"` +> used to resolve to `undefined` and HIDE its node on every row; with no `data` key at all it +> is unevaluable, this surface fails soft, and the node is SHOWN. At the runtime layer the row +> is `record` (ADR-0089 D3) — rewrite such a gate to `record.*` rather than re-publishing a +> `data` key. + ### Scoped Data Some components provide scoped data: @@ -420,6 +463,12 @@ or author each variant and gate it with a condition key: ## Form Expressions +A form's own values are the **row under edit**, and the row is bound as `record` and nothing +else — the bare shorthand (`country`) and the wrong-layer `data.country` were both retired on +runtime record surfaces (objectui#5330 phase 2), and `record` is the canonical runtime-layer +root (ADR-0089 D3). There is no `form` root: a predicate written against one is unevaluable, +and a fail-soft surface answers it by showing the field on every row. + ### Dependent Fields ```json @@ -436,7 +485,7 @@ or author each variant and gate it with a condition key: "type": "select", "name": "state", "label": "State/Province", - "visibleOn": "${form.country === 'USA'}", + "visibleOn": "${record.country === 'USA'}", "options": ["CA", "NY", "TX"] } ] @@ -467,7 +516,7 @@ evaluated on every component type: ```json { "type": "text", - "content": "Total: ${form.price * form.quantity}" + "content": "Total: ${record.price * record.quantity}" } ``` @@ -484,13 +533,13 @@ Expressions are re-evaluated when data changes. Avoid expensive operations: "content": "${users.map(u => expensiveOperation(u)).join(', ')}" } -// ✅ Good: Pre-compute in data +// ✅ Good: Pre-compute, and publish the result ``` ```tsx -const data = { +const scope = { processedUsers: users.map(u => expensiveOperation(u)) } ``` @@ -521,7 +570,8 @@ All expression outputs are automatically sanitized to prevent XSS attacks. ### Expression Errors -Invalid expressions show helpful error messages: +An expression that cannot be resolved is **not** an error the reader sees, and it is not the +same failure in both directions. Measured on the built evaluator: ```json { @@ -530,23 +580,32 @@ Invalid expressions show helpful error messages: } ``` -Error: "Cannot read property 'invalidProperty' of undefined" +| the scope | what the evaluator returns | +|---|---| +| `user` is published, `invalidProperty` is not a member of it | `undefined` — nothing is thrown | +| no `user` root at all | the template's own **source text**, and one line on the console | + +So a missing member renders as nothing, and a missing root renders as the characters you +typed. Neither raises, and neither stops the render — which is why the scope a page publishes +has to be stated rather than assumed. ### Debug Mode -Enable debug mode to see expression evaluation: +`debug` is read off the same provider context as `dataSource` +(`context?.debug || context?.debugFlags?.enabled`), never off the element — a `debug` written +on `SchemaRenderer` is forwarded to the component the schema names, exactly like a `data` prop, +and turns nothing on. Mount the provider instead: - + ```tsx - + + + ``` -This logs all expression evaluations to the console. +This logs all expression evaluations to the console. It is orthogonal to the expression scope: +wrap this pair in a `PredicateScopeProvider` as well when you want both. ## Advanced Usage @@ -567,12 +626,12 @@ const formatCurrency = (value: number) => evaluateExpression('${formatCurrency(price)}', { formatCurrency, price: 1234.5 }) ``` -That is the direct-evaluation path. A component expression rendered by -`SchemaRenderer` resolves against the scope the renderer itself builds — the -provider's data source (as `data`), the host scope (`user` / `current_user`) and -page variables — so a function you registered elsewhere is not reachable from a -schema expression. Compute the value before it reaches the schema, and bind the -result. +That is the direct-evaluation path, and `formatCurrency` is a root there because this call +hands the evaluator its own context. A component expression rendered by `SchemaRenderer` +resolves against a different scope — the names the host published through +`PredicateScopeProvider`, plus `record` and `page` — so a function you registered elsewhere is +not reachable from a schema expression. Compute the value before it reaches the schema, and +bind the result. Hold an evaluator when you want one context reused — construct it, then call `evaluate`: @@ -643,12 +702,12 @@ language's own. A membership test is written with the array method: ### 4. Use TypeScript -Define your data types: +Define the type of the scope you publish: - + ```tsx -interface UserData { +interface AppScope { user: { name: string role: 'admin' | 'user' @@ -656,8 +715,10 @@ interface UserData { } } -const data: UserData = { /* ... */ } - +const scope: AppScope = { /* ... */ } + + + ``` ## Next Steps