diff --git a/.changeset/27145-objectui-org-retired.md b/.changeset/27145-objectui-org-retired.md new file mode 100644 index 00000000000..03ffe1533fb --- /dev/null +++ b/.changeset/27145-objectui-org-retired.md @@ -0,0 +1,45 @@ +--- +'@objectstack/skills': patch +'@object-ui/console': patch +'@object-ui/auth': patch +'@object-ui/cli': patch +'@object-ui/collaboration': patch +'@object-ui/components': patch +'@object-ui/core': patch +'@object-ui/create-plugin': patch +'@object-ui/data-objectstack': patch +'@object-ui/fields': patch +'@object-ui/i18n': patch +'@object-ui/layout': patch +'@object-ui/mobile': patch +'@object-ui/permissions': patch +'@object-ui/plugin-calendar': patch +'@object-ui/plugin-charts': patch +'@object-ui/plugin-chatbot': patch +'@object-ui/plugin-dashboard': patch +'@object-ui/plugin-designer': patch +'@object-ui/plugin-detail': patch +'@object-ui/plugin-editor': patch +'@object-ui/plugin-form': patch +'@object-ui/plugin-gantt': patch +'@object-ui/plugin-grid': patch +'@object-ui/plugin-kanban': patch +'@object-ui/plugin-list': patch +'@object-ui/plugin-map': patch +'@object-ui/plugin-markdown': patch +'@object-ui/plugin-report': patch +'@object-ui/plugin-timeline': patch +'@object-ui/plugin-tree': patch +'@object-ui/plugin-view': patch +'@object-ui/providers': patch +'@object-ui/react': patch +'@object-ui/react-runtime': patch +'@object-ui/types': patch +--- + +docs(ui): package homepages and README links point at objectstack.ai/docs; objectui.org is retired + +Clause-②: no +Metadata-protocol: none + +The ObjectUI documentation site at objectui.org is retired. Its pages now live in the ObjectStack docs, mostly under [User Interface](https://objectstack.ai/docs/frontend). Every `@object-ui/*` package's `homepage` and README links now go to the matching page there, and the old objectui.org addresses answer with a permanent redirect to the same page. The `description` fields spell the product name "ObjectUI". The `@objectstack/skills` bundle's ObjectUI skill links to the same pages. Nothing else changes in what these packages ship. diff --git a/.changeset/config.json b/.changeset/config.json index 4fa89d13f9b..1d5187cc93b 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -122,6 +122,6 @@ "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": ["@object-ui/example-*", "@object-ui/site", "@object-ui/runner", "@object-ui/test-support"], + "ignore": ["@object-ui/example-*", "@object-ui/runner", "@object-ui/test-support"], "privatePackages": { "version": true, "tag": false } } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 142624004e5..77765efaea5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -451,7 +451,7 @@ jobs: # spelled it, exactly as the two narrower `skills/` entries were. - 'docs/**' # The objectui tree, the root files the ui jobs build from, and the - # three roots outside ui/ that the ui jobs' gates and tests read + # four roots outside ui/ that the ui jobs' gates and tests read # (objectstack#25712): # - every root workflow, not ci.yml alone: Lint UI's pre-install # import graph check scans all of `.github/workflows/` for ui @@ -464,7 +464,14 @@ jobs: # objectstack#26097. Lint UI's skill gates (skills-paths, # skill-examples, skill-eval-tokens, doc-json, # shell-escape-residue) and Test UI's skill readers find it - # through `ui/scripts/skill-bundle-root.mjs`. + # through `ui/scripts/skill-bundle-root.mjs`; + # - `content/docs/frontend/**`, the ObjectUI SDK pages since the + # objectui.org docs folded into this site. Lint UI's doc gates + # (doc-types, doc-snippets, doc-json, doc-fences, spec-symbols) + # and the Test UI tests that render a guide's example read them; + # - `content/docs/ui/pages.mdx`, where the per-element data + # binding section of the old data-source guide landed. Two Test + # UI pins read its fences and its block table (objectstack#27145). ui: - 'ui/**' - 'pnpm-lock.yaml' @@ -473,6 +480,8 @@ jobs: - '.github/workflows/**' - '.github/prompts/**' - 'skills/objectui/**' + - 'content/docs/frontend/**' + - 'content/docs/ui/pages.mdx' # turbo.json's `globalDependencies`: every build task hashes # them, so they move what the ui jobs build, the SDUI manifest's # regeneration included (#26137). @@ -3981,8 +3990,10 @@ jobs: # when the tree moved under `ui/` (it reads only this repository's # `.github/workflows/`), and which objectstack#25712 deleted with the # rest of `ui/.github/workflows/`. They judge what objectui ships - # — `ui/content/docs/**`, the skill bundle (`skills/objectui/**` at the - # repository root since objectstack#26097), the package READMEs and the + # — the ObjectUI SDK pages (`content/docs/frontend/**` since the + # objectui.org docs folded into this site), the skill bundle + # (`skills/objectui/**` at the repository root since objectstack#26097), + # the package READMEs and the # documented component types — so the change most likely to trip them is # a docs-only or skills-only PR, which the `filter` job's `ui` globs # include. @@ -3999,7 +4010,9 @@ jobs: # job's own restore and save serve the build below). Retired since: the # Action Ref Convention scan, which judged objectui's own workflows and # went with them (objectstack#25712); a repository-wide action-ref - # convention needs a home that no `ui` path filter skips (sub-issue 4). + # convention needs a home that no `ui` path filter skips (sub-issue 4); + # and the Doc Example Id and Docs Route Eager Closure checks, which + # judged the retired objectui.org site and went with it. - name: Control Byte Scan — Scan tracked text files for raw control bytes if: ${{ !cancelled() }} run: pnpm --dir ui run check:control-bytes @@ -4035,10 +4048,6 @@ jobs: pnpm --dir ui run check:doc-fences --self-test pnpm --dir ui run check:doc-fences - - name: Doc Example Id Check — Check documented example ids against the catalog registry - if: ${{ !cancelled() }} - run: pnpm --dir ui run check:doc-example-ids - - name: Doc Component Type Check — Check documented component types against the registry if: ${{ !cancelled() }} run: pnpm --dir ui run check:doc-types @@ -4059,10 +4068,6 @@ jobs: if: ${{ !cancelled() }} run: pnpm --dir ui run check:skill-eval-tokens - - name: Docs Route Eager Closure Check — Weigh the docs route eager closure - if: ${{ !cancelled() }} - run: pnpm --dir ui run check:docs-route-closure - - name: Install dependencies id: install if: ${{ !cancelled() }} diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index 9ea0c9d1ea1..e56cb1993b6 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -1,18 +1,12 @@ name: Docs Deploy -# The ONE path by which this repository's two docs sites reach production, -# one job each: +# The ONE path by which this repository's docs site reaches production: the +# `deploy` job, Vercel project `objectstack`, Root Directory `apps/docs`. # -# - `deploy`: the ObjectStack docs, Vercel project `objectstack`, Root -# Directory `apps/docs`; -# - `deploy-ui-site`: the Object UI docs at www.objectui.org, Vercel project -# `objectui`, Root Directory `ui/apps/site`. That site came here with the -# objectui tree; objectui's own copy of this workflow was deleted with the -# rest of `ui/.github/workflows/`, and nothing else deploys it. Its header -# below says what differs from `deploy`. -# -# Everything this header says applies to both jobs. The two are independent: -# one going red does not stop or cancel the other. +# The Object UI docs site (www.objectui.org) was retired when its pages folded +# into this one. Its old URLs answer host-scoped permanent +# redirects from `apps/docs/redirects.mjs` (`objectuiRedirects`) once the +# objectui.org domains point at this project; nothing deploys it any more. # # ## Why the build runs here and not on Vercel # @@ -217,139 +211,3 @@ jobs: echo echo "Deployment: ${{ steps.deploy.outputs.url || 'none (the run stopped before the deploy step finished)' }}" } >> "$GITHUB_STEP_SUMMARY" - - # ── The Object UI docs site (www.objectui.org) ───────────────────────────── - # - # The same build-here, upload-prebuilt path as `deploy` above, for the Vercel - # project `objectui`. `ui/apps/site/vercel.json` turns that project's Git - # deployments off (`git.deploymentEnabled: false`), so this job is the only - # thing that publishes the site, and every failure is red, as above. - # - # What differs from `deploy`, and why: - # - # - The project's Root Directory must read `ui/apps/site`. It read - # `apps/site` while the site lived in the objectui repository, and it is - # a dashboard setting that no file here can change. A step right after - # `vercel pull` reads the setting the pull wrote and goes red, naming the - # setting and its value, when it is anything else; without that step - # the old value fails later inside `vercel build`, as an error that does - # not say a dashboard setting is the cause. - # - `vercel build` gets no token. It runs `vercel.json`'s install and build - # commands, which run every workspace package's lifecycle scripts, and - # it does not call the Vercel API. Only the `pull` and `deploy` steps, - # which do, see VERCEL_TOKEN. objectui's own workflow made this choice - # for the same reason. - # - objectui's pinned-@objectstack restore and build steps are not ported. - # In this repository every @objectstack package the site imports is a - # workspace package, and `vercel.json`'s build command builds it from - # this tree through turbo's `^build`. - # - # The site's own install and build commands live in `ui/apps/site/vercel.json` - # and are unchanged from objectui: `pnpm i` installs this whole workspace from - # any member directory, and its build command's `cd ../..` lands in `ui/`, - # from which turbo resolves this repository's root and builds - # `@object-ui/site` with its full closure (65 tasks, all built, on the - # local run measured below). - deploy-ui-site: - name: Deploy objectui docs to Vercel (production) - runs-on: ubuntu-latest - # The build behind `vercel.json`'s command (the site, its @object-ui - # packages and their @objectstack closure: 65 turbo tasks, none cached) - # measured 596 s at `--concurrency=2` on a shared dev container; the runner - # adds install and upload. - timeout-minutes: 30 - env: - # Identifiers, not credentials, as in `deploy`: the same team, and the - # project that serves www.objectui.org. - VERCEL_ORG_ID: team_06nvlGKZCkwhzdXmRdxeCNnB - VERCEL_PROJECT_ID: prj_v1rXIczaI7YGmXmseQCLFUFOFxTB - # The same pin as `deploy`'s VERCEL_CLI_VERSION; move the two together. - VERCEL_CLI_VERSION: '60.1.3' - # The value the project's Root Directory must have; see the header. - VERCEL_ROOT_DIRECTORY: ui/apps/site - steps: - - name: Refuse any ref but main - if: github.ref != 'refs/heads/main' - run: | - echo "::error::Docs Deploy publishes to PRODUCTION and runs only from main; this run is on ${GITHUB_REF}. Re-run it with 'main' selected in the branch picker." - exit 1 - - - name: Require the Vercel token - env: - VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} - run: | - if [ -z "${VERCEL_TOKEN:-}" ]; then - echo "::error::The secret VERCEL_TOKEN is not visible to this run, so www.objectui.org cannot be deployed and production is NOT being updated. It is provided as an objectstack-ai ORGANIZATION secret: check that it still exists and that this repository is in its repository access list (org Settings -> Secrets and variables -> Actions). Then re-run this workflow." - exit 1 - fi - echo "VERCEL_TOKEN is set." - - - name: Checkout repository - uses: actions/checkout@v7 - - - name: Setup workspace - uses: ./.github/actions/setup-workspace - - # From the repository root, for the reason `deploy`'s comment on this - # step gives: the CLI applies the Root Directory relative to its cwd. - - name: Pull the production project settings - env: - VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} - run: npx --yes "vercel@${VERCEL_CLI_VERSION}" pull --yes --environment=production --token="$VERCEL_TOKEN" - - - name: Require the project's Root Directory to be ui/apps/site - run: | - set -euo pipefail - actual="$(node -e " - const fs = require('node:fs'); - const file = '.vercel/project.json'; - if (!fs.existsSync(file)) { console.log('(vercel pull wrote no ' + file + ')'); process.exit(0); } - const settings = JSON.parse(fs.readFileSync(file, 'utf8')).settings || {}; - console.log(settings.rootDirectory ?? '(not set)'); - ")" - if [ "$actual" != "$VERCEL_ROOT_DIRECTORY" ]; then - echo "::error::The Vercel project ${VERCEL_PROJECT_ID} has Root Directory '${actual}', and this repository builds the site from '${VERCEL_ROOT_DIRECTORY}'. Nothing is deployed. A maintainer sets it in the Vercel dashboard: project objectui -> Settings -> Build and Deployment -> Root Directory = ${VERCEL_ROOT_DIRECTORY}. Then re-run this workflow." - exit 1 - fi - echo "Root Directory: ${actual}" - - # No VERCEL_TOKEN here; see the header. TURBO_FORCE for the reason - # `deploy`'s build step gives. - - name: Build the site (vercel build --prod) - env: - TURBO_FORCE: 'true' - run: npx --yes "vercel@${VERCEL_CLI_VERSION}" build --prod - - - name: Assert the build produced a deployable output - run: | - set -euo pipefail - if [ ! -f ui/apps/site/.next/BUILD_ID ]; then - echo "::error file=ui/apps/site/vercel.json::vercel build exited 0 but produced no ui/apps/site/.next/BUILD_ID - the site was not built, so nothing is deployed." - exit 1 - fi - if [ ! -f .vercel/output/config.json ]; then - echo "::error::vercel build exited 0 but wrote no .vercel/output/config.json at the repository root - there is no prebuilt output to deploy." - exit 1 - fi - echo "Built the objectui site: BUILD_ID=$(cat ui/apps/site/.next/BUILD_ID)" - - - name: Deploy the prebuilt output to production - id: deploy - env: - VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} - run: | - set -euo pipefail - url=$(npx --yes "vercel@${VERCEL_CLI_VERSION}" deploy --prebuilt --prod --token="$VERCEL_TOKEN") - echo "url=${url}" >> "$GITHUB_OUTPUT" - echo "Deployed: ${url}" - - - name: Publish the run to the summary - if: always() - run: | - { - echo "### Docs Deploy (www.objectui.org) — ${{ job.status }}" - echo - echo "Commit: \`${{ github.sha }}\` (${{ github.event_name }})" - echo - echo "Deployment: ${{ steps.deploy.outputs.url || 'none (the run stopped before the deploy step finished)' }}" - } >> "$GITHUB_STEP_SUMMARY" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fa7514e4d0d..83bd90deb8f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -199,7 +199,7 @@ same list. | `content/docs/references/` | Generated from the Zod schemas by `packages/spec/scripts/build-docs.ts`. Never edit it by hand. | | `content/docs/releases/` | Written at release time from changesets. Never edit it in a code pull request. | | `examples/` | The example apps. The *Examples* table in the [package directory](#package-directory) describes each one. | -| `ui/` | ObjectUI: the `@object-ui/*` packages, the Console's source and the objectui.org docs site. [UI Workspace](#ui-workspace-ui) below covers working there. | +| `ui/` | ObjectUI: the `@object-ui/*` packages and the Console's source. Their guides live in `content/docs/frontend/`. [UI Workspace](#ui-workspace-ui) below covers working there. | | [ARCHITECTURE.md](./ARCHITECTURE.md) | Design details, the plugin lifecycle and the dependency graph. | AGENTS.md (*Documentation Guardrails*) has the rules for every docs path, including what a @@ -207,8 +207,8 @@ new page needs before it shows up in the navigation. ## UI Workspace (`ui/`) -`ui/` holds ObjectUI, the view layer: the `@object-ui/*` packages, the Console -(`ui/apps/console`) and the objectui.org docs site (`ui/apps/site`). It is a member of this +`ui/` holds ObjectUI, the view layer: the `@object-ui/*` packages and the Console +(`ui/apps/console`). It is a member of this workspace, so the root `pnpm install` above installs it too, and [ui/AGENTS.md](./ui/AGENTS.md) adds that tree's own rules to AGENTS.md: the spec-first and contract-first commandments, Shadcn-native styling, actions as data. Its code is MIT-licensed @@ -219,13 +219,12 @@ contract-first commandments, Shadcn-native styling, actions as data. Its code is Run these from `ui/`: ```bash -pnpm exec turbo run build --filter='./*/*' --filter='!@object-ui/site' # Build every ui package (turbo, parallel & cached) -pnpm exec turbo run type-check --filter='./*/*' # Run tsc --noEmit across the ui packages (turbo) -pnpm exec turbo run lint --filter='./*/*' # Run eslint across the ui packages (turbo) -pnpm dev # The Console's Vite dev server, on :5180 -pnpm site:dev # The docs site, on http://localhost:3000 -pnpm docs:check-links # The internal-link check CI runs on ui docs -pnpm test:e2e # End-to-end tests (playwright) +pnpm exec turbo run build --filter='./*/*' # Build every ui package (turbo, parallel & cached) +pnpm exec turbo run type-check --filter='./*/*' # Run tsc --noEmit across the ui packages (turbo) +pnpm exec turbo run lint --filter='./*/*' # Run eslint across the ui packages (turbo) +pnpm dev # The Console's Vite dev server, on :5180 +pnpm docs:check-links # The internal-link check CI runs on the ui docs and READMEs +pnpm test:e2e # End-to-end tests (playwright) ``` turbo resolves a relative filter against the directory it is launched from, so the filter above @@ -290,32 +289,31 @@ use as a few-shot corpus. Neither declares a `dev` script. | --- | --- | | `ui/packages/*` | The published `@object-ui/*` packages, plus the private `test-support` (test-only, never released), `runner` (workspace demo app, never released) and `app-shell` (the Console's shell and views, no longer published after 18.2.0) | | `ui/apps/console` | The Console app (Vite + React), which `pnpm console:build` builds into the `@objectstack/console` package | -| `ui/apps/site` | The public docs site at (fumadocs) | | `ui/examples/*` | The snippet and the schema catalog, see [UI examples](#ui-examples) | -| `ui/content/docs/` | The docs site's pages | -| `ui/docs/` | Internal engineering material (ADRs, audits); not part of the site | +| `ui/docs/` | Internal engineering material (ADRs, audits, design records); not part of the docs site | | `ui/e2e/` | Playwright end-to-end tests | The [package directory](#package-directory) lists every `@object-ui/*` package by layer. ### UI documentation -The docs site's pages live in `ui/content/docs/`, and a page's path below it becomes its -route under the site's /docs base, so an internal link between pages is written -`[Quick Start](/docs/guide/quick-start)`, with the prefix. `ui/docs/` is not part of the site. +ObjectUI's guides are pages of the documentation site, in its *Custom Front-end* section +([content/docs/frontend](./content/docs/frontend)), and follow the documentation rules above. +`ui/docs/` is not part of the site. - **A code block in an ADR or a dated audit is a specimen, not an example to copy.** Fence it - `plaintext`, never `ts` / `tsx`: a gate compiles the code fences of `ui/content/docs/`, and - a record edited until it compiles says something it never said. Code a reader may copy - belongs in `ui/content/docs/` or the skills tree. + `plaintext`, never `ts` / `tsx`: a gate compiles the code fences of the Custom Front-end + pages, and a record edited until it compiles says something it never said. Code a reader may + copy belongs in those pages or the skills tree. - **A `json` fence must pass a strict `JSON.parse`.** A block annotated with comments or trailing commas is fenced `jsonc`, and must still be one JSON document once those are removed; a `${…}` expression stays on one line inside its string. - **Internal links are checked offline.** `pnpm docs:check-links` resolves every relative - path and site route against the checkout, and CI runs it on every change. + path and site route on the Custom Front-end pages, the ui READMEs and `ui/docs/` against the + checkout, and CI runs it on every change. -ObjectUI's [Quick Start](./ui/content/docs/guide/quick-start.md) and -[Component Registry](./ui/content/docs/guide/component-registry.md) guides are the place to +ObjectUI's [Quick Start](./content/docs/frontend/quick-start.mdx) and +[Component Registry](./content/docs/frontend/component-registry.mdx) guides are the place to start when you add a component. ## Package Directory diff --git a/README.md b/README.md index bd90f698e82..8b12bb64304 100644 --- a/README.md +++ b/README.md @@ -251,10 +251,10 @@ designers) are lazy-loaded plugins, and every schema is a TypeScript type in | You want to… | Start here | Install | | :--- | :--- | :--- | -| Render a JSON schema inside your React app | [Quick Start](./ui/content/docs/guide/quick-start.md) | `npm install @object-ui/react @object-ui/components` | +| Render a JSON schema inside your React app | [Quick Start](https://objectstack.ai/docs/frontend/quick-start) | `npm install @object-ui/react @object-ui/components` | | Build a UI from a JSON file, no React code at all | [`@object-ui/cli`](./ui/packages/cli) | `npm install -g @object-ui/cli` | -The guides are published at [objectui.org](https://www.objectui.org); working on `ui/` itself +The guides are published under [Custom front-end](https://objectstack.ai/docs/frontend) on the docs site; working on `ui/` itself is covered in [CONTRIBUTING.md](./CONTRIBUTING.md#ui-workspace-ui), and its packages are listed in the [package directory](./CONTRIBUTING.md#package-directory). diff --git a/apps/docs/lib/site.ts b/apps/docs/lib/site.ts index 3698c4d98a3..0e10afd9f4b 100644 --- a/apps/docs/lib/site.ts +++ b/apps/docs/lib/site.ts @@ -1,3 +1,5 @@ +import { DOCS_SITE_ORIGIN } from '@/redirects.mjs'; + /** * Canonical identity of the documentation site — the origin it names itself by, * and the shared cover asset it presents itself with. @@ -11,9 +13,11 @@ * Deliberately NOT read from an environment variable. A preview deployment that * derived its own origin would emit canonical links and a sitemap pointing at the * preview host — precisely the duplicate-content signal a canonical link exists to - * suppress. One host, declared once, here. + * suppress. One host, declared once: in `redirects.mjs`, the dependency-free + * module `next.config.mjs` also reads, because the objectui.org redirects there + * answer with this same origin. */ -export const SITE_ORIGIN = 'https://objectstack.ai'; +export const SITE_ORIGIN = DOCS_SITE_ORIGIN; /** * Absolute URL for a **site-relative** path. @@ -25,8 +29,8 @@ export const SITE_ORIGIN = 'https://objectstack.ai'; * than reported. * * Next's `metadataBase` wants a `URL` rather than a string — write - * `new URL(SITE_ORIGIN)` there, so this file stays the only place the origin is - * spelled out. + * `new URL(SITE_ORIGIN)` there, so the origin is never spelled out a second + * time. */ export function absoluteUrl(path: string): string { if (!path.startsWith('/')) { diff --git a/apps/docs/redirects.mjs b/apps/docs/redirects.mjs index 687fa76fdf2..4ccd70d9532 100644 --- a/apps/docs/redirects.mjs +++ b/apps/docs/redirects.mjs @@ -212,11 +212,255 @@ export const blogRedirects = BLOG_PATH_PREFIXES.flatMap((prefix) => [ ...MOVED_POSTS.map((slug) => [`${prefix}/blog/${slug}`, `${PRODUCT_SITE_ORIGIN}/en/blog/${slug}/`]), ]); -/** Next.js redirects() entries derived from the two tables above. */ +/** + * This site's own origin, declared here for the same reason as + * `PRODUCT_SITE_ORIGIN`: `lib/site.ts` re-exports it as `SITE_ORIGIN` for the + * sitemap, `robots.txt` and the canonical links, and the cross-host redirects + * below name it, so the two can never name two hosts. + */ +export const DOCS_SITE_ORIGIN = 'https://objectstack.ai'; + +/** + * The hosts the retired ObjectUI docs site answered on. `has` values are + * anchored regular expressions in Next (`^…$`), matched against the request's + * hostname with any port removed. + */ +export const OBJECTUI_HOST_PATTERN = '(?:www\\.)?objectui\\.org'; + +/** + * www.objectui.org folded into this site: its pages moved to `/docs/frontend` + * (the ObjectUI SDK and its renderers), into the metadata pages they described, + * and into the generated component reference. Each row is an old objectui.org + * path and the path on THIS site that carries its content now. + * + * The rows are host-scoped (`has: [{ type: 'host' }]`, see `toNextRedirects`): + * they fire only for a request whose host is objectui.org or www.objectui.org, + * and answer a permanent redirect to the same path on `DOCS_SITE_ORIGIN`. So + * they are inert until those domains point at this Vercel project, and they + * can never shadow one of this site's own URLs: `/docs/plugins` on objectui.org + * is the old plugin gallery, `/docs/plugins` here is the plugins section. + * + * Every published path of the old site has a row: the 183 pages under its + * `content/docs` at the fold, by their URL. The final row catches everything + * else the site served (the home page, the playground, `llms.txt`, OG images) + * and sends it to the SDK section's landing page. First match wins, so it stays + * last. + * + * `scripts/check-docs-redirects.mjs` holds this table to the same rules as + * `docsRedirects`, judged on the host the destination is served from: every + * destination resolves to a real page under `content/docs`, and none is a + * source of `docsRedirects` (that would be a second hop). + */ +export const objectuiRedirects = [ + // The ObjectUI SDK guide and the renderer-type pages, now under /docs/frontend. + ['/docs', '/docs/frontend'], + ['/docs/guide/quick-start', '/docs/frontend/quick-start'], + ['/docs/guide/building-crud-app', '/docs/frontend/building-crud-app'], + ['/docs/guide/architecture-overview', '/docs/frontend/architecture-overview'], + ['/docs/guide/schema-overview', '/docs/frontend'], + ['/docs/guide/schema-rendering', '/docs/frontend/schema-rendering'], + ['/docs/guide/component-registry', '/docs/frontend/component-registry'], + ['/docs/guide/fields', '/docs/frontend/fields'], + ['/docs/guide/plugins', '/docs/frontend/plugins'], + ['/docs/guide/plugin-development', '/docs/frontend/plugin-development'], + ['/docs/guide/data-source', '/docs/frontend/data-source'], + ['/docs/guide/layout', '/docs/frontend/layout'], + ['/docs/guide/theming', '/docs/frontend/theming'], + ['/docs/guide/troubleshooting', '/docs/frontend/troubleshooting'], + ['/docs/core/schema-renderer', '/docs/frontend/schema-rendering'], + ['/docs/layout/app-shell', '/docs/frontend/app-shell'], + ['/docs/layout/sidebar-nav', '/docs/frontend/sidebar-nav'], + ['/docs/api/schema-reference', '/docs/ui/components'], + ['/docs/utilities/cli', '/docs/frontend/cli'], + ['/docs/utilities/create-plugin', '/docs/frontend/create-plugin'], + ['/docs/utilities/data-objectstack', '/docs/frontend/data-objectstack'], + ['/docs/plugins', '/docs/frontend/plugins'], + ['/docs/plugins/plugin-calendar', '/docs/frontend/plugins/plugin-calendar'], + ['/docs/plugins/plugin-charts', '/docs/frontend/plugins/plugin-charts'], + ['/docs/plugins/plugin-chatbot', '/docs/frontend/plugins/plugin-chatbot'], + ['/docs/plugins/plugin-dashboard', '/docs/frontend/plugins/plugin-dashboard'], + ['/docs/plugins/plugin-detail', '/docs/frontend/plugins/plugin-detail'], + ['/docs/plugins/plugin-editor', '/docs/frontend/plugins/plugin-editor'], + ['/docs/plugins/plugin-form', '/docs/frontend/plugins/plugin-form'], + ['/docs/plugins/plugin-gantt', '/docs/frontend/plugins/plugin-gantt'], + ['/docs/plugins/plugin-grid', '/docs/frontend/plugins/plugin-grid'], + ['/docs/plugins/plugin-kanban', '/docs/frontend/plugins/plugin-kanban'], + ['/docs/plugins/plugin-list', '/docs/frontend/plugins/plugin-list'], + ['/docs/plugins/plugin-map', '/docs/frontend/plugins/plugin-map'], + ['/docs/plugins/plugin-markdown', '/docs/frontend/plugins/plugin-markdown'], + ['/docs/plugins/plugin-report', '/docs/ui/reports'], + ['/docs/plugins/plugin-timeline', '/docs/frontend/plugins/plugin-timeline'], + ['/docs/plugins/plugin-view', '/docs/frontend/plugins/plugin-view'], + ['/docs/components/basic/button-group', '/docs/frontend/renderers#basic'], + ['/docs/components/basic/div', '/docs/frontend/renderers#basic'], + ['/docs/components/basic/navigation-menu', '/docs/frontend/renderers#basic'], + ['/docs/components/basic/pagination', '/docs/frontend/renderers#basic'], + ['/docs/components/basic/separator', '/docs/frontend/renderers#basic'], + ['/docs/components/basic/sidebar', '/docs/frontend/renderers#basic'], + ['/docs/components/complex/carousel', '/docs/frontend/renderers#complex'], + ['/docs/components/complex/data-table', '/docs/frontend/renderers#complex'], + ['/docs/components/complex/filter-builder', '/docs/frontend/renderers#complex'], + ['/docs/components/complex/filter-ui', '/docs/frontend/renderers#complex'], + ['/docs/components/complex/resizable', '/docs/frontend/renderers#complex'], + ['/docs/components/complex/scroll-area', '/docs/frontend/renderers#complex'], + ['/docs/components/complex/sort-ui', '/docs/frontend/renderers#complex'], + ['/docs/components/complex/view-switcher', '/docs/frontend/renderers#complex'], + ['/docs/components/data-display/avatar', '/docs/frontend/renderers#data-display'], + ['/docs/components/data-display/breadcrumb', '/docs/frontend/renderers#data-display'], + ['/docs/components/data-display/kbd', '/docs/frontend/renderers#data-display'], + ['/docs/components/data-display/list', '/docs/frontend/renderers#data-display'], + ['/docs/components/data-display/statistic', '/docs/frontend/renderers#data-display'], + ['/docs/components/data-display/tree-view', '/docs/frontend/renderers#data-display'], + ['/docs/components/disclosure/collapsible', '/docs/frontend/renderers#disclosure'], + ['/docs/components/disclosure/toggle-group', '/docs/frontend/renderers#disclosure'], + ['/docs/components/feedback/empty', '/docs/frontend/renderers#feedback'], + ['/docs/components/feedback/loading', '/docs/frontend/renderers#feedback'], + ['/docs/components/feedback/progress', '/docs/frontend/renderers#feedback'], + ['/docs/components/feedback/skeleton', '/docs/frontend/renderers#feedback'], + ['/docs/components/feedback/sonner', '/docs/frontend/renderers#feedback'], + ['/docs/components/feedback/spinner', '/docs/frontend/renderers#feedback'], + ['/docs/components/feedback/toast', '/docs/frontend/renderers#feedback'], + ['/docs/components/form/calendar', '/docs/frontend/renderers#form'], + ['/docs/components/form/checkbox', '/docs/frontend/renderers#form'], + ['/docs/components/form/combobox', '/docs/frontend/renderers#form'], + ['/docs/components/form/command', '/docs/frontend/renderers#form'], + ['/docs/components/form/date-picker', '/docs/frontend/renderers#form'], + ['/docs/components/form/file-upload', '/docs/frontend/renderers#form'], + ['/docs/components/form/form', '/docs/frontend/renderers#form'], + ['/docs/components/form/input-otp', '/docs/frontend/renderers#form'], + ['/docs/components/form/input', '/docs/frontend/renderers#form'], + ['/docs/components/form/radio-group', '/docs/frontend/renderers#form'], + ['/docs/components/form/select', '/docs/frontend/renderers#form'], + ['/docs/components/form/slider', '/docs/frontend/renderers#form'], + ['/docs/components/form/switch', '/docs/frontend/renderers#form'], + ['/docs/components/form/textarea', '/docs/frontend/renderers#form'], + ['/docs/components/form/toggle', '/docs/frontend/renderers#form'], + ['/docs/components/layout/aspect-ratio', '/docs/frontend/renderers#layout'], + ['/docs/components/layout/page', '/docs/frontend/renderers#layout'], + ['/docs/components/navigation/header-bar', '/docs/frontend/renderers#navigation'], + ['/docs/components/overlay/alert-dialog', '/docs/frontend/renderers#overlay'], + ['/docs/components/overlay/context-menu', '/docs/frontend/renderers#overlay'], + ['/docs/components/overlay/dialog', '/docs/frontend/renderers#overlay'], + ['/docs/components/overlay/drawer', '/docs/frontend/renderers#overlay'], + ['/docs/components/overlay/dropdown-menu', '/docs/frontend/renderers#overlay'], + ['/docs/components/overlay/hover-card', '/docs/frontend/renderers#overlay'], + ['/docs/components/overlay/menubar', '/docs/frontend/renderers#overlay'], + ['/docs/components/overlay/popover', '/docs/frontend/renderers#overlay'], + ['/docs/components/overlay/sheet', '/docs/frontend/renderers#overlay'], + ['/docs/components/overlay/tooltip', '/docs/frontend/renderers#overlay'], + // Pages merged into the metadata pages. The Console and Studio guides aim at + // where their facts went, never at the retired /docs/ui/{console,studio} + // pages, which redirect themselves (a chain). + ['/docs/fields/auto-number', '/docs/data-modeling/field-types#autonumber'], + ['/docs/fields/boolean', '/docs/data-modeling/field-types#boolean'], + ['/docs/fields/currency', '/docs/data-modeling/field-types#currency'], + ['/docs/fields/date', '/docs/data-modeling/field-types#date'], + ['/docs/fields/datetime', '/docs/data-modeling/field-types#datetime'], + ['/docs/fields/email', '/docs/data-modeling/field-types#email'], + ['/docs/fields/file', '/docs/data-modeling/field-types#media-types'], + ['/docs/fields/formula', '/docs/data-modeling/field-types#formula'], + ['/docs/fields/grid', '/docs/data-modeling/field-types#master_detail'], + ['/docs/fields/image', '/docs/data-modeling/field-types#image'], + ['/docs/fields/location', '/docs/data-modeling/field-types#location'], + ['/docs/fields/lookup', '/docs/data-modeling/field-types#lookup'], + ['/docs/fields/number', '/docs/data-modeling/field-types#number'], + ['/docs/fields/object', '/docs/data-modeling/field-types#json'], + ['/docs/fields/password', '/docs/data-modeling/field-types#password'], + ['/docs/fields/percent', '/docs/data-modeling/field-types#percent'], + ['/docs/fields/phone', '/docs/data-modeling/field-types#phone'], + ['/docs/fields/rich-text', '/docs/data-modeling/field-types#richtext'], + ['/docs/fields/select', '/docs/data-modeling/field-types#select'], + ['/docs/fields/summary', '/docs/data-modeling/field-types#summary'], + ['/docs/fields/text', '/docs/data-modeling/field-types#text'], + ['/docs/fields/textarea', '/docs/data-modeling/field-types#textarea'], + ['/docs/fields/time', '/docs/data-modeling/field-types#time'], + ['/docs/fields/url', '/docs/data-modeling/field-types#url'], + ['/docs/fields/user', '/docs/data-modeling/field-types#user'], + ['/docs/fields/vector', '/docs/data-modeling/field-types#vector'], + ['/docs/fields/widget-props', '/docs/data-modeling/field-types#how-the-console-renders-a-field'], + ['/docs/guide/console', '/docs/build-without-code'], + ['/docs/guide/metadata-diagnostics', '/docs/deployment/validating-metadata#metadata-diagnostics-on-load'], + ['/docs/guide/flow-designer', '/docs/automation/flows'], + ['/docs/guide/notifications', '/docs/build-without-code'], + ['/docs/guide/slotted-pages', '/docs/ui/pages#record-pages'], + ['/docs/guide/record-edit-modes', '/docs/ui/create-vs-edit-form#modal-or-page-editmode'], + ['/docs/guide/designing-app-navigation', '/docs/ui/apps#designing-the-navigation'], + ['/docs/guide/dashboard-filters', '/docs/ui/dashboards#filter-controls-and-options'], + ['/docs/guide/public-forms', '/docs/ui/forms#6-internal-forms-_consoleformsname'], + ['/docs/guide/user-state-persistence', '/docs/ui/views#a-users-list-state-never-writes-the-view'], + ['/docs/guide/expressions', '/docs/ui/pages#expression-holes-in-element-properties'], + ['/docs/guide/print-pages', '/docs/ui/print-pages#print-in-the-browser'], + ['/docs/guide/react-pages', '/docs/ui/react-pages'], + ['/docs/guide/agent-skills', '/docs/ai/skills#the-skill-catalog'], + ['/docs/core/app-schema', '/docs/ui/apps#action-navigation'], + ['/docs/core/report-schema', '/docs/ui/reports'], + ['/docs/core/enhanced-actions', '/docs/ui/actions#collect-input-and-shape-the-ux'], + ['/docs/core/theme-schema', '/docs/ui/apps#branding'], + ['/docs/layout/page-header', '/docs/ui/pages#the-page-header'], + // Component pages replaced by the generated component reference. + ['/docs/components', '/docs/ui/components'], + ['/docs/components/basic/icon', '/docs/ui/components/ui#icon'], + ['/docs/components/basic/image', '/docs/ui/components/ui#image'], + ['/docs/components/basic/span', '/docs/ui/components/html#span'], + ['/docs/components/basic/text', '/docs/ui/components/ui#text'], + ['/docs/components/complex/table', '/docs/ui/components/html#table'], + ['/docs/components/data-display/alert', '/docs/ui/components/ui#alert'], + ['/docs/components/data-display/badge', '/docs/ui/components/ui#badge'], + ['/docs/components/disclosure/accordion', '/docs/ui/components/ui#accordion'], + ['/docs/components/form/button', '/docs/ui/components/ui#button'], + ['/docs/components/form/label', '/docs/ui/components/html#label'], + ['/docs/components/layout/box', '/docs/ui/components/ui#box'], + ['/docs/components/layout/card', '/docs/ui/components/ui#card'], + ['/docs/components/layout/container', '/docs/ui/components/ui#container'], + ['/docs/components/layout/flex', '/docs/ui/components/ui#flex'], + ['/docs/components/layout/grid', '/docs/ui/components/ui#grid'], + ['/docs/components/layout/stack', '/docs/ui/components/ui#stack'], + ['/docs/components/layout/tabs', '/docs/ui/components/ui#tabs'], + // Pages deleted outright: the nearest section index. + ['/docs/guide', '/docs/frontend'], + ['/docs/guide/architecture', '/docs/frontend'], + ['/docs/guide/console-architecture', '/docs/frontend'], + ['/docs/guide/deployment', '/docs/frontend'], + ['/docs/guide/objectos-integration', '/docs/frontend'], + ['/docs/guide/release-notes', '/docs/frontend'], + ['/docs/guide/schema-catalog', '/docs/frontend'], + ['/docs/guide/schema-playground', '/docs/frontend'], + ['/docs/blocks', '/docs/frontend'], + ['/docs/blocks/authentication', '/docs/frontend'], + ['/docs/blocks/dashboard', '/docs/frontend'], + ['/docs/blocks/ecommerce', '/docs/frontend'], + ['/docs/blocks/forms', '/docs/frontend'], + ['/docs/blocks/marketing', '/docs/frontend'], + ['/docs/api', '/docs/frontend'], + ['/docs/utilities', '/docs/frontend'], + ['/docs/utilities/runner', '/docs/frontend'], + ['/docs/utilities/vscode-extension', '/docs/frontend'], + ['/docs/rfcs/0001-clipboard-paste', '/docs/frontend'], + ['/docs/plugins/plugin-ai', '/docs/frontend/plugins'], + ['/docs/plugins/plugin-designer', '/docs/frontend/plugins'], + ['/docs/plugins/plugin-calendar-view', '/docs/frontend/plugins/plugin-calendar'], + ['/docs/components/feedback/toaster', '/docs/frontend/renderers#feedback'], + ['/docs/components/layout/semantic', '/docs/frontend/renderers#layout'], + // Everything else the old site served — keep last. + ['/:path*', '/docs/frontend'], +]; + +/** Next.js redirects() entries derived from the three tables above. */ export function toNextRedirects() { - return [...docsRedirects, ...blogRedirects].map(([source, destination]) => ({ + // The host-scoped rows come first: a request on objectui.org must never fall + // through to a row written for this site's own paths. + const objectui = objectuiRedirects.map(([source, destination]) => ({ source, - destination, + destination: `${DOCS_SITE_ORIGIN}${destination}`, permanent: true, + has: [{ type: 'host', value: OBJECTUI_HOST_PATTERN }], })); + return [ + ...objectui, + ...[...docsRedirects, ...blogRedirects].map(([source, destination]) => ({ + source, + destination, + permanent: true, + })), + ]; } diff --git a/content/docs/frontend/architecture-overview.mdx b/content/docs/frontend/architecture-overview.mdx index 972bb7e75ed..37aa504d861 100644 --- a/content/docs/frontend/architecture-overview.mdx +++ b/content/docs/frontend/architecture-overview.mdx @@ -150,6 +150,7 @@ Plugins use `React.lazy()` wrapped by `LazyPluginLoader` (`packages/react/src/La Every plugin follows the same structure (see `packages/plugin-kanban/`, `packages/plugin-grid/`): +{/* doc-snippet: fragment — an excerpt of a plugin package's own index: `./KanbanImpl` is that package's sibling module and `Props` its local prop type, neither of which exists outside the plugin being described */} ```tsx // 1. Lazy-load the heavy implementation const LazyKanban = React.lazy(() => import('./KanbanImpl')); @@ -271,6 +272,7 @@ that dispatch. It owns the protocol (name-based action identity, record-id resolution, re-entrancy guard, the `/actions` response-envelope rule) and injects the three things core has no opinion about: +{/* doc-snippet: fragment — the three injected values are the reader's own (`myAuthenticatedFetch`, `currentObject`, `notifyDataChanged`), and the closing line is a bare `` tag whose remaining props are deliberately elided */} ```typescript import { createServerActionHandler } from '@object-ui/core'; diff --git a/content/docs/frontend/building-crud-app.mdx b/content/docs/frontend/building-crud-app.mdx index 29df2f7f807..1922dc76381 100644 --- a/content/docs/frontend/building-crud-app.mdx +++ b/content/docs/frontend/building-crud-app.mdx @@ -34,6 +34,7 @@ pnpm add -D tailwindcss @tailwindcss/vite Add Tailwind to your `vite.config.ts`: +{/* doc-snippet: fragment — a vite.config.ts for the reader's own project: @vitejs/plugin-react and @tailwindcss/vite are the reader's dependencies, not this repository's, so the imports cannot resolve here */} ```ts import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; @@ -209,6 +210,7 @@ Wire everything together in `src/App.tsx`. `SchemaRendererProvider` injects the data source once, and every `SchemaRenderer` beneath it renders its schema against that one adapter: +{/* doc-snippet: fragment — the reader's src/App.tsx; './setup' and './data/rest-data-source' are the project files created in Steps 2 and 4 */} ```tsx import './setup'; import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; @@ -262,6 +264,7 @@ resolved** panel naming itself and the object it was about to read. ObjectUI generates forms directly from your schema. Extend `App.tsx` with form state: +{/* doc-snippet: fragment — two lines to paste into the App component of Step 5 — the useState import and the surrounding function body are already there */} ```tsx const [showForm, setShowForm] = useState(false); const [editId, setEditId] = useState(null); @@ -272,6 +275,7 @@ Add a "New Task" button and handle row clicks to open the edit form: Both of these render inside the `SchemaRendererProvider` from Step 5, so neither carries a data source of its own: +{/* doc-snippet: fragment — JSX to place inside the Step 5 App component; showForm, editId and their setters are the state declared in the block above */} ```tsx string) { diff --git a/content/docs/frontend/component-registry.mdx b/content/docs/frontend/component-registry.mdx index b70d476f4ca..22fc944a3b3 100644 --- a/content/docs/frontend/component-registry.mdx +++ b/content/docs/frontend/component-registry.mdx @@ -88,6 +88,7 @@ Now you can use it in schemas: All registered components receive the schema as props: +{/* doc-snippet: fragment — continues the custom-component block above — `BaseSchema` and `MyComponentSchema` are declared there, and re-declaring them here would teach the reader to write the same interface twice (measured: TS2304 x3) */} ```tsx interface ComponentProps { // The complete schema object @@ -119,6 +120,7 @@ function MyRenderer(props: ComponentProps) { Register components with additional metadata: +{/* doc-snippet: fragment — continues the custom-component block above — `MyComponent` and the `ComponentRegistry` import come from it (measured: TS2304 x2). The `ComponentMeta` literal this block shows is type-checked on the complete example at the end of the page, which does compile */} ```tsx ComponentRegistry.register('my-component', MyComponent, { label: 'My Custom Component', @@ -144,6 +146,7 @@ of a react page's JSX scope — and does not stand in for the slot. Register components that load on demand: +{/* doc-snippet: fragment — `./HeavyComponent` is the reader's own module, so the dynamic import cannot resolve here (measured: TS2307 x1, plus TS2304 on the `ComponentRegistry` the block above imports) */} ```tsx // The loader runs the first time a schema asks for `heavy-component`. ComponentRegistry.registerLazy('heavy-component', () => import('./HeavyComponent')) @@ -153,6 +156,7 @@ ComponentRegistry.registerLazy('heavy-component', () => import('./HeavyComponent Override default components with your own: +{/* doc-snippet: fragment — `MyCustomButton` is the reader's own replacement component; there is nothing in this repo to import it from (measured: TS2304 x1) */} ```tsx import { ComponentRegistry } from '@object-ui/core' import { initializeComponents } from '@object-ui/components' @@ -309,6 +313,7 @@ Use kebab-case for component types: ### 4. Provide Meaningful Metadata +{/* doc-snippet: fragment — `RatingComponent` is the component built in the complete example at the end of this page, and the `ComponentRegistry` import comes with it (measured: TS2304 x2) */} ```tsx ComponentRegistry.register('rating', RatingComponent, { label: 'Star Rating', @@ -320,6 +325,7 @@ ComponentRegistry.register('rating', RatingComponent, { ### 5. Handle Missing Props Gracefully +{/* doc-snippet: fragment — continues the component-interface block above — `ComponentProps` is declared there and `MySchema` stands for whatever schema the reader's component takes (measured: TS2304 x2) */} ```tsx function MyComponent(props: ComponentProps) { const { schema } = props @@ -339,6 +345,7 @@ function MyComponent(props: ComponentProps) { Group related components into plugin packages: +{/* doc-snippet: fragment — the three chart components are files the reader is being told to write, so `./BarChart` / `./LineChart` / `./PieChart` cannot resolve here (measured: TS2307 x3) */} ```tsx // @my-org/objectui-plugin-charts import { ComponentRegistry } from '@object-ui/core' @@ -355,6 +362,7 @@ export function registerChartComponents() { Usage: +{/* doc-snippet: fragment — `@my-org/objectui-plugin-charts` is the package the reader has just been shown how to create, not one this repo publishes (measured: TS2307 x1) */} ```tsx import { initializeComponents } from '@object-ui/components' import '@object-ui/fields' diff --git a/content/docs/frontend/create-plugin.mdx b/content/docs/frontend/create-plugin.mdx index fc4bf623c7e..af6fc3a32b2 100644 --- a/content/docs/frontend/create-plugin.mdx +++ b/content/docs/frontend/create-plugin.mdx @@ -130,6 +130,7 @@ Advice for the plugin you are about to write, beyond whatever the scaffold hands third-party libraries are allowed to live, which makes it the layer that most needs a `React.lazy` boundary, so an app that never renders your component never pays for it: + {/* doc-snippet: fragment — the lazy boundary points at `./HeavyImpl`, a module in the READER's own plugin that cannot exist beside this page, and `React` is the UMD global a one-line excerpt does not import (measured: TS2307 x1, TS2686 x1). Adding an import would make the snippet about this repository rather than about the plugin the reader is scaffolding. */} ```tsx const HeavyImpl = React.lazy(() => import('./HeavyImpl')); ``` diff --git a/content/docs/frontend/data-objectstack.mdx b/content/docs/frontend/data-objectstack.mdx index 42a4e6fe500..9797cc522f5 100644 --- a/content/docs/frontend/data-objectstack.mdx +++ b/content/docs/frontend/data-objectstack.mdx @@ -112,6 +112,7 @@ implements `DataSource` — backed by an ObjectStack backend. **Config:** +{/* doc-snippet: fragment — a SIGNATURE excerpt of the factory: a `function` declaration with no body and an `ObjectStackAdapter` return type this reference block does not import (measured: TS2391 x1, TS2304 x1). ⚠️ This gate never compiles a DECLARED block, so nothing here re-verifies that this excerpt still agrees with the shipped `packages/data-objectstack/dist/index.d.ts` — and this reason may not claim it does (objectui#7505). Read the shipped declaration when this page matters to you; it has drifted twice, once in the return type (objectui#7503) and once in a config member (objectui#7505) */} ```typescript function createObjectStackAdapter(config: { /** ObjectStack server base URL */ @@ -252,6 +253,7 @@ A schema node's `dataSource` key is the spec's `ElementDataSource` (validated by `ElementDataSourceSchema` in `@objectstack/spec`) — plain JSON metadata, **not** the adapter object: +{/* doc-snippet: fragment — a SHAPE excerpt restating `@objectstack/spec`'s own `ElementDataSource` as a bare interface, so its `filter` member names the spec's `ViewFilterRule` without importing it (measured: TS2304 x1); the type is exported from `@objectstack/spec/ui`. ⚠️ This gate never compiles a DECLARED block, so nothing here re-verifies this excerpt against `ElementDataSourceSchema` in the installed `@objectstack/spec` — and this reason may not claim it does (objectui#7505). The schema is the authority on which members are required and whether it is strict; read it there */} ```typescript interface ElementDataSource { /** Object to query (required) */ @@ -342,6 +344,7 @@ passed to `createObjectStackAdapter` in code. Sourcing those values from the environment is your bundler's business (`process.env.*` under Node or webpack, `import.meta.env.*` under Vite): +{/* doc-snippet: fragment — `process` is a NODE global carried by the reader's own `@types/node`, which this gate's program does not load, and the paragraph above says so in words: sourcing config from the environment is the reader's bundler's business (measured: TS2591 x2) */} ```typescript import { createObjectStackAdapter } from '@object-ui/data-objectstack'; diff --git a/content/docs/frontend/data-source.mdx b/content/docs/frontend/data-source.mdx index df30b6ec48b..aa09544ec15 100644 --- a/content/docs/frontend/data-source.mdx +++ b/content/docs/frontend/data-source.mdx @@ -164,6 +164,7 @@ class UserDataSource implements DataSource { ObjectUI uses OData-style query keys for broad compatibility: +{/* doc-snippet: fragment — continues the adapter block above: dataSource is the adapter created there, and repeating its construction here would bury the query keys this section is about */} ```typescript await dataSource.find('users', { $select: ['id', 'name', 'email'], diff --git a/content/docs/frontend/layout.mdx b/content/docs/frontend/layout.mdx index 2b2b8418933..b7befdd6e10 100644 --- a/content/docs/frontend/layout.mdx +++ b/content/docs/frontend/layout.mdx @@ -38,6 +38,7 @@ your JSON pages *inside* it. `app-shell` is not a component key either — what ### Basic Usage +{/* doc-snippet: fragment — `lucide-react` supplies the `NavItem.icon` COMPONENTS this example is about. It is a dependency of ten workspace packages (`@object-ui/layout` among them) but not a root one, so the specifier does not resolve in this gate's program (measured: TS2307 x1), and `pageSchema` is the reader's own page JSON (TS2304 x1). Probed with the icon import shimmed and `pageSchema` typed `BaseSchema`: the `NavItem[]` literal and the `AppShell` / `SidebarNav` props underneath are clean — 0 diagnostics */} ```tsx import { AppShell, SidebarNav, type NavItem } from '@object-ui/layout'; import { SchemaRenderer } from '@object-ui/react'; @@ -246,6 +247,7 @@ renders a button with no text. ### Schema API +{/* doc-snippet: fragment — a SHAPE excerpt, not an expression — the keys carry `?` optional markers and trailing prose comments, so the object literal cannot parse as TypeScript (measured: TS1109 / TS1005 / TS1011) */} ```typescript { type: 'page', @@ -349,6 +351,7 @@ React, or declare it as the app's `navigation` metadata when the tree has to com `SidebarNav` renders a Shadcn `Sidebar`, so it must be inside a `SidebarProvider` — `AppShell` supplies one. Its rows are `NavLink`s, so it also needs a router above it. +{/* doc-snippet: fragment — `lucide-react` supplies the `NavItem.icon` COMPONENTS this example is about. It is a dependency of ten workspace packages (`@object-ui/layout` among them) but not a root one, so the specifier does not resolve in this gate's program (measured: TS2307 x1), and `pageSchema` is the reader's own page JSON (TS2304 x1). Probed with the icon import shimmed and `pageSchema` typed `BaseSchema`: the `NavItem[]` literal and the `AppShell` / `SidebarNav` props underneath are clean — 0 diagnostics */} ```tsx import { AppShell, SidebarNav, type NavItem } from '@object-ui/layout'; import { SchemaRenderer } from '@object-ui/react'; @@ -453,6 +456,7 @@ renders the same "Unknown component type" panel. The shell is React; the page inside it is your JSON. +{/* doc-snippet: fragment — `lucide-react` supplies the `NavItem.icon` COMPONENTS this example is about. It is a dependency of ten workspace packages (`@object-ui/layout` among them) but not a root one, so the specifier does not resolve in this gate's program (measured: TS2307 x1), and `pageSchema` is the reader's own page JSON (TS2304 x1). Probed with the icon import shimmed and `pageSchema` typed `BaseSchema`: the `NavItem[]` literal and the `AppShell` / `SidebarNav` props underneath are clean — 0 diagnostics */} ```tsx import { AppShell, SidebarNav, type NavItem } from '@object-ui/layout'; import { SchemaRenderer } from '@object-ui/react'; @@ -488,6 +492,7 @@ and `defaultOpen` is the shell's own initial-state prop. Omit `sidebar` and the content fills the width under the top bar. +{/* doc-snippet: fragment — a JSX excerpt showing one prop — `AppShell`, `SchemaRenderer` and `landingSchema` all come from the Basic Usage block above, and restating its five setup lines here would bury the one line the section is about (measured: TS2304 x3) */} ```tsx Welcome}> @@ -575,7 +580,7 @@ block's `properties.title`, not on the page. "properties": { "name": "delete_record", "label": "Delete", - "variant": "destructive", + "variant": "danger", "icon": "trash", "actionType": "deleteRecord" } @@ -666,6 +671,7 @@ them reads `lg` (1024px), so 800px and 1400px get the same layout. Add Tailwind classes to layout components: +{/* doc-snippet: fragment — a JSX excerpt showing the `className` / `navbar` / `sidebar` slots — `AppShell`, `SidebarNav`, `SchemaRenderer`, `navItems` and `pageSchema` all come from the blocks above (measured: TS2304 x6) */} ```tsx }> @@ -810,6 +817,7 @@ Group related items with `NavGroup`. Pass groups instead of a flat list and `Sid labels each section and draws the separator between them itself — there is no `divider` item, and a row is either a link or a group, never both: +{/* doc-snippet: fragment — `lucide-react` supplies the `NavItem.icon` COMPONENTS this example is about. It is a dependency of ten workspace packages (`@object-ui/layout` among them) but not a root one, so the specifier does not resolve in this gate's program (measured: TS2307 x1). Probed with the icon import shimmed: the `NavGroup[]` literal and the `SidebarNav` props underneath are clean — 0 diagnostics */} ```tsx import { SidebarNav, type NavGroup } from '@object-ui/layout'; import { DollarSign, Home, Settings } from 'lucide-react'; diff --git a/content/docs/frontend/plugin-development.mdx b/content/docs/frontend/plugin-development.mdx index b22dc09faac..71014186a44 100644 --- a/content/docs/frontend/plugin-development.mdx +++ b/content/docs/frontend/plugin-development.mdx @@ -98,6 +98,7 @@ export interface BoardProps { ### 2. Build the Implementation +{/* doc-snippet: fragment — step 2 of the tutorial: this is the plugin package's own src/BoardImpl.tsx and it imports ./types, the sibling module step 1 tells the reader to write, so it cannot resolve in isolation */} ```tsx // src/BoardImpl.tsx import React from 'react'; @@ -136,6 +137,7 @@ export default function BoardImpl({ schema, className }: BoardProps) { ### 3. Create the Entry Point +{/* doc-snippet: fragment — step 3 of the tutorial: the plugin package's own src/index.tsx, importing ./BoardImpl and ./types — both are files the reader created in steps 1 and 2 */} ```tsx // src/index.tsx import React, { Suspense } from 'react'; @@ -274,6 +276,7 @@ grids, reports). That is precisely why a **field widget** needs an adapter when it is rendered from a schema node instead of from a form. Wrap it once, at registration, and the widget only ever implements one contract: +{/* doc-snippet: fragment — registration excerpt: ColorPickerField is the widget the reader writes in the next section, shown here first so the registration call reads in one piece */} ```tsx import { ComponentRegistry } from '@object-ui/core'; import { withFieldCarrier } from '@object-ui/fields'; @@ -357,6 +360,7 @@ export function ColorPickerField({ Register it as a field widget: +{/* doc-snippet: fragment — the plugin package's own src/index.tsx again, importing ./ColorPickerField — the file written in the block immediately above */} ```tsx // src/index.tsx import { ComponentRegistry } from '@object-ui/core'; @@ -382,6 +386,7 @@ export { ColorPickerField }; Namespaces prevent type collisions between plugins: +{/* doc-snippet: fragment — continues the board example: BoardRenderer is the component defined in step 3's src/index.tsx, not re-declared here */} ```tsx import { ComponentRegistry } from '@object-ui/core'; @@ -413,6 +418,7 @@ ComponentRegistry.getNamespaceComponents('plugin-board'); // RegistryCompone Define your schema interface in `types.ts` and extend `BaseSchema`: +{/* doc-snippet: fragment — abridged restatement of step 1's src/types.ts — BoardColumn and BoardItem are the interfaces declared alongside it there, elided to keep the extends BaseSchema line in focus */} ```typescript import type { BaseSchema } from '@object-ui/types'; @@ -427,6 +433,7 @@ Declare `ComponentInput` entries when registering: they are what the published m `of` is the coarse kind of an `array`'s elements, or of the values of an `object` used as a map. Declare it whenever the contract admits exactly one kind there: `type: 'array'` alone says a value is a list and stops, so a member of the wrong shape reaches the renderer with nothing anywhere reporting it. With `of` declared, the parser reports a member no declared kind accepts, and the generated `sdui-intrinsics.d.ts` types the elements (`string[]` rather than `unknown[]`). Like `type`, it names a KIND and never a domain: `of: 'object'` says the elements are objects, not which keys they carry — spell that out in `description`, and let `os validate` judge the values. A member contract that genuinely accepts several kinds takes the array form (`of: ['string', 'object']`), or no `of` at all: +{/* doc-snippet: fragment — continues the board example: ComponentRegistry and BoardRenderer both come from step 3's src/index.tsx; this block shows only the inputs metadata */} ```tsx ComponentRegistry.register('board', BoardRenderer, { inputs: [ @@ -446,6 +453,7 @@ ComponentRegistry.register('board', BoardRenderer, { ObjectUI uses **Vitest + React Testing Library**. Place tests next to the implementation. +{/* doc-snippet: fragment — the plugin package's own src/BoardImpl.test.tsx, importing ./BoardImpl — the implementation the reader wrote in step 2 */} ```tsx // src/BoardImpl.test.tsx import { describe, it, expect } from 'vitest'; @@ -529,6 +537,7 @@ npm publish --access public pnpm add @object-ui/plugin-board ``` +{/* doc-snippet: fragment — consumer-side excerpt: @object-ui/plugin-board is the package the reader has just been taught to build and publish, so it does not resolve from this repo */} ```tsx // app/main.tsx — import once, auto-registers import '@object-ui/plugin-board'; diff --git a/content/docs/frontend/plugins/index.mdx b/content/docs/frontend/plugins/index.mdx index 47daa51ba00..aff53f7650e 100644 --- a/content/docs/frontend/plugins/index.mdx +++ b/content/docs/frontend/plugins/index.mdx @@ -241,6 +241,7 @@ Add a line only for the plugins you install. The other `@object-ui/plugin-*` pac Plugins use React's `lazy()` and `Suspense` to load heavy dependencies on-demand: +{/* doc-snippet: fragment — the inside of a plugin package: `./MonacoImpl` is that package's own sibling module, which exists only within the plugin being described and cannot resolve from here */} ```tsx // The plugin structure import React, { Suspense } from 'react' @@ -286,6 +287,7 @@ Without lazy loading, all this code would be in your main bundle! Plugins automatically register their components when imported: +{/* doc-snippet: fragment — continues the plugin-structure block above: `CodeEditorRenderer` is the component defined there, and repeating its definition here would bury the one line this section is about */} ```typescript // In the plugin's index.tsx import { ComponentRegistry } from '@object-ui/core' @@ -322,6 +324,7 @@ cd packages/plugin-myfeature ### 2. Create Heavy Implementation +{/* doc-snippet: fragment — the reader's new package importing its own heavy dependency; 'heavy-library' is a placeholder name, not an installed module */} ```tsx // src/MyFeatureImpl.tsx import HeavyLibrary from 'heavy-library' @@ -333,6 +336,7 @@ export default function MyFeatureImpl(props) { ### 3. Create Lazy Wrapper +{/* doc-snippet: fragment — the reader's new src/index.tsx; './MyFeatureImpl' is the sibling file created in the previous step */} ```tsx // src/index.tsx import React, { Suspense } from 'react' @@ -372,6 +376,7 @@ export interface MyFeatureSchema extends BaseSchema { ### 5. Configure Build +{/* doc-snippet: fragment — a vite.config.ts for the reader's plugin package; '@vitejs/plugin-react' is that package's devDependency, not this repo's */} ```typescript // vite.config.ts import { defineConfig } from 'vite' @@ -458,6 +463,7 @@ Heavy imports go in the `*Impl.tsx` file. Always show a meaningful skeleton while loading: +{/* doc-snippet: fragment — a bare JSX excerpt showing the Suspense wrapper shape; Suspense, Skeleton, LazyComponent and props all come from the surrounding component */} ```tsx @@ -470,6 +476,7 @@ Always show a meaningful skeleton while loading: Make your plugin type-safe: +{/* doc-snippet: fragment — re-export excerpt from the reader's package; './types' is the file created in step 4 */} ```typescript export type { MyFeatureSchema } from './types' ``` @@ -512,6 +519,7 @@ ls -lh dist/ Check that you imported it in your app: +{/* doc-snippet: fragment — the app-side import of '@object-ui/plugin-myfeature', the package this guide teaches the reader to publish */} ```typescript import '@object-ui/plugin-myfeature' ``` @@ -520,6 +528,7 @@ import '@object-ui/plugin-myfeature' Make sure types are exported: +{/* doc-snippet: fragment — re-export from '@object-ui/plugin-myfeature', the reader's own published package */} ```typescript export type { MyFeatureSchema } from '@object-ui/plugin-myfeature' ``` @@ -537,6 +546,7 @@ Check that the implementation is in a separate file: Check that ComponentRegistry.register() is called at the module level: +{/* doc-snippet: fragment — a good/bad contrast pair; ComponentRegistry and MyFeatureRenderer are the ambient names of the plugin index.tsx being discussed */} ```typescript // ✅ Good - runs on import ComponentRegistry.register('my-feature', MyFeatureRenderer) diff --git a/content/docs/frontend/plugins/plugin-calendar.mdx b/content/docs/frontend/plugins/plugin-calendar.mdx index 199a81bde8c..87a472b5ce8 100644 --- a/content/docs/frontend/plugins/plugin-calendar.mdx +++ b/content/docs/frontend/plugins/plugin-calendar.mdx @@ -270,6 +270,7 @@ dispatches the standard create action — `{ type: 'create', payload: {} }` — the same action channel every other `calendar-view` gesture uses, so the host decides what a "create" means (open a form, navigate, call an API). +{/* doc-snippet: fragment — a SHAPE excerpt continuing the "CalendarView Usage" block above, which is where the event array is written inline: `events` is the READER's own data and is not defined in this block (measured: TS2552 x1) */} ```tsx const schema = { type: 'calendar-view', @@ -554,6 +555,7 @@ The `@object-ui/plugin-calendar-view` package has been merged into this package. npm install @object-ui/plugin-calendar-view ``` +{/* doc-snippet: fragment — a migration guide's "Before" block, quoting the RETIRED `@object-ui/plugin-calendar-view` package on purpose: it was merged into `@object-ui/plugin-calendar` and no longer exists, so the specifier resolves nowhere and must not compile (measured: TS2882 x1, TS2307 x1). Correct documentation about code that is gone */} ```tsx import '@object-ui/plugin-calendar-view' import { CalendarView } from '@object-ui/plugin-calendar-view' diff --git a/content/docs/frontend/plugins/plugin-charts.mdx b/content/docs/frontend/plugins/plugin-charts.mdx index be3e42a440a..fffa07c4cd5 100644 --- a/content/docs/frontend/plugins/plugin-charts.mdx +++ b/content/docs/frontend/plugins/plugin-charts.mdx @@ -271,6 +271,7 @@ Without lazy loading, this would add 541 KB to your main bundle. With lazy loadi ### Custom Colors +{/* doc-snippet: fragment — shape excerpt; `salesData` is the reader's own rows */} ```tsx const schema = { type: 'bar-chart', @@ -282,6 +283,7 @@ const schema = { ### Responsive Height +{/* doc-snippet: fragment — shape excerpt; `metricsData` is the reader's own rows */} ```tsx const schema = { type: 'chart', diff --git a/content/docs/frontend/plugins/plugin-map.mdx b/content/docs/frontend/plugins/plugin-map.mdx index b2c6e4e95ee..2fed2aaf51e 100644 --- a/content/docs/frontend/plugins/plugin-map.mdx +++ b/content/docs/frontend/plugins/plugin-map.mdx @@ -236,6 +236,7 @@ export function ClickableMap() { ### Separate Fields +{/* doc-snippet: fragment — a SHAPE excerpt of one of the READER's own data records, not an expression: a bare object literal at statement position parses as a block with labels (measured: TS1005 x3). No ObjectUI type describes it — these are the caller's own row fields, named by `map.latitudeField` / `map.longitudeField` above */} ```tsx { id: 1, @@ -247,6 +248,7 @@ export function ClickableMap() { ### Combined String +{/* doc-snippet: fragment — a SHAPE excerpt of one of the READER's own data records, not an expression: a bare object literal at statement position parses as a block with labels (measured: TS1005 x2). No ObjectUI type describes it — `coordinates` is the caller's own field, named by `map.locationField` above */} ```tsx { id: 1, @@ -257,6 +259,7 @@ export function ClickableMap() { ### Object Format +{/* doc-snippet: fragment — a SHAPE excerpt of one of the READER's own data records, not an expression: a bare object literal at statement position parses as a block with labels (measured: TS1005 x3). No ObjectUI type describes it — `location` is the caller's own field, named by `map.locationField` above */} ```tsx { id: 1, diff --git a/content/docs/frontend/plugins/plugin-view.mdx b/content/docs/frontend/plugins/plugin-view.mdx index d6d5c17694a..551d485814a 100644 --- a/content/docs/frontend/plugins/plugin-view.mdx +++ b/content/docs/frontend/plugins/plugin-view.mdx @@ -474,6 +474,7 @@ row — there is no `recordId` to author, because the click chooses the record. `navigation.mode` decides how it opens, and `onNavigate` is what hands a page-mode record off to your router: +{/* doc-snippet: fragment — the navigation callback hands off to the host's own router */} ```typescript import type { ObjectViewSchema } from '@object-ui/types'; diff --git a/content/docs/frontend/quick-start.mdx b/content/docs/frontend/quick-start.mdx index f01aab7ef4b..cb75d9650e6 100644 --- a/content/docs/frontend/quick-start.mdx +++ b/content/docs/frontend/quick-start.mdx @@ -37,6 +37,7 @@ pnpm add -D tailwindcss @tailwindcss/vite Add Tailwind to your `vite.config.ts`: +{/* doc-snippet: fragment — a vite.config.ts for the reader's own project: @vitejs/plugin-react and @tailwindcss/vite are the reader's dependencies, not this repository's, so the imports cannot resolve here */} ```ts import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; diff --git a/content/docs/frontend/schema-rendering.mdx b/content/docs/frontend/schema-rendering.mdx index bc9f22e74d7..80a751a5251 100644 --- a/content/docs/frontend/schema-rendering.mdx +++ b/content/docs/frontend/schema-rendering.mdx @@ -145,6 +145,7 @@ Use expression syntax `${}` to reference the scope, by the name you published it The schema renderer uses a component registry to map schema types to React components: +{/* doc-snippet: fragment — MyComponent is the reader's own React component, named here to show what register() takes as its second argument */} ```tsx import { ComponentRegistry } from '@object-ui/core' @@ -294,6 +295,7 @@ author it as a literal. Components can emit events that you handle in React: +{/* doc-snippet: fragment — prop excerpt: the JSX call is shown alone to isolate onAction and onSubmit; schema and SchemaRenderer come from the first example on this page */} ```tsx - {themes.map((t) => ( - - ))} - - ); -} -``` - -`useTheme()` throws outside a provider; `useOptionalTheme()` returns `null` instead. - -### ThemeProvider Props - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `themes` | `Theme[]` | `[]` | Available theme documents | -| `defaultTheme` | `string` | first theme's `name` | Initially active theme | -| `defaultMode` | `ThemeMode` | `'auto'` | Initial mode | -| `persist` | `boolean` | `false` | Persist theme + mode to `localStorage` | -| `storageKey` | `string` | `'objectui-theme'` | `localStorage` key prefix (stored as `-name` / `-mode`) | -| `target` | `HTMLElement \| null` | `document.documentElement` | Element receiving the CSS variables and mode class | - -Persistence is a **provider** concern — there is no `persistPreference` or `storageKey` key on the -theme document itself. - -## Theme Properties - -| Property | Type | Required | Description | -|----------|------|----------|-------------| -| `name` | `string` | yes | Unique theme identifier | -| `label` | `string` | yes | Human-readable display name | -| `description` | `string` | no | Optional description | -| `mode` | `'light' \| 'dark' \| 'auto'` | no | Display mode; absence means `'auto'` | -| `colors` | `ColorPalette` | yes | Color palette — the only required token group | -| `typography` | `{ fontFamily?: { base?: string } }` | no | Only `fontFamily.base` is live (see [Typography](#typography)) | -| `borderRadius` | scale object | no | Rounded-corner scale (see [Border Radius](#border-radius)) | -| `shadows` | scale object | no | Box-shadow scale (see [Shadows](#shadows)) | -| `customVars` | `Record` | no | Emitted verbatim as `--: ` | -| `extends` | `string` | no | Name of a theme to inherit from | - -## Theme Modes - -`ThemeMode` is `'light' | 'dark' | 'auto'` — **there is no `'system'` member**; the OS-following -mode is spelled `'auto'`. The vocabulary is also exported as a runtime tuple: - -```ts -import { THEME_MODES, type ThemeMode } from '@object-ui/types'; - -const mode: ThemeMode = 'auto'; -console.log(mode, THEME_MODES); // auto ['auto', 'light', 'dark'] -``` - -With `'auto'`, `ThemeProvider` resolves the effective mode from `prefers-color-scheme` and -re-resolves live when the OS preference changes. The resolved mode is applied as a `light` / -`dark` class on the target element, so Tailwind `dark:` variants respond to it. - -A theme document carries a **single `colors` map**, not per-mode palettes: the same variables are -injected in both modes. For a palette that differs between light and dark, author two theme -documents — typically a dark variant that `extends` the light one (see -[Theme Inheritance](#theme-inheritance)) — and switch between them with `setTheme`. - -## Color Palette - -`colors.primary` is required; every other key is optional. Keys are emitted as the Shadcn CSS -variables ObjectUI components already consume: - -| Key | Required | CSS variable | -|-----|----------|--------------| -| `primary` | yes | `--primary` | -| `secondary` | | `--secondary` | -| `accent` | | `--accent` | -| `success` | | `--success` | -| `warning` | | `--warning` | -| `error` | | `--destructive` | -| `info` | | `--info` | -| `background` | | `--background` | -| `surface` | | `--card` | -| `text` | | `--foreground` | -| `textSecondary` | | `--muted-foreground` | -| `border` | | `--border` | -| `disabled` | | `--muted` | -| `primaryLight` | | `--primary-light` | -| `primaryDark` | | `--primary-dark` | -| `secondaryLight` | | `--secondary-light` | -| `secondaryDark` | | `--secondary-dark` | - -Hex values (`#3b82f6`) are converted to the `H S% L%` channel format Shadcn variables expect; any -other CSS color syntax (`rgb(...)`, `hsl(...)`, `oklch(...)`) passes through unchanged. - -```ts -import type { ColorPalette } from '@object-ui/types'; - -const colors: ColorPalette = { - primary: '#3b82f6', - secondary: '#64748b', - accent: '#8b5cf6', - error: '#ef4444', - background: '#ffffff', - surface: '#f8fafc', - text: '#0f172a', - textSecondary: '#64748b', - border: '#e2e8f0', -}; -``` - -## Typography - -`typography.fontFamily.base` is the only live typography key — it is emitted as `--font-sans`: - -```ts -import type { Theme } from '@object-ui/types'; - -const branded: Theme = { - name: 'branded', - label: 'Branded', - colors: { primary: '#3b82f6' }, - typography: { - fontFamily: { base: 'Inter, system-ui, sans-serif' }, - }, - customVars: { - 'font-size-base': '16px', - 'line-height-base': '1.5', - }, -}; -``` - -The former typography scales (`fontSize`, `fontWeight`, `lineHeight`, `letterSpacing`, -`fontFamily.heading`, `fontFamily.mono`) were retired upstream (objectstack#5021): a -theme declaring them is **refused, not accepted-and-stripped**. `customVars` -is the declared replacement — an entry is emitted verbatim, so -`customVars: { 'font-size-lg': '1.125rem' }` puts the same `--font-size-lg` on the document that -the retired scale used to. The retired keys are typed `never`, which makes the refusal a -compile-time error: - -```ts -import type { Theme } from '@object-ui/types'; - -const legacy: Theme = { - name: 'legacy', - label: 'Legacy', - colors: { primary: '#3b82f6' }, - typography: { - // @ts-expect-error -- `fontSize` was retired (objectstack#5021); author `customVars` instead - fontSize: 16, - }, -}; -``` - -## Border Radius - -The key is `borderRadius` (not `radius`), and the middle step is `base` (not `default`): - -```ts -import type { Theme } from '@object-ui/types'; - -const rounded: Theme = { - name: 'rounded', - label: 'Rounded', - colors: { primary: '#3b82f6' }, - borderRadius: { - sm: '0.25rem', - base: '0.5rem', - md: '0.75rem', - lg: '1rem', - xl: '1.5rem', - }, -}; -``` - -| Key | CSS variable | -|-----|--------------| -| `none` | `--radius-none` | -| `sm` | `--radius-sm` | -| `base` | `--radius` | -| `md` | `--radius-md` | -| `lg` | `--radius-lg` | -| `xl` | `--radius-xl` | -| `2xl` | `--radius-2xl` | -| `full` | `--radius-full` | - -## Shadows - -The `shadows` scale has the same shape, with `inner` in place of `full`: - -| Key | CSS variable | -|-----|--------------| -| `none` | `--shadow-none` | -| `sm` | `--shadow-sm` | -| `base` | `--shadow` | -| `md` | `--shadow-md` | -| `lg` | `--shadow-lg` | -| `xl` | `--shadow-xl` | -| `2xl` | `--shadow-2xl` | -| `inner` | `--shadow-inner` | - -## Custom Variables - -`customVars` entries are emitted verbatim onto the target element as `--: ` (a leading -`--` is added when the key does not carry one). This is the declared door for any token the schema -does not model — z-index steps, animation durations, layout dimensions: - -```ts -import type { Theme } from '@object-ui/types'; - -const dashboard: Theme = { - name: 'dashboard', - label: 'Dashboard', - colors: { primary: '#3b82f6' }, - customVars: { - 'header-height': '4rem', - 'sidebar-width': '16rem', - '--z-modal': '1400', - }, -}; -``` - -## Theme Inheritance - -A theme can extend another by `name`. On resolution the chain is merged deep for `colors`, -`typography`, `borderRadius`, `shadows` and `customVars` — the child overrides key by key and -inherits the rest. Cycles are detected and stop the walk. - -```ts -import type { Theme } from '@object-ui/types'; - -const acmeLight: Theme = { - name: 'acme-light', - label: 'Acme', - colors: { primary: '#3b82f6', background: '#ffffff', text: '#0f172a' }, - borderRadius: { base: '0.5rem' }, -}; - -const acmeDark: Theme = { - name: 'acme-dark', - label: 'Acme Dark', - extends: 'acme-light', - colors: { primary: '#60a5fa', background: '#0f172a', text: '#f1f5f9' }, -}; -``` - -Register both on the provider: `acme-dark` resolves against `acme-light`, inheriting the -`borderRadius` scale while its `colors` override key by key. - -The engine functions are exported for direct use: - -```ts -import { generateThemeVars, resolveMode } from '@object-ui/core'; -import type { Theme } from '@object-ui/types'; - -const probe: Theme = { - name: 'probe', - label: 'Probe', - colors: { primary: '#3b82f6' }, -}; - -const vars = generateThemeVars(probe); // { '--primary': '217 91% 60%' } -const effective = resolveMode('auto'); // 'light' | 'dark', from prefers-color-scheme -console.log(vars, effective); -``` - -## Validation - -Theme documents are validated at the **type level**: retired keys are `never`-typed tombstones, so -an invalid document fails to compile rather than being silently stripped at runtime. There is no -runtime validator for theme documents — the zod schemas `@objectstack/spec` used to publish -(`ThemeDefinitionSchema` and its token sub-schemas) were retired with its theme module, and -`@object-ui/types/zod` does not export a replacement. - -For the runtime check that matters to theming — accessibility — the engine ships WCAG helpers: - -```ts -import { contrastRatio, meetsContrastLevel } from '@object-ui/core'; - -const ratio = contrastRatio('#0f172a', '#ffffff'); // ≈ 14.9 -const readable = meetsContrastLevel('#0f172a', '#ffffff', 'AA'); // true -console.log(ratio, readable); -``` - -## Complete Theme Example - -```ts -import type { Theme } from '@object-ui/types'; - -const professional: Theme = { - name: 'professional', - label: 'Professional', - description: 'Default corporate look', - mode: 'auto', - - colors: { - primary: '#3b82f6', - secondary: '#64748b', - accent: '#8b5cf6', - success: '#10b981', - warning: '#f59e0b', - error: '#ef4444', - info: '#0ea5e9', - background: '#ffffff', - surface: '#f8fafc', - text: '#0f172a', - textSecondary: '#64748b', - border: '#e2e8f0', - }, - - typography: { - fontFamily: { base: 'Inter, system-ui, sans-serif' }, - }, - - borderRadius: { - sm: '0.25rem', - base: '0.5rem', - md: '0.75rem', - lg: '1rem', - }, - - shadows: { - sm: '0 1px 2px 0 rgb(0 0 0 / 0.05)', - base: '0 1px 3px 0 rgb(0 0 0 / 0.1)', - lg: '0 10px 15px -3px rgb(0 0 0 / 0.1)', - }, - - customVars: { - 'header-height': '4rem', - 'sidebar-width': '16rem', - }, -}; -``` - -## Best Practices - -1. **Use the semantic keys** — map brand colors onto `primary` / `accent` / `error` rather than - inventing custom variables for tokens the palette already models. -2. **Test both modes** — an `'auto'` theme renders under both the `light` and `dark` classes. -3. **Maintain contrast** — check WCAG pairs with `meetsContrastLevel` from `@object-ui/core`. -4. **Default to `'auto'`** — respect the OS preference; it is the provider's default mode. -5. **Persist on the provider** — user preference is `ThemeProvider`'s `persist` / `storageKey`, - not a key on the theme document. -6. **Share tokens with `extends`** — author variants as small overrides of a base theme. - -## Related - -- [App Schema](/docs/core/app-schema) - Application configuration -- [Schema Overview](/docs/guide/schema-overview) - Where theming sits among the schema families -- [CSS Variables](https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties) - MDN documentation -- [Tailwind Theming](https://tailwindcss.com/docs/theme) - Tailwind CSS theming guide diff --git a/ui/content/docs/fields/auto-number.mdx b/ui/content/docs/fields/auto-number.mdx deleted file mode 100644 index 72fab47e162..00000000000 --- a/ui/content/docs/fields/auto-number.mdx +++ /dev/null @@ -1,158 +0,0 @@ ---- -title: "AutoNumber Field" -description: "Read-only auto-generated sequence number" ---- - -The AutoNumber Field component displays auto-generated sequence numbers. This is a read-only field where the value is automatically generated by the backend when records are created. - -## Basic Usage - - - -## Custom Format - - - -## Date-Based Format - - - -## Field Schema - -An auto-number field is authored as `AutoNumberFieldMetadata` (`@object-ui/types`), -which is the source of truth for the key set: it extends `BaseFieldMetadata` with the -record-number pattern, `autonumberFormat`. The type value is `autonumber`, the spelling -`@objectstack/spec`'s `FieldType` declares (`auto_number` is refused at `type`). - -```ts -import type { AutoNumberFieldMetadata } from '@object-ui/types'; - -const invoiceNumber: AutoNumberFieldMetadata = { - type: 'autonumber', - name: 'invoice_number', - label: 'Invoice Number', - inlineHelpText: 'Assigned by the platform when the record is created.', - readonly: true, - autonumberFormat: 'INV-{0000}', -}; -``` - -`autonumberFormat` is the spelling `@objectstack/spec` prefers on a new field. -`format` is the older shorthand for the same pattern, and the spec still accepts -it: `format: 'INV-{0000}'` mints the same numbers. When a field sets both, -`autonumberFormat` wins and `format` is ignored. - -The generated value, and the `className` a host supplies, are **not** metadata keys — -they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## Format Templates - -Common format patterns: - -### Simple Sequential - -```plaintext -autonumberFormat: '{0000}' // 0001, 0002, 0003... -autonumberFormat: '{00000}' // 00001, 00002, 00003... -``` - -### With Prefix - -```plaintext -autonumberFormat: 'ORD-{0000}' // ORD-0001, ORD-0002... -autonumberFormat: 'INV-{00000}' // INV-00001, INV-00002... -autonumberFormat: 'CUST-{000}' // CUST-001, CUST-002... -``` - -### Date-Based - -```plaintext -autonumberFormat: '{YYYY}-{0000}' // 2024-0001, 2024-0002... -autonumberFormat: '{YY}{MM}-{000}' // 2403-001, 2403-002... -autonumberFormat: 'ORD-{YYYYMMDD}-{00}' // ORD-20240315-01... -``` - -### Mixed Format - -```plaintext -autonumberFormat: 'PO-{YYYY}-{MM}-{0000}' // PO-2024-03-0001 -``` - -## Format Placeholders - -These are the tokens `parseAutonumberFormat` (`@objectstack/spec/data`) reads: - -- `{0}`, `{00}`, `{000}`, etc. - Sequential number, zero-padded to at least that many digits (one per format) -- `{YYYY}` - Four-digit year (2024) -- `{YY}` - Two-digit year (24) -- `{MM}` - Two-digit month (03) -- `{DD}` - Two-digit day (15) -- `{YYYYMMDD}` - Year, month and day together (20240315) -- `{field_name}` - The value of another field on the same record - -The date tokens are exact and case-sensitive, and there are no others: no -quarter, week or time token. Any other name in braces is a `{field_name}` -token, so `{Q}` reads a field called `Q`, not the quarter. A referenced field -must exist on the object and be set before the record is created: -`objectstack compile` refuses a format that names a field the object does not -have, and an insert whose referenced field is empty fails rather than mint a -number. - -## Backend Implementation - -ObjectUI does not render the format. An ObjectStack backend mints each value -when the record is inserted, with the renderer `@objectstack/spec/data` -exports: `parseAutonumberFormat` reads the format into tokens and -`renderAutonumber` renders them. The query engine and the SQL driver share -that renderer, so the placeholders above are the whole grammar. A backend of -your own should call the same two functions rather than re-implement the -token grammar. - -## Sequence Management - -Allocating the next number is the backend's job, not the renderer's. ObjectUI -never generates a value: `AutoNumberField` displays whatever the saved record -already carries, and renders a muted placeholder dash while the field is still -empty. What ObjectUI owns is the metadata the backend reads — how each value -is formatted. There is no starting-value key: `@objectstack/spec`'s `FieldSchema` -refuses `starting_number` by name, so where a sequence starts is the backend's -counter, not the field's metadata: - -```ts -import type { AutoNumberFieldMetadata } from '@object-ui/types'; - -const orderNumber: AutoNumberFieldMetadata = { - type: 'autonumber', - name: 'order_number', - label: 'Order Number', - autonumberFormat: 'ORD-{YYYY}-{0000}', -}; -``` - -On the backend, keep one counter per object, field and counter scope, and -increment it in the same transaction that inserts the record, so two concurrent -inserts cannot read the same current value. The scope is whatever the format -renders before the `{0000}` slot, and `renderAutonumber` returns it with the -value: `'ORD-{YYYY}-{0000}'` counts per year and restarts each January, while -`'ORD-{0000}'` keeps one count. - -Because the number is assigned at insert time, it does not exist while the -record is still being drafted: a create form shows the field empty, and the -value appears once the saved record comes back. - -## Use Cases - -- **Order Management**: Order numbers, PO numbers -- **Invoicing**: Invoice IDs, receipt numbers -- **Ticketing**: Support ticket IDs, case numbers -- **Customer Management**: Customer IDs, account numbers -- **Inventory**: SKU numbers, serial numbers -- **Document Management**: Document IDs, revision numbers - -## Best Practices - -1. **Choose appropriate padding**: Use enough digits for expected volume -2. **Include year for long-running systems**: Helps with archival and partitioning -3. **Use meaningful prefixes**: Makes numbers self-documenting -4. **Don't expose internal IDs**: Use auto-numbers for user-facing identifiers -5. **Consider reset policies**: Decide if/when sequences reset (yearly, monthly, etc.) diff --git a/ui/content/docs/fields/boolean.mdx b/ui/content/docs/fields/boolean.mdx deleted file mode 100644 index 357b3a0170b..00000000000 --- a/ui/content/docs/fields/boolean.mdx +++ /dev/null @@ -1,87 +0,0 @@ ---- -title: "Boolean Field" -description: "Switch/checkbox input for true/false values" ---- - -The Boolean Field component provides a switch or checkbox input for collecting true/false boolean values. - -## Basic Usage - - - -## With Description - - - -## Default Value - - - -## Field Schema - -A boolean field is authored as `BooleanFieldMetadata` (`@object-ui/types`), which is -the source of truth for the key set. It adds nothing of its own to -`BaseFieldMetadata` beyond the discriminant — a checkbox needs no extra configuration. - -```ts -import type { BooleanFieldMetadata } from '@object-ui/types'; - -const isActive: BooleanFieldMetadata = { - type: 'boolean', - name: 'is_active', - label: 'Active', - description: 'Inactive records stay searchable but are excluded from lists.', - required: false, - defaultValue: true, -}; -``` - -The value being edited, and the `className` / `disabled` a host supplies, are **not** -metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## Read-only Display - -In a read-only row (a `readonly` field in a generated form, or a detail body), the widget draws -a real `true` as "Yes" and a real `false` as "No". **Only a real boolean is a value of a boolean -field.** `@objectstack/spec` declares the runtime value of `boolean` / `toggle` as a bare JS -boolean on the wire; the SQL `0`/`1` repair and the `'true'` / `'false'` spellings belong to -the producer boundaries (driver read-coercion, CSV import), never to the widget. So `null`, -`undefined`, `[]`, `{}`, `0`, `1`, `''`, `'true'` and `'false'` all render the shared -`EmptyValue` affordance — an em-dash with the accessible name "No value", the same affordance -every other field widget draws for an absent value — because the record holds no boolean -there. The widget keeps no second truth table: a `'false'` string that reaches it is a producer -that skipped its coercion, and the place to fix that is the data source. - -## Use Cases - -- **Feature Toggles**: Enable/disable features -- **Preferences**: User settings and preferences -- **Permissions**: Access control flags -- **Status**: Active/inactive, published/draft states -- **Agreements**: Terms acceptance, consent flags - -## Cell Renderer - -In tables/grids, `BooleanCellRenderer` draws a real boolean as a disabled checkbox — checked -for `true`, unchecked for `false` — named by the field's label. One field-name convention -changes the shape: a completion field (`completed`, `is_completed`, `done`, `is_done`) draws a -green "Completed" or an outlined "Not completed" indicator. Every other name draws the -checkbox, so `false` is a neutral unchecked box whatever the field is called: `active`, -`enabled` and `verified` read the same as any other boolean. - -**Only a real boolean is a value of a boolean column.** `@objectstack/spec` declares the -runtime value of `boolean` / `toggle` as a bare JS boolean on the wire; the SQL `0`/`1` repair -and the `'true'` / `'false'` spellings belong to the producer boundaries (driver read-coercion, -CSV import). So `null`, `undefined`, `[]`, `{}`, `0`, `1`, `''`, `'true'` and `'false'` all -render the shared `EmptyValue` affordance — an em-dash with the accessible name "No value" — -because the record holds no boolean there. A `'false'` string that reaches this renderer is a -producer that skipped its coercion: fix it at the data source; the renderer keeps no second -truth table. - -## Styling - -The boolean field uses the Switch component from Shadcn UI, providing: -- Smooth animations -- Accessible keyboard navigation -- Focus states -- Disabled states diff --git a/ui/content/docs/fields/currency.mdx b/ui/content/docs/fields/currency.mdx deleted file mode 100644 index 022f5307790..00000000000 --- a/ui/content/docs/fields/currency.mdx +++ /dev/null @@ -1,94 +0,0 @@ ---- -title: "Currency Field" -description: "Formatted currency input with locale support" ---- - -The Currency Field component provides a formatted currency input with proper locale formatting and currency symbol display. - -## Basic Usage - - - -## Different Currencies - - - -## Field Schema - -A currency field is authored as `CurrencyFieldMetadata` (`@object-ui/types`), which is -the source of truth for the key set: it extends `BaseFieldMetadata` with the currency -declaration, the total-digit `precision` and the two numeric bounds. - -```ts -import type { CurrencyFieldMetadata } from '@object-ui/types'; - -const amount: CurrencyFieldMetadata = { - type: 'currency', - name: 'amount', - label: 'Amount', - placeholder: '0.00', - required: true, - currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' }, - min: 0, - max: 1000000, -}; -``` - -**The currency is `currencyConfig`**, `@objectstack/spec`'s own declaration. A field that -always shows one currency declares `{ currencyMode: 'fixed', defaultCurrency }`; a field -with no `currencyConfig`, or with `currencyMode: 'dynamic'`, shows the tenant's default -currency. A field-level `currency` key is refused by the spec by name, and -`CurrencyFieldMetadata` refuses it at compile time. - -**Decimal places come from the currency, not from a field key.** An amount shows its -currency's own ISO 4217 minor unit — two decimals for USD or CNY, none for JPY, three -for KWD — in the read-only display, in the input's `step` and in the rounding applied -when the input loses focus. `precision` is the total digit count of the stored decimal -(a DECIMAL(18,2) amount declares `precision: 18`), so it never sets how many decimals are -shown. With no currency resolved at all, the amount shows two decimals. - -The value being edited, and the `className` / `disabled` a host supplies, are **not** -metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## What the browser rewrites before this field sees it - -This widget renders a native `type="number"` input, so the browser decides what -the box accepts. Two things can happen and **only one of them is announced**: - -- **Announced.** Text the browser cannot read at all — `1e`, a lone `-`, a lone - `.` — leaves the box visibly showing what was typed while its value reads - empty. The field is marked `aria-invalid` and draws - *"Not saved: the text in this box is not a number."* -- ⚠️ **Not announced.** Entries the browser silently **truncates**: pasting - ``1.2.3`` stores ``1.23``, and `0x10` stores ``10``. No warning is - possible here — the browser discards the extra characters as they arrive, so - nothing reaches ObjectUI to check. - -⛔ **"No warning" therefore does not mean "the value is right."** Full -explanation and the reasoning: [What a number field silently -rewrites](/docs/guide/fields#what-a-number-field-silently-rewrites). - -## Supported Currencies - -- **USD**: US Dollar ($) -- **EUR**: Euro (€) -- **GBP**: British Pound (£) -- **JPY**: Japanese Yen (¥) -- And all other ISO 4217 currency codes - -## Cell Renderer - -In tables/grids, currency values are formatted with the appropriate symbol: - -```ts -import { CurrencyCellRenderer } from '@object-ui/fields'; - -// Renders: $1,234.56 or €1.234,56 based on locale -``` - -## Use Cases - -- **Pricing**: Product prices, service costs -- **Financial Data**: Revenue, expenses, budgets -- **Transactions**: Payment amounts, invoice totals -- **Salaries**: Compensation amounts diff --git a/ui/content/docs/fields/date.mdx b/ui/content/docs/fields/date.mdx deleted file mode 100644 index 221cbb2ed07..00000000000 --- a/ui/content/docs/fields/date.mdx +++ /dev/null @@ -1,116 +0,0 @@ ---- -title: "Date Field" -description: "Date picker input for date and datetime values" ---- - -The Date Field component provides a date picker for selecting dates and optionally times. - -## Basic Usage - - - -## With Default Value - - - -## Field Schema - -A date field is authored as `DateFieldMetadata` (`@object-ui/types`), which is the -source of truth for the key set: it extends `BaseFieldMetadata` with a display format -and the due/deadline flag. - -```ts -import type { DateFieldMetadata } from '@object-ui/types'; - -const closeDate: DateFieldMetadata = { - type: 'date', - name: 'close_date', - label: 'Close Date', - placeholder: 'Pick a date', - required: true, - format: 'yyyy-MM-dd', - dueLike: true, -}; -``` - -`dueLike` declares the date a deadline, gating the relative "Overdue Nd" wording and -the red styling. It is `@objectstack/spec`'s `FieldSchema.dueLike`, and the spec -accepts it on `date` and `datetime` fields only. `datetime` is its own type with its -own metadata (`DateTimeFieldMetadata`) and honours `dueLike` identically; see -[DateTime Field](/docs/fields/datetime). Nothing is inferred from the field's name: -without the key, a past `due_date` reads as any past date does. - -`settledWhen` says when the deadline is settled for one record: a CEL predicate over -the record's own columns, read as `record`. While it holds, no overdue wording or -colour applies to that record. It is `FieldSchema.settledWhen`, and the spec accepts -it only beside `dueLike: true`: - -```ts -import type { DateFieldMetadata } from '@object-ui/types'; - -const dueDate: DateFieldMetadata = { - type: 'date', - name: 'due_date', - dueLike: true, - settledWhen: "record.status == 'done'", -}; -``` - -Every view that draws the date beside its record — the grid, the record page, related -lists, kanban and gallery cards, the compact list and the tree — evaluates -`settledWhen` against that record, so a done task's past due date reads neutral while -an open one's reads overdue. A predicate that fails to evaluate leaves the deadline -unsettled. - -⚠️ The predicate reads the record as the view fetched it. A grid, a list view and a -related list add the fields `settledWhen` reads to their own query, so the field the -predicate reads need not be a column. A field the viewer may not read is never -fetched, so a deadline whose predicate reads one stays unsettled for that viewer. An -approval's record summary judges the deadline against the snapshot it shows. - -There is no date-range key. `@objectstack/spec`'s `FieldSchema` declares no date bound -(its `min` / `max` are numbers) and refuses `min_date` / `max_date` by name, and no -widget ever read them, so `DateFieldMetadata` refuses both at compile time. - -The value being edited, and the `className` / `disabled` a host supplies, are **not** -metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## Date Formats - -The date field supports various display formats: - -- **Short**: 12/31/2024 -- **Medium**: Dec 31, 2024 (default) -- **Long**: December 31, 2024 -- **Full**: Monday, December 31, 2024 - -## Use Cases - -- **Birthdates**: User birth dates -- **Deadlines**: Task or project deadlines -- **Events**: Event start/end dates -- **Appointments**: Meeting or appointment scheduling -- **Releases**: Product or content release dates - -## Cell Renderer - -In tables/grids, dates are formatted consistently: - -```ts -import { DateCellRenderer } from '@object-ui/fields'; - -// Renders: Dec 31, 2024 -// Or: Dec 31, 2024 2:30 PM (for datetime) -``` - -## DateTime Variant - -For collecting both date and time: - -```plaintext -{ - type: 'datetime', - name: 'appointment', - label: 'Appointment Time' -} -``` diff --git a/ui/content/docs/fields/datetime.mdx b/ui/content/docs/fields/datetime.mdx deleted file mode 100644 index 1c4b8c1616c..00000000000 --- a/ui/content/docs/fields/datetime.mdx +++ /dev/null @@ -1,128 +0,0 @@ ---- -title: "DateTime Field" -description: "Date and time input with combined picker" ---- - -The DateTime Field component provides a combined date and time input for collecting both date and time information in a single field. - -## Basic Usage - - - -## With Default Value - - - -## Required Field - - - -## Read-Only - - - -## Field Schema - -A datetime field is authored as `DateTimeFieldMetadata` (`@object-ui/types`), which is -the source of truth for the key set: it extends `BaseFieldMetadata` with a display -format and the due/deadline flag. As on the [Date Field](/docs/fields/date), there is -no range key: the spec refuses `min_date` / `max_date` by name and nothing read them. - -```ts -import type { DateTimeFieldMetadata } from '@object-ui/types'; - -const startsAt: DateTimeFieldMetadata = { - type: 'datetime', - name: 'starts_at', - label: 'Starts At', - placeholder: 'Pick a date and time', - required: true, - format: 'yyyy-MM-dd HH:mm', - dueLike: true, -}; -``` - -## Overdue Affordance - -`dueLike` declares the datetime a deadline. It is `@objectstack/spec`'s -`FieldSchema.dueLike`, the same key, with the same meaning, that the -[Date Field](/docs/fields/date) uses, and the spec accepts it on `date` and -`datetime` fields only. A past `starts_at` or `created_at` is not a missed -deadline, so only a field that says so gets the affordance: - -- the relative face reads **"Overdue Nd"** instead of the neutral "N days ago"; -- the cell is styled red, whichever display face it is painting. - -An absent key, or `false`, means "not a deadline", with nothing inferred from -the field's name: an undeclared `due_at` in the past reads as any past datetime -does. - -`settledWhen` says when the deadline is settled for one record. It is -`FieldSchema.settledWhen`: a CEL predicate over the record's own columns, read as -`record`, written as a bare string or as the spec's expression envelope. While it -holds, no overdue wording or colour applies to that record, and the spec accepts it -only beside `dueLike: true`: - -```ts -import type { DateTimeFieldMetadata } from '@object-ui/types'; - -const respondBy: DateTimeFieldMetadata = { - type: 'datetime', - name: 'respond_by', - dueLike: true, - settledWhen: { dialect: 'cel', source: "record.status == 'closed'" }, -}; -``` - -Every view that draws the datetime beside its record evaluates `settledWhen` -against that record (see [Date Field](/docs/fields/date)), and a settled deadline -reads the neutral phrase with no red. A predicate that fails to evaluate leaves -the deadline unsettled. - -The affordance is **day-granular**. It compares calendar days, so a datetime a -few hours past its deadline still reads "Today" and is not red — the same answer -the date field gives for a deadline falling today. The shortest overdue phrase -is "Overdue 2d". - -The two halves do not switch on and off together. The red styling starts the day -after the deadline; the "Overdue Nd" wording starts a day later still, and then -stops once the date is far enough in the past that the relative face falls back -to an absolute date. The red does not stop with it — a long-overdue deadline -reads as a plain past date, in red. - -The same key is authorable on a **detail-view field entry**, where it means -exactly this. See [DetailViewSchema](/docs/api/schema-reference#detailviewschema). - -The value being edited, and the `className` / `disabled` a host supplies, are **not** -metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## Date Format - -The datetime field stores values in ISO 8601 format: `YYYY-MM-DDTHH:mm` - -Example: `2024-03-15T14:30` represents March 15, 2024 at 2:30 PM - -## Cell Renderer - -When used in data tables or grids: - -```ts -import { DateTimeCellRenderer } from '@object-ui/fields'; - -// Renders: Mar 15, 2024, 02:30 PM -``` - -## Time Zone - -A datetime is an instant, and it renders in the workspace's time zone: the -`timezone` that `GET /api/v1/auth/me/localization` answers, which the console -hands to every date face through `LocalizationProvider`. Without one it renders -in the viewer's own zone. A date-only value is a calendar day and shows the -same day in every zone. - -## Use Cases - -- **Event Scheduling**: Meeting times, appointments -- **Timestamps**: Order placed, task deadline -- **Booking Systems**: Reservation date and time -- **Notifications**: Scheduled notification time diff --git a/ui/content/docs/fields/email.mdx b/ui/content/docs/fields/email.mdx deleted file mode 100644 index 2a7c7d6b981..00000000000 --- a/ui/content/docs/fields/email.mdx +++ /dev/null @@ -1,71 +0,0 @@ ---- -title: "Email Field" -description: "Email input with validation" ---- - -The Email Field component provides a text input with built-in email validation and formatting. - -## Basic Usage - - - -## Required Email - - - -## Field Schema - -An email field is authored as `EmailFieldMetadata` (`@object-ui/types`), which is the -source of truth for the key set: it extends `BaseFieldMetadata` with a single length -bound, `@objectstack/spec`'s own `FieldSchema.maxLength`, typed by reference. -Address-shape validation is the widget's, not a metadata key. - -```ts -import type { EmailFieldMetadata } from '@object-ui/types'; - -const contactEmail: EmailFieldMetadata = { - type: 'email', - name: 'contact_email', - label: 'Email Address', - placeholder: 'name@example.com', - required: true, - maxLength: 254, -}; -``` - -The value being edited, and the `className` / `disabled` a host supplies, are **not** -metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## Validation - -The email field automatically validates: -- Proper email format (user@domain.com) -- Presence of @ symbol -- Valid domain structure -- No spaces or invalid characters - -## Cell Renderer - -In tables/grids, email addresses are clickable links: - -```ts -import { EmailCellRenderer } from '@object-ui/fields'; - -// Renders as: user@example.com -// Clicking opens default email client -``` - -## Use Cases - -- **User Registration**: Account email addresses -- **Contact Forms**: Customer contact information -- **Support**: Support ticket emails -- **Notifications**: Email notification addresses -- **Team Members**: Team member email addresses - -## Features - -- **Autocomplete**: Browser email autocomplete -- **Validation**: Real-time email format validation -- **Clickable Links**: Email links in read-only mode -- **Accessible**: Proper ARIA labels and keyboard navigation diff --git a/ui/content/docs/fields/file.mdx b/ui/content/docs/fields/file.mdx deleted file mode 100644 index 1990174dc17..00000000000 --- a/ui/content/docs/fields/file.mdx +++ /dev/null @@ -1,121 +0,0 @@ ---- -title: "File Field" -description: "File upload with multiple file support and metadata display" ---- - -The File Field component provides a file upload interface with support for multiple files, file type filtering, and file metadata display. - -## Basic Usage - - - -## Multiple Files - - - -## File Type Restrictions - - - -## Field Schema - -A file field is authored as `FileFieldMetadata` (`@object-ui/types`), which is the -source of truth for the key set: it extends `BaseFieldMetadata` with `multiple`, `accept` -and `maxSize`. `maxSize` is the largest file the widget accepts, in bytes, and it is the -spec's `FieldSchema.maxSize`: a larger pick is refused before any upload starts. There is -no key for a file count, because the spec declares none and no widget enforces one. - -The field's stored **value** is the bare `sys_file` id the upload adapter minted — a -`string`, or `string[]` when `multiple` — the reference form the platform's stored -contract admits. An upload whose adapter surfaces no id is refused in the widget with -"did not complete" and nothing is submitted (objectui#7699); mount an `UploadProvider` -whose adapter registers the file with the platform (`createObjectStackUploadAdapter`). -`UploadedFileMetadata`, exported from the same package, is the **legacy inline shape** a -record written before file-as-reference may still hold: the widgets still render it, -and never write a new one. - -**Restricted files.** When the reader may read the record but not a file's `sys_file` -metadata, the platform sends that file as `{ id, metadataRefused: true }` -(`FileRefusedValueSchema` in `@objectstack/spec/data`). `readFileValue` flags it as -`metadataRefused: true` on the `FileValueView`, and every file, image and avatar renderer -shows a lock with a translated "Restricted file" label (`fields.file.restricted`) instead of a -name or a thumbnail. The open link still points to the id-derived download endpoint, which -decides access itself. An editable widget that re-submits the field's values sends such a -file back as its bare id. - -```ts -import type { FileFieldMetadata, UploadedFileMetadata } from '@object-ui/types'; - -const attachments: FileFieldMetadata = { - type: 'file', - name: 'attachments', - label: 'Attachments', - inlineHelpText: 'PDF or Word, up to 10 MB each.', - multiple: true, - accept: ['application/pdf', 'application/msword'], - maxSize: 10 * 1024 * 1024, -}; - -// The LEGACY inline shape a pre-reference record may still hold as its VALUE — -// rendered by the widgets, never written by them (a new upload stores the id). -const legacyStoredFile: UploadedFileMetadata = { - name: 'contract.pdf', - original_name: 'Contract (signed).pdf', - size: 248_310, - mime_type: 'application/pdf', - url: 'https://files.example.com/contract.pdf', -}; -``` - -The value being edited, and the `className` / `disabled` a host supplies, are **not** -metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## Accepted File Types - -Common MIME type examples: - -```plaintext -// Documents -accept: ['application/pdf', 'application/msword'] - -// Images -accept: ['image/png', 'image/jpeg', 'image/gif'] - -// Archives -accept: ['application/zip', 'application/x-rar'] - -// Spreadsheets -accept: ['application/vnd.ms-excel', 'text/csv'] - -// Wildcards -accept: ['image/*'] // All images -``` - -## Features - -- **File List Display**: Shows uploaded files with names and sizes -- **Remove Files**: Individual file removal with X button -- **Size Display**: File sizes shown in KB/MB -- **Upload Button**: Clear upload interface -- **Multiple Selection**: Support for batch file uploads - -## Cell Renderer - -In tables/grids, displays file count: - -```ts -import { FileCellRenderer } from '@object-ui/fields'; - -// Renders: "3 files" or "document.pdf" -``` - -## Integration Notes - -The widget uploads through the ambient `UploadProvider` from `@object-ui/providers` and stores the `sys_file` id its adapter returns in `meta.fileId`; `createObjectStackUploadAdapter` (ObjectStack's presigned flow) returns one. With no provider mounted, `useUpload()` falls back to an object-URL adapter that persists nothing and returns no id, so every pick is refused with "did not complete" and the field is not changed. - -## Use Cases - -- **Document Management**: PDF uploads, contracts -- **File Attachments**: Email attachments, support tickets -- **Media Libraries**: Asset uploads, resources -- **Data Import**: CSV files, batch uploads diff --git a/ui/content/docs/fields/formula.mdx b/ui/content/docs/fields/formula.mdx deleted file mode 100644 index 2b8c025f8d2..00000000000 --- a/ui/content/docs/fields/formula.mdx +++ /dev/null @@ -1,117 +0,0 @@ ---- -title: "Formula Field" -description: "Read-only computed field with automatic calculation" ---- - -The Formula Field component displays computed values calculated from other fields. This is a read-only field where the value is automatically calculated by the backend. - -## Basic Usage - - - -## Text Formula - - - -## Date Formula - - - -## Field Schema - -A formula field is authored as `FormulaFieldMetadata` (`@object-ui/types`), which is -the source of truth for the key set: it extends `BaseFieldMetadata` with the -expression and its declared return type. Both are `@objectstack/spec`'s own -`FieldSchema` members, typed by reference: - -- `expression` — the formula, written in CEL against the record - (`record.quantity * record.unit_price`), or the spec's expression envelope - (`{ "dialect": "cel", "source": "…" }`); -- `returnType` — a closed union of `'number' | 'text' | 'boolean' | 'date'`. - -```ts -import type { FormulaFieldMetadata } from '@object-ui/types'; - -const totalPrice: FormulaFieldMetadata = { - type: 'formula', - name: 'total_price', - label: 'Total Price', - readonly: true, - expression: 'record.quantity * record.unit_price', - returnType: 'number', -}; -``` - -There is no `formula` key. `FieldSchema` refuses it by name and points at -`expression`, and `FormulaFieldMetadata` does not declare it. - -The computed value, and the `className` a host supplies, are **not** metadata keys — -they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## Return Types - -The formula field draws its value by `returnType`. The form and the table cell -read the same rule, so one stored value reads the same in both: - -- **number**: Formatted as a number field formats it, in the viewer's display - locale and at the width a `scale` on the field sets (`200000` reads `200,000` - in en-US) -- **boolean**: The UI language's Yes / No word; a value that is not a JS boolean - reads as empty -- **date**: The date field's read-only face (`2020-07-04` reads `Jul 4, 2020` in - en-US), in the table cell too: it does not use the date cell's relative style -- **text**: The value as text, in a monospace font - -With no `returnType`, a JS number is drawn as a number and any other value as -text. Nothing is inferred from the expression. - -## Formula Examples - -Common formula patterns, each the value of `expression` (CEL, with the record's -fields under `record.`): - -```plaintext -// Arithmetic -expression: 'record.price * record.quantity' -expression: '(record.subtotal - record.discount) * record.tax_rate' - -// Text concatenation -expression: 'record.first_name + " " + record.last_name' -expression: 'record.city + ", " + record.state + " " + record.zip' - -// Conditional -expression: 'record.age >= 18 ? "Adult" : "Minor"' -expression: 'record.status == "closed" ? record.completed_at : null' - -// Date calculations -expression: 'addDays(record.created_at, 7)' -expression: 'daysBetween(record.start_date, record.end_date)' -``` - -## Cell Renderer - -In tables/grids, `FormulaCellRenderer` draws the value by the same rule as the -form (see Return Types above). Only text is drawn in a monospace font. A null or -empty value renders an em-dash. - -```ts -import { FormulaCellRenderer } from '@object-ui/fields'; - -// with no returnType -// renders the number cell's text: 200,000 (en-US) -``` - -## Backend Implementation - -Formula fields are computed on the backend: the platform evaluates the field's -`expression` against the record and returns the result as the field's value. -The widget never evaluates anything; it formats the value it is given by -`returnType`. - -## Use Cases - -- **Calculations**: Totals, subtotals, tax amounts -- **Aggregations**: Sum of related records -- **Concatenations**: Full names, addresses -- **Derived Values**: Age from birthdate, days until deadline -- **Conditional Logic**: Status based on other fields diff --git a/ui/content/docs/fields/grid.mdx b/ui/content/docs/fields/grid.mdx deleted file mode 100644 index 098c0ebf5e2..00000000000 --- a/ui/content/docs/fields/grid.mdx +++ /dev/null @@ -1,370 +0,0 @@ ---- -title: "Grid Field" -description: "Sub-table field for inline tabular data" ---- - -The Grid Field component provides an inline table for managing related records or tabular data within a parent record. It displays data in rows and columns with optional editing capabilities. - -## Basic Usage - - - -## With Data - - - -## Read-Only - - - -## Field-Level Keys - - - -## Field Schema - -A grid field is authored as `GridFieldMetadata` (`@object-ui/types`), which is the -source of truth for the key set: it extends `BaseFieldMetadata` with the column list, -the row-count and row-action limits, the footer total, the Add button's label and the -row field that keeps the rows' order. -Each column is `@objectstack/spec`'s inline grid column (`InlineGridColumn`, the -element of the spec's `inlineColumns` list on a `master_detail` field), typed by -reference, so the columns are checked by the same compiler that checks the field, -and `objectui validate` refuses a column key the spec does not declare. - -```ts -import type { GridFieldMetadata } from '@object-ui/types'; - -const lineItems: GridFieldMetadata = { - type: 'grid', - name: 'line_items', - label: 'Line Items', - columns: [ - { name: 'product', label: 'Product', type: 'lookup', required: true, width: 240 }, - { name: 'quantity', label: 'Qty', type: 'number', width: 80 }, - { name: 'unit_price', label: 'Unit Price', type: 'currency', width: 120 }, - { name: 'amount', label: 'Amount', type: 'currency', computed: true, expr: 'quantity * unit_price' }, - ], - minRows: 1, - maxRows: 50, - allowAdd: true, - allowDelete: true, - allowReorder: false, - totalField: 'amount', - addLabel: 'Add line', - sortField: 'position', -}; -``` - -The field-level keys, each read under exactly this one spelling: - -- `minRows` / `maxRows` — the row-count limits. -- `allowAdd` / `allowDelete` — whether rows can be added or removed. Each row's - duplicate action follows `allowAdd`, since a duplicate is an add. -- `allowReorder` — set `false` to remove the drag handles; rows can be reordered - by dragging otherwise. -- `totalField` — the `name` of the **child** column summed into the footer total. - It carries the spec's `amountField`. The spec's own `totalField` on a - master-detail subform is a different thing with the same name: the parent field - the save writes the sum to. No total shows when it is unset. -- `addLabel` — the Add button's label. -- `sortField` — the name of a field on each **row** that the grid stamps with the - row's index (0, 1, 2, …) whenever a row is added, removed, duplicated or moved, - so the order a drag-reorder leaves is saved with the rows. Editing a cell moves - no row and leaves every row's position as it was. It is not one of the columns: - a column of that name would have its typed value overwritten. Rows carry no - position when it is unset. When - a master-detail form derives a detail's grid from the child object (it does - unless the detail names its relationship field and types every column), it sets - this to the child's first field named `position`, `sort_order`, `sequence`, - `line_no`, `line_number` or `sort`; there is no master-detail key to override - that pick. - -The same keys are accepted on a `form` field entry of `type: 'grid'`, as the -Field-Level Keys example above writes them. `FormField` declares each one by -reference to `GridFieldMetadata`, and its zod mirror judges each one by that type, -so the strict authoring face accepts them there and refuses a value of the wrong -type. Only the `grid` widget reads them. - -A read-only or disabled grid offers no add, delete, duplicate or reorder, whatever -these keys say. The line-number column always shows. There is no `reorderable`, -`amount_field`, `amountField`, `allow_duplicate` or `show_line_numbers` key: none -is read, and `GridFieldMetadata` refuses each. - -A column's `width` is a **number** of pixels. There is no per-column `editable` -key (whether cells can be edited follows the field's own read-only state) and no -per-column `defaultValue`: a new row starts with every cell empty. - -The value being edited, and the `className` / `disabled` a host supplies, are **not** -metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -### Retired snake_case keys - -Until objectui#11610 these eight keys were spelled in snake_case: `min_rows`, -`max_rows`, `allow_add`, `allow_delete`, `allow_reorder`, `total_field`, -`add_label` and `sort_field`. Each is now refused by name, with the camelCase key -to write instead, and its value carries over unchanged: - -- `GridFieldMetadata` and `FormField` declare each as a `never` member, so - TypeScript refuses it at the authoring site. -- `objectui validate` refuses it on a `form` field entry with - ``Did you mean `min_rows` → `minRows`?`` (and so on for each key). -- The `grid` widget draws an alert naming the retired keys and their replacements - instead of the grid, and logs the same text with `console.error`. It does not - quietly draw a grid that ignores them. - -## Column Types - -A column's `type` is one of the spec's nine cell controls: `text`, `number`, -`currency`, `date`, `datetime`, `time`, `select`, `lookup` and `file`. Any other -value is refused. - -```plaintext -columns: [ - { name: 'name', label: 'Name', type: 'text', required: true }, - { name: 'quantity', label: 'Qty', type: 'number' }, - { name: 'price', label: 'Price', type: 'currency' }, - { name: 'date', label: 'Date', type: 'date' }, - { name: 'status', label: 'Status', type: 'select', options: [...] }, - { name: 'receipt', label: 'Receipt', type: 'file', accept: ['image/*', '.pdf'] } -] -``` - -A `currency` column has no currency key of its own: its cells are shown in the -tenant's default currency. - -### File / image columns - -A `file` column renders a real upload control inside the cell — a compact -"Upload" button that opens the native file picker, with uploaded files shown -as removable chips (image files show a thumbnail). This covers the common -"attach a receipt per expense line" pattern without opening the per-row form. - -- `accept?: string[]` — restrict the picker (e.g. `['image/*', '.pdf']`). -- `multiple?: boolean` — allow several files per cell. -- Uploads go through the configured `UploadProvider` adapter, exactly like the - full-size file field. - -When grid columns are auto-derived from a child object's schema (master-detail -subforms), `file` / `image` / `avatar` fields map to `file` columns -automatically — image-flavoured fields default to `accept: ['image/*']`. - -Shown as text (read-only, or in the per-row form's list), a file cell reads the -file's name, or a count of its files when it holds several. The count and the -word used for a file with no name come from the session language's pack -(`detail.fileCount`, `fields.file.fileFallback`): an English session reads -`2 files` and `File`, and every other language reads its own pack's words, in -the plural form that language takes for the count. - -### Boolean columns from a child object - -When columns are auto-derived, a `boolean` or `toggle` child field becomes a -`select` column whose options store `'true'` and `'false'`. Their labels are the -session language's words for yes and no (`common.yes` / `common.no`), `Yes` / -`No` in an English session. Only the label is translated; the stored value is -the same in every language. - -Because file/image/avatar now render in-grid, a child object with a *single* -such field keeps the inline **grid** form factor by default (the smart -`inlineEdit` heuristic no longer forces a per-row form for one attachment -column). Only a truly form-only field (textarea / rich text / JSON / location) -or **several** rich fields tips the default to the per-row form; an explicit -`inlineEdit: 'grid' | 'form'` always wins. - -## Data Format - -Grid data is stored as an array of objects: - -```plaintext -const gridValue = [ - { product: 'Item 1', quantity: 2, price: 29.99 }, - { product: 'Item 2', quantity: 1, price: 49.99 }, - { product: 'Item 3', quantity: 5, price: 9.99 } -]; -``` - -## Common Patterns - -### Invoice Line Items - -```plaintext -{ - type: 'grid', - name: 'line_items', - label: 'Line Items', - columns: [ - { name: 'description', label: 'Description', type: 'text', required: true }, - { name: 'quantity', label: 'Quantity', type: 'number', required: true }, - { name: 'unit_price', label: 'Unit Price', type: 'currency', required: true }, - { name: 'amount', label: 'Amount', type: 'currency', computed: true, expr: 'quantity * unit_price' } - ] -} -``` - -### Order Details - -```plaintext -{ - type: 'grid', - name: 'order_details', - label: 'Order Details', - columns: [ - { name: 'sku', label: 'SKU', type: 'text' }, - { name: 'product', label: 'Product', type: 'lookup', reference: 'products' }, - { name: 'quantity', label: 'Qty', type: 'number' }, - { name: 'price', label: 'Price', type: 'currency' }, - { name: 'discount', label: 'Discount', type: 'number' }, - { name: 'total', label: 'Total', type: 'currency' } - ] -} -``` - -### Task Checklist - -```plaintext -{ - type: 'grid', - name: 'tasks', - label: 'Tasks', - columns: [ - { name: 'task', label: 'Task', type: 'text', required: true }, - { name: 'assigned_to', label: 'Assigned To', type: 'lookup', reference: 'sys_user' }, - { name: 'due_date', label: 'Due Date', type: 'date' }, - { name: 'completed', label: 'Done', type: 'select', options: [{ label: 'Yes', value: 'true' }, { label: 'No', value: 'false' }] } - ] -} -``` - -## Features - -- **Table Display**: Clean tabular layout, every row shown -- **Type-Specific Cells**: Each editable cell is the control its column's `type` names; - read-only mode formats `date`, `datetime`, `time`, `file` and `lookup` cells for display -- **Read-Only Mode**: Full table view without editing -- **Responsive**: Scrollable for many columns - -## Cell Renderer - -In tables/grids, a `grid` value is shown as a compact placeholder, not as a -nested table. It has no named renderer export of its own — the component is -resolved by field type, which is the supported path for every type: - -```ts -import { getCellRenderer } from '@object-ui/fields'; - -const GridCell = getCellRenderer('grid'); - -// renders: [Grid] -``` - -An empty value (`null`, `undefined`, `''` or `[]`) renders the shared "No value" -affordance instead, so an empty cell is distinguishable from a populated one. - -## Full Grid Functionality - -For advanced grid features, use the grid plugin (`@object-ui/plugin-grid`): - -```plaintext -// Basic inline grid (simple display) -{ type: 'grid', name: 'items', columns: [...] } - -// Advanced grid with full features (requires plugin) -// The block's props go in its `properties` bag (the spec's -// `ComponentPropsMap['object-grid']` row) — a `props` envelope is never read -{ - type: 'object-grid', // the registered type name — there is no `plugin:grid` - bind: 'items', // node-level: resolves the array from the surrounding data scope - properties: { - columns: [ // spec `ListColumn[]` — each entry is keyed by `field` - { field: 'product', label: 'Product' }, - { field: 'quantity', label: 'Quantity', sortable: false } - ], - editable: true, // Plugin feature: inline cell editing - pagination: { pageSize: 20 } // Plugin feature: pagination - } -} -``` - -**Note**: `editable` and `pagination` are read by `@object-ui/plugin-grid` (after `SchemaRenderer` hoists the bag onto the node), not by the basic grid field. Sorting and search are **not** node keys: column sorting is on by default and is turned off per column (`{ field, sortable: false }`), and there is no node-level `filterable` — a grid that fetches its own rows declares its query with `objectName` plus `filter` / `sort`. - -## Use Cases - -- **Invoice/Order Line Items**: Product lines, services -- **Expense Reports**: Expense entries, receipts -- **Time Tracking**: Time entries, work logs -- **Inventory**: Stock items, materials -- **Checklists**: Task lists, requirements -- **Schedules**: Appointments, bookings -- **Configurations**: Settings lists, parameters - -## Backend Storage - -Grid data is typically stored as JSON: - -```plaintext -// Database column type: JSONB (PostgreSQL) -interface OrderRecord { - id: string; - customer_id: string; - line_items: Array<{ - product: string; - quantity: number; - price: number; - }>; -} - -// Store in database -const order = { - customer_id: 'CUST-123', - line_items: [ - { product: 'Widget A', quantity: 2, price: 29.99 }, - { product: 'Widget B', quantity: 1, price: 49.99 } - ] -}; - -await db.insert('orders', order); -``` - -## Validation - -Example validation for grid data: - -```plaintext -// InlineGridColumn: the spec's grid column type (@objectstack/spec/data) -const validateGridData = (data: any[], columns: InlineGridColumn[]) => { - const errors: string[] = []; - - data.forEach((row, index) => { - columns.forEach(col => { - // Check required columns - if (col.required && !row[col.name]) { - errors.push(`Row ${index + 1}: ${col.label} is required`); - } - - // Validate by type - if (col.type === 'number' && isNaN(row[col.name])) { - errors.push(`Row ${index + 1}: ${col.label} must be a number`); - } - }); - }); - - return errors; -}; -``` - -## Integration with Advanced Grid - -The `grid` field and `@object-ui/plugin-grid`'s `object-grid` node do different jobs. -The `grid` field edits an array stored on the parent record, through the field-level -keys above. `object-grid` lists records (fetched for its `objectName`, or handed to -it) and acts on them as records. What each one reads: - -| Feature | `grid` field (`@object-ui/fields`) | `object-grid` (`@object-ui/plugin-grid`) | -| --- | --- | --- | -| Inline editing | Every cell but a computed one is its column's control, unless the field is read-only or disabled | `editable: true` (off by default), only where the current user may edit the object | -| Add and remove rows | `allowAdd` and `allowDelete`, each on unless `false` | An add-record row with `operations.create`, and a row's Delete with `operations.delete` and the host's `onDelete`, each only where the current user may create or delete the object's records | -| Sorting and filtering | None | Column-header sorting (a column opts out with `sortable: false`), the query's `filter` and `sort`, and search over `searchableFields` | -| Drag-and-drop reordering | Rows, with `allowReorder` (on unless `false`); `sortField` keeps the order on save | Columns only, with `reorderableColumns: true`; rows are not reordered | -| Export | None | `exportOptions` | -| Computed columns | A column with `computed: true` and an arithmetic `expr` over the row's other cells | None: a formula field's value is shown as the server computed it | -| Totals | One footer total, the sum of the `totalField` column | A footer summary per column (`columns[].summary`), and aggregates on group headers (`aggregations`) | diff --git a/ui/content/docs/fields/image.mdx b/ui/content/docs/fields/image.mdx deleted file mode 100644 index 20baab54906..00000000000 --- a/ui/content/docs/fields/image.mdx +++ /dev/null @@ -1,98 +0,0 @@ ---- -title: "Image Field" -description: "Image upload with thumbnail preview and multiple image support" ---- - -The Image Field component provides an image upload interface with thumbnail previews, multiple image support, and visual file management. - -## Basic Usage - - - -## Multiple Images - - - -## Field Schema - -An image field is authored as `ImageFieldMetadata` (`@object-ui/types`), which is the -source of truth for the key set: it extends `BaseFieldMetadata` with `multiple`, `accept` -and `maxSize`, the spec's `FieldSchema.maxSize` in bytes. There is no key for a file count -or for pixel dimensions, because the spec declares none and no widget enforces one. A stored -image is the bare `sys_file` id the upload adapter minted, the same value shape the file -field stores: see the -[File Field](/docs/fields/file) for the refusal when no id comes back, and for -`UploadedFileMetadata`, the legacy inline shape an older record may still hold. -An image the reader may not open (`{ id, metadataRefused: true }`) renders as a restricted -lock tile, never a thumbnail; see **Restricted files** on the [File Field](/docs/fields/file). - -```ts -import type { ImageFieldMetadata } from '@object-ui/types'; - -const productPhotos: ImageFieldMetadata = { - type: 'image', - name: 'product_photos', - label: 'Product Photos', - multiple: true, - accept: ['image/png', 'image/jpeg', 'image/webp'], - maxSize: 5 * 1024 * 1024, -}; -``` - -The value being edited, and the `className` / `disabled` a host supplies, are **not** -metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## Accepted Image Types - -By default, accepts all common image formats: - -```plaintext -// Default: 'image/*' -// Specific formats: -accept: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'] -``` - -## Features - -- **Thumbnail Grid**: Images displayed in 4-column grid -- **Preview**: Visual thumbnails for uploaded images -- **Hover to Remove**: Delete button appears on hover -- **Batch Upload**: Multiple image selection -- **Size Restrictions**: Configurable file size limits - -## Cell Renderer - -In tables/grids, displays image thumbnails: - -```ts -import { ImageCellRenderer } from '@object-ui/fields'; - -// Shows up to 3 thumbnails plus count if more -``` - -## Image Optimization - -For best results, cap the upload size and accept web-friendly formats: - -```plaintext -{ - type: 'image', - name: 'product_images', - label: 'Product Photos', - multiple: true, - maxSize: 5242880, // 5 MB - accept: ['image/jpeg', 'image/png', 'image/webp'] -} -``` - -## Integration Notes - -Uploads go through the ambient `UploadProvider`, exactly as for the [File Field](/docs/fields/file): the stored value is the `sys_file` id the adapter returns, and a pick whose adapter returns none is refused with "did not complete". - -## Use Cases - -- **User Profiles**: Avatar/profile pictures -- **Product Catalogs**: Product images, SKU photos -- **Photo Galleries**: Image collections, portfolios -- **Content Management**: Blog post images, media assets -- **Real Estate**: Property photos, listings diff --git a/ui/content/docs/fields/location.mdx b/ui/content/docs/fields/location.mdx deleted file mode 100644 index 909528f3f4a..00000000000 --- a/ui/content/docs/fields/location.mdx +++ /dev/null @@ -1,152 +0,0 @@ ---- -title: "Location Field" -description: "Geographic coordinates input for latitude and longitude" ---- - -The Location Field component provides an input for geographic coordinates, storing latitude and longitude as a structured object. - -## Basic Usage - - - -## With Default Value - - - -## Read-Only - - - -## Field Schema - -A location field is authored as `LocationFieldMetadata` (`@object-ui/types`), which is -the source of truth for the key set: it adds no key of its own to `BaseFieldMetadata`. -There is no zoom key: `@objectstack/spec`'s `FieldSchema` refuses `default_zoom` by -name and the widget never read it, so `LocationFieldMetadata` refuses it at compile time. - -```ts -import type { LocationFieldMetadata } from '@object-ui/types'; - -const officeLocation: LocationFieldMetadata = { - type: 'location', - name: 'office_location', - label: 'Office Location', - placeholder: 'latitude, longitude', - required: false, -}; -``` - -The coordinates themselves are the field's **value**, not metadata: the widget stores -an object carrying a `lat` and an `lng` and displays it as a comma-separated pair. -That value shape is exported as `LocationValue` by `@objectstack/spec/data` — -`{ lat, lng, altitude?, accuracy? }` — and it is what -`valueSchemaFor({ type: 'location' })` validates a stored location against. - -The value being edited, and the `className` / `disabled` a host supplies, are **not** -metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## Data Format - -The location field stores coordinates as an object: - -```plaintext -{ - lat: 37.7749, - lng: -122.4194 -} -``` - -`altitude` and `accuracy` are optional numbers on the same object. - -**Note**: the stored keys are `lat` and `lng`. The older `{ latitude, longitude }` -spelling is deprecated and is **rejected** by `valueSchemaFor({ type: 'location' })` -with `invalid_type` at `[lat]` and `[lng]` — do not author it. - -Input format: `latitude, longitude` — a user still types a comma-separated -latitude-then-longitude pair; only the stored key names are `lat` / `lng`. -- Example: `37.7749, -122.4194` - -## Coordinate Ranges - -- **Latitude** (`lat`): -90 to 90 (negative = South, positive = North) -- **Longitude** (`lng`): -180 to 180 (negative = West, positive = East) - -Examples: -- New York: `40.7128, -74.0060` -- Tokyo: `35.6762, 139.6503` -- Sydney: `-33.8688, 151.2093` -- London: `51.5074, -0.1278` - -## Use Cases - -- **Store Locator**: Retail locations, branch offices -- **Delivery Zones**: Service areas, delivery points -- **Event Venues**: Conference locations, meeting points -- **Asset Tracking**: Equipment locations, vehicle tracking -- **Real Estate**: Property coordinates, land parcels - -## Integration with Maps - -For full map functionality, consider integrating with map services. The example -below has two halves: the **input** that captures a coordinate pair, and the **map -node** that plots what it stored. - -**1. The input.** `LocationField` is the widget behind the `location` field type. -Render it directly when you build the form yourself: - -```tsx -import { useState } from 'react'; -import { LocationField } from '@object-ui/fields'; -import type { LocationFieldMetadata } from '@object-ui/types'; - -const field: LocationFieldMetadata = { - type: 'location', - name: 'location', - label: 'Store location', -}; - -export function StoreLocationInput() { - const [value, setValue] = useState<{ lat: number; lng: number } | null>(null); - return ; -} -``` - -**2. The map over the same field.** With the map plugin installed, an `object-map` -node reads that field off every record. It is a metadata node, not a component -call — its keys sit in the node's `properties` bag, the spelling -`@objectstack/spec` declares, which the renderer hoists onto the node; a key -written flat on the node is refused by `objectui validate` (objectui#10859): - -```jsonc -{ - "type": "object-map", // the registered type name — there is no `plugin:map` - "properties": { - "objectName": "store", // the records to plot - "map": { // the declared config input; markers are derived from the data - "locationField": "location", // this page's field, read as { lat, lng } - "titleField": "name", // field used as the marker label - "zoom": 12, // initial zoom level - // Initial centre as [latitude, longitude]. Used only when no record - // matches — with records the view fits to their bounds instead. - "center": [37.7749, -122.4194] - } - } -} -``` - -**Note**: the map plots the records it fetches, so it takes an `objectName` (or inline rows under `staticData`, or a `data: { provider: 'value', items }` configuration — a bare array under `data` is not a record source) rather than a `bind` path, and it derives its markers from those records — a `markers` key on the node is not read. Every marker setting lives under the declared `map` input. - -## Validation - -The field validates coordinate ranges: - -```plaintext -// Valid coordinates -{ lat: 37.7749, lng: -122.4194 } ✓ - -// Invalid - latitude out of range -{ lat: 95, lng: -122.4194 } ✗ - -// Invalid - longitude out of range -{ lat: 37.7749, lng: 200 } ✗ -``` diff --git a/ui/content/docs/fields/lookup.mdx b/ui/content/docs/fields/lookup.mdx deleted file mode 100644 index 6269cfac54e..00000000000 --- a/ui/content/docs/fields/lookup.mdx +++ /dev/null @@ -1,245 +0,0 @@ ---- -title: "Lookup Field" -description: "Reference field for linking to other objects/records" ---- - -The Lookup Field component provides a reference field for creating relationships between objects and records. It supports dynamic data loading from a DataSource with debounced search, loading/error/empty states, keyboard navigation, and optional quick-create entry. - -## Basic Usage - - - -## Multiple References - - - -## Field Schema - -A lookup field is authored as `LookupFieldMetadata` (`@object-ui/types`), which is the -source of truth for the key set: it extends `BaseFieldMetadata` with the reference -target, the display and description fields, an optional static option list, and the -Record Picker's column and paging configuration. Static options are `SelectOptionMetadata`, -and picker columns are `LookupColumnDef` — both exported, both checked here. - -```ts -import type { LookupFieldMetadata } from '@object-ui/types'; - -const accountId: LookupFieldMetadata = { - type: 'lookup', - name: 'account_id', - label: 'Account', - placeholder: 'Search accounts…', - required: true, - reference: 'accounts', - reference_field: 'name', - descriptionField: 'industry', - multiple: false, - searchable: true, - allowCreate: true, - // Record Picker dialog (Enterprise): columns accept a field name or a descriptor. - lookupColumns: ['name', { field: 'industry', label: 'Industry', width: '160px' }], - lookupPageSize: 10, - lookupFilters: [{ field: 'active', operator: 'eq', value: true }], -}; -``` - -The target object is `reference` — the spelling `@objectstack/spec`'s `FieldSchema` -declares, and the only one the lookup and user widgets and their read cells read. -[objectui#11070](https://github.com/objectstack-ai/objectui/issues/11070) retired -objectui's snake_case twin `reference_to`: the spec refuses it by name, and nothing -reads it. A stored object definition that still spells it is folded onto `reference` -when it is served through the ObjectStack adapter; a definition handed to the widget -any other way names no target — rename the key to `reference`. - -The quick-create switch and the Record Picker's configuration are `allowCreate`, -`lookupColumns` and `lookupPageSize`, the spellings `FieldSchema` declares and the only -ones the widget reads. -[objectui#12157](https://github.com/objectstack-ai/objectui/issues/12157) retired -objectui's snake_case twins `allow_create`, `lookup_columns` and `lookup_page_size`, -with no alias: the spec refuses each of them, and nothing reads them. A stored object -definition that still spells one is folded onto the declared key when it is served -through the ObjectStack adapter or the console's metadata store; a definition handed -to the widget any other way loses the setting — rename the key. - -The record field a pick commits as the value, `id` unless told otherwise, is picker -configuration rather than object metadata. `FieldSchema` refuses an `idField` by -name, so a lookup field definition cannot carry it, and `LookupFieldMetadata` does -not declare it. A host sets it where it builds the picker: on a form entry -(`FormField.idField`), on an action parameter, or on a `master_detail` inline grid -column. A reference stored by machine name sets it to `name`. - -When no data source is available the field falls back to a static option list: - -```ts -import type { LookupFieldMetadata } from '@object-ui/types'; - -const priority: LookupFieldMetadata = { - type: 'lookup', - name: 'priority', - label: 'Priority', - reference: 'priorities', - options: [ - { label: 'High', value: 'high' }, - { label: 'Normal', value: 'normal' }, - ], -}; -``` - -A static option may also carry a `description` — secondary text the picker's -typeahead searches alongside the label. Both faces declare it: -`SelectOptionMetadata` in `@object-ui/types`, and `@objectstack/spec` as -`SelectOptionSchema.description`, by the option-A ruling on -[objectui#6140](https://github.com/objectstack-ai/objectui/issues/6140) / -[objectui#6153](https://github.com/objectstack-ai/objectui/issues/6153) — so an -option carrying it is accepted in an authored object document. The spec's option -is still strict: a key it does not declare, such as `icon` (which -`SelectOptionMetadata` carries only for objectui's own renderers), is refused -**by name** (`unrecognized_keys`), and since `FieldSchema` routes a field's -`options` through that schema, it fails the **whole field**. -`select-option-spec-extension-7014.test.ts` (`@object-ui/types`) re-derives both -verdicts against the installed spec. The -`dataSource` a host injects and the `onCreateNew` callback it passes are widget props, -not metadata. - -A **dependent lookup** scopes its candidates by a sibling field's value, declared -with `dependsOn` — the same `BaseFieldMetadata` member the select widgets gate on -([objectui#6153](https://github.com/objectstack-ai/objectui/issues/6153)), in the -spec's field-level shape: an array of controlling field names, or `{ field, param }` -entries when the remote filter parameter differs from the local field name. While -any controlling value is empty the trigger is gated ("Select account first"); once -set, every candidate query — the typeahead popover, the Record Picker and the -people picker — carries the chain as a hard `$filter` no user input can override. - -```ts -import type { LookupFieldMetadata } from '@object-ui/types'; - -const contact: LookupFieldMetadata = { - type: 'lookup', - name: 'contact', - label: 'Contact', - reference: 'contacts', - // Filter `contacts` by `account_id` equal to the form's current `account`. - dependsOn: [{ field: 'account', param: 'account_id' }], -}; -``` - -`dependsOn` is the only spelling this widget reads. -[objectui#7357](https://github.com/objectstack-ai/objectui/issues/7357) retired -objectui's snake_case twin `depends_on`: it was never a `@objectstack/spec` key -(the object contract refuses it by name, so a document carrying it is rejected -at publish), and the widget no longer reads it. Metadata that still spells it -that way does not gate and does not scope the picker — rename it to `dependsOn`. - -The value being edited, and the `className` / `disabled` a host supplies, are **not** -metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## Dynamic Data Source - -When a `DataSource` is available (via `SchemaRendererContext`, explicit prop, or field config), the Lookup popup **automatically** fetches records from the referenced object: - -```plaintext -// Automatic — DataSource from SchemaRendererContext -// (works out-of-the-box in ObjectForm, DrawerForm, etc.) -{ - type: 'lookup', - name: 'customer', - label: 'Customer', - reference: 'customers', - reference_field: 'name', // Display field (default: 'name') - descriptionField: 'industry', // Optional secondary field -} -``` - -The popup will: -1. Fetch records via `dataSource.find(reference, { $top: 50 })` on open -2. Send `$search` queries with 300ms debounce as the user types -3. Show loading spinner, error state with retry, and empty state -4. Display "Showing X of Y" when more records exist than the page size -5. Show a **"Show All Results"** button (inside the popover) to open the full Record Picker dialog when total exceeds page size - -## Browse All Button - -Every Lookup field with a `dataSource` always renders a **"Browse All"** button (table icon) next to the quick-select trigger. This button opens the full **RecordPickerDialog** directly, regardless of dataset size — ensuring enterprise features like multi-column tables, sort/filter bar, and cell renderers are always discoverable. - -- Always visible when `dataSource` is configured -- Opens the Record Picker dialog without needing to open the popover first -- Keyboard accessible and screen-reader friendly (`aria-label="Browse all records"`) - -## Record Picker Dialog (Enterprise) - -The full **RecordPickerDialog** can be opened in two ways: -1. **"Browse All" button** (table icon) — always visible next to the quick-select trigger -2. **"Show All Results"** link inside the popover — shown when total records exceed the page size - -```plaintext -// Configure the Record Picker with lookupColumns -{ - type: 'lookup', - name: 'order', - label: 'Order', - reference: 'orders', - reference_field: 'order_number', - descriptionField: 'customer_name', - lookupColumns: [ - { field: 'order_number', label: 'Order #' }, - { field: 'customer_name', label: 'Customer' }, - { field: 'total_amount', label: 'Amount' }, - { field: 'status', label: 'Status' }, - ], - lookupPageSize: 15, -} -``` - -The Record Picker dialog provides: -- **Multi-column table** with configurable columns via `lookupColumns` -- **Search** with debounced server-side querying -- **Column sorting** via clickable headers (sends `$orderby` to DataSource) -- **Pagination** with page-by-page navigation -- **Keyboard navigation** — Arrow keys to move between rows, Enter/Space to select -- **Single/Multi-select** with visual check indicators and confirmation flow -- **Responsive layout** — Mobile-friendly width (95vw on small screens) -- **Loading, error, and empty states** -- Auto-inferred columns from `reference_field` when `lookupColumns` is not set - -## Lookup vs Master-Detail - -- **Lookup**: Standard reference field, can be deleted independently -- **Master-Detail**: Parent-child relationship, deleting parent deletes children - -## Cell Renderer - -In tables/grids, lookup values display the referenced record name: - -```ts -import { LookupCellRenderer } from '@object-ui/fields'; - -// Single value: Display name/label -// Multiple values: Multiple chips/badges -``` - -## Use Cases - -- **Assignments**: Assign tasks to users -- **Relationships**: Link related records -- **Categories**: Reference to category objects -- **Parent Records**: Master-detail relationships -- **Team Members**: Multi-user references - -## Features - -- **Two-Level Interaction**: Popover typeahead (Level 1) + full Record Picker dialog (Level 2) -- **Record Picker Dialog**: Enterprise-grade table with multi-column, pagination, search, sorting -- **Inline Popover**: Level 1 opens as anchored dropdown (non-modal) for fast typeahead -- **Column Sorting**: Clickable column headers with `$orderby` server-side sort -- **Dynamic DataSource Loading**: Automatically fetches records from referenced objects -- **Search**: Debounced type-ahead search with `$search` parameter -- **Multi-Select**: Support for multiple references with confirmation flow -- **Keyboard Navigation**: Arrow keys to navigate rows, Enter to select in both levels -- **Responsive**: Mobile-friendly width, adapts to screen size -- **Loading/Error/Empty States**: Friendly feedback for all states -- **Secondary Field Display**: Show description/subtitle per option -- **Quick-Create Entry**: Optional "Create new" button when no results -- **Configurable Columns**: `lookupColumns` for multi-column picker display -- **Base Filters**: `lookupFilters` to restrict selectable records -- **Pagination**: Page-by-page navigation in Record Picker dialog -- **Backward Compatible**: Falls back to static options when no DataSource diff --git a/ui/content/docs/fields/meta.json b/ui/content/docs/fields/meta.json deleted file mode 100644 index 9625fd439ec..00000000000 --- a/ui/content/docs/fields/meta.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "title": "Fields", - "pages": [ - "text", - "textarea", - "number", - "currency", - "percent", - "boolean", - "select", - "date", - "datetime", - "time", - "email", - "phone", - "url", - "password", - "file", - "image", - "location", - "lookup", - "formula", - "summary", - "auto-number", - "user", - "rich-text", - "object", - "vector", - "grid", - "widget-props" - ] -} diff --git a/ui/content/docs/fields/number.mdx b/ui/content/docs/fields/number.mdx deleted file mode 100644 index 787b9032af5..00000000000 --- a/ui/content/docs/fields/number.mdx +++ /dev/null @@ -1,97 +0,0 @@ ---- -title: "Number Field" -description: "Numeric input field for integer and decimal numbers" ---- - -The Number Field component provides a numeric input for collecting integer or decimal numbers, with an optional number of decimal places (`scale`). - -## Basic Usage - - - -## With Decimal Places - - - -## With Min/Max - - - -## Field Schema - -A number field is authored as `NumberFieldMetadata` (`@object-ui/types`), which is the -source of truth for the key set: it extends `BaseFieldMetadata` with the numeric -bounds, the stored precision and scale, the stepper increment, and the display -hint `useGrouping` (see [Cell Renderer](#cell-renderer)). - -```ts -import type { NumberFieldMetadata } from '@object-ui/types'; - -const quantity: NumberFieldMetadata = { - type: 'number', - name: 'quantity', - label: 'Quantity', - placeholder: '0', - required: true, - min: 0, - max: 9999, - precision: 10, - scale: 0, - step: 1, - useGrouping: true, -}; -``` - -The value being edited, and the `className` / `disabled` a host supplies, are **not** -metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## What the browser rewrites before this field sees it - -This widget renders a native `type="number"` input, so the browser decides what -the box accepts. Two things can happen and **only one of them is announced**: - -- **Announced.** Text the browser cannot read at all — `1e`, a lone `-`, a lone - `.` — leaves the box visibly showing what was typed while its value reads - empty. The field is marked `aria-invalid` and draws - *"Not saved: the text in this box is not a number."* -- ⚠️ **Not announced.** Entries the browser silently **truncates**: pasting - ``1.2.3`` stores ``1.23``, and `0x10` stores ``10``. No warning is - possible here — the browser discards the extra characters as they arrive, so - nothing reaches ObjectUI to check. - -⛔ **"No warning" therefore does not mean "the value is right."** Full -explanation and the reasoning: [What a number field silently -rewrites](/docs/guide/fields#what-a-number-field-silently-rewrites). - -## Use Cases - -- **Quantities**: Order quantities, stock levels -- **Measurements**: Dimensions, weights, distances -- **Ratings**: Numeric ratings or scores -- **Ages**: User age or duration values - -## Cell Renderer - -In tables/grids, numbers are displayed with tabular formatting: - -```ts -import { NumberCellRenderer } from '@object-ui/fields'; - -// Renders in a tabular-nums font, to the field's `scale`: -// a stored 1234.5 on a field declaring `scale: 2` renders: 1,234.50 -// the same value on a field with no `scale` renders: 1,234.5 -``` - -Whether the value gets thousands separators is the field's `useGrouping`: - -| `useGrouping` | `scale` | Thousands separators | -|---|---|---| -| `false` | any | no: `12345.5` | -| `true` | any | yes: a scale-0 count reads `2,026` | -| not set | `0` | no: read as a year or another ordinal, `2026` | -| not set | anything else, or not set | yes: `12,345.5` | - -`useGrouping` is the `@objectstack/spec` field key of the same name. An authored -`true` always groups, even in a locale that leaves four-digit numbers alone by -default (es-ES renders `1.234`). With the key unset, the grouped rows follow the -locale's own convention. diff --git a/ui/content/docs/fields/object.mdx b/ui/content/docs/fields/object.mdx deleted file mode 100644 index a2ba42ac9d3..00000000000 --- a/ui/content/docs/fields/object.mdx +++ /dev/null @@ -1,220 +0,0 @@ ---- -title: "Object Field" -description: "JSON object editor for structured data" ---- - -The Object Field component provides a JSON editor for storing and editing structured data objects. It validates JSON syntax and provides a textarea interface for complex data. - -It is the form editor for the free-form JSON field types: `object`, `composite`, `record` and `json` all resolve to it. A `json` value need not be an object — an array, a number, a string, a boolean or `null` is shown as its JSON text and parsed back as itself. (`code` is a different field: it stays a raw-text editor and saves its text.) - -## Basic Usage - - - -## Structured Configuration - - - -## Nested Data - - - -## Read-Only - - - -## Field Schema - -A JSON field is authored as `ObjectFieldMetadata` (`@object-ui/types`), which is the -source of truth for the key set. Its type value is `json`, the `@objectstack/spec` -`FieldType` for a free-form JSON value: `object` is not a spec field type, and -`FieldSchema` refuses it at `type`. The type adds no key of its own to -`BaseFieldMetadata`; the shape the value must have is declared on the object instead -(see [Schema Validation](#schema-validation)). - -```ts -import type { ObjectFieldMetadata } from '@object-ui/types'; - -const settings: ObjectFieldMetadata = { - type: 'json', - name: 'settings', - label: 'Settings', - placeholder: '{ }', - inlineHelpText: 'Stored as JSON.', -}; -``` - -The value being edited, and the `className` / `disabled` a host supplies, are **not** -metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## JSON Validation - -The field validates JSON syntax in real-time: - -- **Valid JSON**: Updates the value immediately -- **Invalid JSON**: Refused. The text stays in the box so it can be corrected, the box says "Invalid JSON" and is marked invalid, and the value is not updated. Inside a form the box is also invalid for the browser's constraint validation, so the form will not submit until the text parses — the last valid value is never sent in its place. -- **Empty Input**: Sets value to `null` - -## Data Format - -The object field stores data as parsed JSON: - -```plaintext -// Input (string) -'{"name": "John", "age": 30}' - -// Stored (object) -{ name: "John", age: 30 } - -// Display (formatted) -{ - "name": "John", - "age": 30 -} -``` - -## Common Patterns - -### Configuration Objects - -```plaintext -{ - type: 'json', - name: 'api_config', - label: 'API Configuration', - value: { - endpoint: 'https://api.example.com', - timeout: 5000, - retries: 3, - headers: { - 'Content-Type': 'application/json' - } - } -} -``` - -### Metadata - -```plaintext -{ - type: 'json', - name: 'custom_metadata', - label: 'Custom Metadata', - value: { - tags: ['important', 'urgent'], - priority: 'high', - department: 'engineering' - } -} -``` - -### Preferences - -```plaintext -{ - type: 'json', - name: 'user_preferences', - label: 'Preferences', - value: { - theme: 'dark', - language: 'en', - notifications: { - email: true, - push: false, - sms: false - } - } -} -``` - -## Cell Renderer - -In tables/grids, a `json` value is rendered by the JSON cell renderer — -`getCellRenderer('json')` resolves to it, as `getCellRenderer('object')` does: - -```ts -import { JsonCellRenderer } from '@object-ui/fields'; - -// renders single-line JSON, -// truncated to the column width with the full text in the cell's `title`. -// A null or empty value renders an em-dash instead. -``` - -`composite` and `record` values are not drawn as JSON. Their cell — in a grid and -on the record page alike — is one line of labelled sub-values, with the full text -in the `title`: - -```plaintext -// composite { width: 10, height: 20 } -Width 10 · Height 20 - -// record { primary: { name: "A", score: 9 }, backup: { name: "B", score: 7 } } -Primary (Name A · Score 9) · Backup (Name B · Score 7) -``` - -A label is the humanized key: the field metadata declares no sub-fields to take a -label from. A number sub-value is formatted as the number cell formats it, a boolean -reads as Yes / No in the reader's language, a string reads as itself, and an object or -an array nested inside a sub-value stays compact JSON. -An empty value, `[]`, `{}` and a non-object value read as the JSON cell reads them. - -## Schema Validation - -The shape a JSON value must have is declared on the **object**, not on the field. -`@objectstack/spec` has a validation rule for exactly this, `type: 'json_schema'`, which -names the field and carries a JSON Schema document. Like every object validation rule it -runs on the server's write path, on the writes its `events` name. `FieldSchema` has no -field-level `schema` key: it refuses one -by name, and `ObjectFieldMetadata` refuses it at compile time. - -```plaintext -// on the object definition -validations: [ - { - type: 'json_schema', - name: 'product_specs_shape', - field: 'product_specs', - message: 'Product specifications are not in the expected shape.', - schema: { - type: 'object', - properties: { - weight: { type: 'number' }, - dimensions: { - type: 'object', - properties: { - width: { type: 'number' }, - height: { type: 'number' }, - depth: { type: 'number' } - } - }, - materials: { type: 'array' } - } - } - } -] -``` - -## Use Cases - -- **Configuration**: Application settings, API configurations -- **Metadata**: Custom properties, tags, attributes -- **API Responses**: Storing API response data -- **Preferences**: User preferences, feature flags -- **Structured Data**: Any complex structured information -- **JSON Storage**: Raw JSON data storage - -## Best Practices - -1. **Use for Flexible Data**: When schema changes frequently -2. **Validate on Backend**: Always validate structure server-side -3. **Consider Alternatives**: Use typed fields when structure is fixed -4. **Document Schema**: Provide clear documentation for expected structure -5. **Size Limits**: Set reasonable size limits for JSON data - -## Backend Validation - -ObjectUI does not check the shape of a JSON value. `ObjectField` checks JSON **syntax** -only — it accepts any value `JSON.parse` accepts, and refuses a draft it cannot parse — -so nothing on the client rejects a well-formed object whose shape is wrong. Shape -validation belongs on the server, and the object's `json_schema` rule above is how it is -declared there. diff --git a/ui/content/docs/fields/password.mdx b/ui/content/docs/fields/password.mdx deleted file mode 100644 index 249f8c01566..00000000000 --- a/ui/content/docs/fields/password.mdx +++ /dev/null @@ -1,73 +0,0 @@ ---- -title: "Password Field" -description: "Secure password input with visibility toggle" ---- - -The Password Field component provides a secure text input for passwords with a toggle button to show/hide the password text. - -## Basic Usage - - - -## With Validation - - - -## Confirm Password - - - -## Field Schema - -A password field is authored as `PasswordFieldMetadata` (`@object-ui/types`), which is -the source of truth for the key set: it extends `BaseFieldMetadata` with the two length -bounds, `minLength` and `maxLength` — `@objectstack/spec`'s own `FieldSchema` members, -typed by reference. The reveal toggle and the masked read-only rendering are the widget's. - -```ts -import type { PasswordFieldMetadata } from '@object-ui/types'; - -const password: PasswordFieldMetadata = { - type: 'password', - name: 'password', - label: 'Password', - placeholder: 'Enter a password', - required: true, - minLength: 12, - maxLength: 128, -}; -``` - -The value being edited, and the `className` / `disabled` a host supplies, are **not** -metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## Features - -- **Masked Input**: Password is hidden by default with bullet points (•) -- **Toggle Visibility**: Eye icon button to show/hide password -- **Secure Display**: Read-only mode shows •••••••• for a stored value, never the value itself; an empty value shows the shared "No value" affordance instead -- **Validation**: Built-in support for length requirements - -## Cell Renderer - -In tables/grids, a stored `password` or `secret` value renders as a fixed mask -(`••••••`), never as the value. An empty value (`null`, `undefined`, `''` or `[]`) -renders the shared "No value" affordance instead, so a credential that was never -set is distinguishable from one that is. - -## Security Best Practices - -When using password fields: - -1. **Never log passwords**: Don't log form values containing passwords -2. **Use HTTPS**: Always transmit over secure connections -3. **Validation**: Enforce minimum length and complexity requirements -4. **Storage**: Never store passwords in plain text on the backend -5. **Auto-complete**: Consider disabling auto-complete for sensitive passwords - -## Use Cases - -- **User Registration**: New account password -- **Login Forms**: User authentication -- **Password Change**: Updating existing passwords -- **Security Settings**: API keys, tokens (when masked input is appropriate) diff --git a/ui/content/docs/fields/percent.mdx b/ui/content/docs/fields/percent.mdx deleted file mode 100644 index 0a26cad56fa..00000000000 --- a/ui/content/docs/fields/percent.mdx +++ /dev/null @@ -1,115 +0,0 @@ ---- -title: "Percent Field" -description: "Percentage input with automatic conversion and formatting" ---- - -The Percent Field component provides a percentage input that automatically converts between the stored value and the displayed percentage (0-100). How a value is stored is declared by the field: a fraction (0-1) by default, or whole percentage points when the field declares a `max` above 1. - -## Basic Usage - - - -## With Decimal Places - - - -## Required Field - - - -## Read-Only - - - -## Field Schema - -A percent field is authored as `PercentFieldMetadata` (`@object-ui/types`), which is -the source of truth for the key set: it extends `BaseFieldMetadata` with the decimal -places (`scale`), the total-digit `precision` and the two bounds. - -```ts -import type { PercentFieldMetadata } from '@object-ui/types'; - -const discountRate: PercentFieldMetadata = { - type: 'percent', - name: 'discount_rate', - label: 'Discount Rate', - placeholder: '0%', - scale: 2, - min: 0, - max: 1, -}; -``` - -**Decimal places come from `scale`, not from `precision`.** `scale` is how many -decimals the percent shows: the input's step, the read-only display and the table cell -all follow it, so a stored `0.855` on the field above shows `85.50%`. `precision` is the -total digit count of the stored decimal, so it never sets how many decimals are shown. -Leave `scale` out and the field shows the platform's default width for a percent, which -is whole percents: the same `0.855` shows `86%`. - -The value being edited, and the `className` / `disabled` a host supplies, are **not** -metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## What the browser rewrites before this field sees it - -This widget renders a native `type="number"` input, so the browser decides what -the box accepts. Two things can happen and **only one of them is announced**: - -- **Announced.** Text the browser cannot read at all — `1e`, a lone `-`, a lone - `.` — leaves the box visibly showing what was typed while its value reads - empty. The field is marked `aria-invalid` and draws - *"Not saved: the text in this box is not a number."* -- ⚠️ **Not announced.** Entries the browser silently **truncates**: pasting - ``1.2.3`` stores ``0.0123``, and `0x10` stores ``0.1``. No warning is - possible here — the browser discards the extra characters as they arrive, so - nothing reaches ObjectUI to check. - -⛔ **"No warning" therefore does not mean "the value is right."** Full -explanation and the reasoning: [What a number field silently -rewrites](/docs/guide/fields#what-a-number-field-silently-rewrites). - -## Value Conversion - -The percent field handles automatic conversion, at the storage the field -declares. `@objectstack/spec` states the rule as `percentScaleOf`: a percent -field stores a **fraction** unless it declares a `max` above 1, in which case -it stores **whole percentage points**. - -- **Fraction (the default, no `max` or `max: 1`)**: the stored value is a - decimal between 0 and 1 (e.g., `0.75`), displayed as `75%`. A stored `1` is - `100%`. -- **Whole points (`max: 100`, or any `max` above 1)**: the stored value is - already the percentage (e.g., `75`), displayed as `75%`. A stored `1` is - `1%`. - -Every face reads the same declaration — the input, its read-only text, the -table cell, the grid footer, the record header chip, the gantt tooltip, the -gallery card and the dashboard tile — so one stored value shows one percentage -everywhere. None of them guesses the storage from the value's size. - -Example, for a field with no `max`: -```plaintext -// Stored in database: 0.85 -// Displayed to user: 85% -// User enters: 90 -// Stored as: 0.90 -``` - -## Cell Renderer - -In tables/grids, values are formatted with percentage symbol, to the field's `scale`: - -```ts -import { PercentCellRenderer } from '@object-ui/fields'; - -// A stored 0.855 on a field declaring `scale: 2` renders: 85.50% -// The same value on a field with no `scale` renders: 86% -``` - -## Use Cases - -- **Progress Tracking**: Task completion, project progress -- **Analytics**: Conversion rates, success metrics -- **Financial Data**: Interest rates, discount percentages -- **Statistics**: Performance scores, efficiency ratings diff --git a/ui/content/docs/fields/phone.mdx b/ui/content/docs/fields/phone.mdx deleted file mode 100644 index 69062700c43..00000000000 --- a/ui/content/docs/fields/phone.mdx +++ /dev/null @@ -1,61 +0,0 @@ ---- -title: "Phone Field" -description: "Phone number input with formatting" ---- - -The Phone Field component provides a text input optimized for phone number entry with proper formatting. - -## Basic Usage - - - -## Required Phone - - - -## Field Schema - -A phone field is authored as `PhoneFieldMetadata` (`@object-ui/types`), which is the -source of truth for the key set: it extends `BaseFieldMetadata` with a display format. - -```ts -import type { PhoneFieldMetadata } from '@object-ui/types'; - -const mobile: PhoneFieldMetadata = { - type: 'phone', - name: 'mobile', - label: 'Mobile', - placeholder: '(555) 000-0000', - required: false, - format: '(###) ###-####', -}; -``` - -The value being edited, and the `className` / `disabled` a host supplies, are **not** -metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## Cell Renderer - -In tables/grids, phone numbers are clickable tel: links: - -```ts -import { PhoneCellRenderer } from '@object-ui/fields'; - -// Renders as: +1 (555) 123-4567 -// Clicking initiates a phone call on mobile devices -``` - -## Use Cases - -- **Contact Information**: Customer phone numbers -- **Support**: Support line numbers -- **Emergency Contacts**: Emergency contact numbers -- **Delivery**: Delivery contact numbers -- **Appointments**: Callback numbers - -## Features - -- **Mobile Optimized**: Opens phone dialer on mobile -- **Clickable Links**: tel: links in read-only mode -- **International Support**: Supports international formats -- **Accessible**: Proper input type for mobile keyboards diff --git a/ui/content/docs/fields/rich-text.mdx b/ui/content/docs/fields/rich-text.mdx deleted file mode 100644 index 95a90f7a20e..00000000000 --- a/ui/content/docs/fields/rich-text.mdx +++ /dev/null @@ -1,158 +0,0 @@ ---- -title: "Rich Text Field" -description: "Rich text editor for formatted content" ---- - -The Rich Text Field component edits formatted text content with markdown or HTML support — a plain-textarea editor today, with the stored markup rendered formatted on every read surface. - -## Basic Usage - - - -## HTML Editor - - - -## Field Schema - -`markdown`, `html` and `richtext` are three field types served by one widget, and each -has its own exported metadata type — `MarkdownFieldMetadata`, `HtmlFieldMetadata` and -`RichtextFieldMetadata` (`@object-ui/types`). All three extend `BaseFieldMetadata` and -add a length bound and an inline-editor height; there is no combined "rich text" -metadata type. - -```ts -import type { - HtmlFieldMetadata, - MarkdownFieldMetadata, - RichtextFieldMetadata, -} from '@object-ui/types'; - -const releaseNotes: MarkdownFieldMetadata = { - type: 'markdown', - name: 'release_notes', - label: 'Release Notes', - placeholder: 'Write the notes…', - maxLength: 20000, - // Inline editor height in text rows (positive integer; default 8). The - // fullscreen dialog sizes itself and ignores it. - rows: 12, -}; - -const emailBody: HtmlFieldMetadata = { - type: 'html', - name: 'email_body', - label: 'Email Body', - maxLength: 50000, -}; - -const articleBody: RichtextFieldMetadata = { - type: 'richtext', - name: 'article_body', - label: 'Article Body', - rows: 10, - maxLength: 50000, -}; -``` - -`richtext` gained its exported type in -[objectui#7083](https://github.com/objectstack-ai/objectui/issues/7083); until then it -was the one key of the three with no member to annotate against, so its metadata could -only be written through a cast to one of its siblings. It stores **HTML** — it reads -through the same display pipeline as `html`, not the markdown one -([objectui#5452](https://github.com/objectstack-ai/objectui/issues/5452)) — and that -pipeline is the only thing `type` changes between the three. - -`maxLength` is `@objectstack/spec`'s own `FieldSchema.maxLength`, typed by reference: a -bound on the stored text. The widget reads it — the editor's native stop, a character -counter and its `aria-describedby` description, inline and in the fullscreen dialog -([objectui#8438](https://github.com/objectstack-ai/objectui/issues/8438)) — and the form's -validation rules enforce it again when the form is submitted, whatever the field's type. -The snake_case `max_length` is retired: the spec refuses it by name and nothing reads it -([objectui#11070](https://github.com/objectstack-ai/objectui/issues/11070)). - -`rows` sizes the inline editor, in text rows — declared on all three types (and on -`@objectstack/spec`'s `FieldSchema` for the multiline editor types), matching the -`textarea` field's key of the same name ([objectui#6140](https://github.com/objectstack-ai/objectui/issues/6140)). -The editor is a plain textarea today — there is no formatting toolbar, no preview pane -and no pixel height to configure, so none of the three metadata types declares one: -`toolbar`, `preview`, `minHeight` and `maxHeight` are **not** metadata keys, and writing -them does nothing. - -The value being edited, and the `className` / `disabled` a host supplies, are **not** -metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## Supported Formats - -### Markdown -- **Headings**: `# H1`, `## H2`, `### H3` -- **Bold/Italic**: `**bold**`, `*italic*` -- **Lists**: Ordered and unordered -- **Links**: `[text](url)` -- **Images**: `![alt](url)` -- **Code**: Inline and blocks -- **Tables**: Markdown tables - -### HTML -- Headings, paragraphs, emphasis, lists, quotes, code, tables, links and images -- Sanitized on display against an allow-list (`rehype-sanitize`, GitHub-style): - links keep `http`, `https`, `mailto` and relative URLs, and images keep `http`, - `https` and relative URLs -- Inline `style`, event-handler attributes, embedded frames, objects and SVG or - MathML content are not rendered; an element outside the allow-list shows its text - without its tag -- The `markdown` and `html` / `richtext` display pipelines share the same allow-list - -## Cell Renderer - -In tables/grids, rich text is shown as plain text: - -```ts -import { TextCellRenderer } from '@object-ui/fields'; - -// Strips formatting, shows plain text only -// Long content is truncated -``` - -## Use Cases - -- **Blog Posts**: Article content, blog posts -- **Documentation**: Technical documentation -- **Descriptions**: Product or feature descriptions -- **Comments**: Rich formatted comments -- **Emails**: Email templates, rich email content -- **Pages**: CMS page content - -## Features - -- **Markdown Support**: For lightweight formatting -- **HTML Support**: For advanced formatting -- **Formatted read surfaces**: Grids, detail pages and the readonly form branch - render the stored markup formatted (sanitized for HTML) -- **Fullscreen editing**: An expand affordance when the form opts in - (`mobile.fullscreenLongText`) - -The editing surface itself is a plain textarea with no header row above it; guidance -for the person typing comes from the field's own `placeholder` and help text. A -formatting toolbar, an edit-time preview pane and WYSIWYG editing are **not yet -implemented** (whatever the editor grows into, both the inline and fullscreen -surfaces gain it at once, since they share one editor component). - -## Editor Modes - -The rich text field can operate in different modes: - -1. **Markdown Mode** (`type: 'markdown'`) - - Lightweight markup - - Easy to write and read - - Great for documentation - -2. **HTML Mode** (`type: 'html'`) - - Full HTML editing - - Advanced formatting - - Embedded content support - -3. **Rich Text Mode** (`type: 'richtext'`) - - Stores HTML, and reads through the same display pipeline as `html` - - Editable under `RichtextFieldMetadata` - - Same plain-textarea editing surface as the other two diff --git a/ui/content/docs/fields/select.mdx b/ui/content/docs/fields/select.mdx deleted file mode 100644 index 2fc92e50141..00000000000 --- a/ui/content/docs/fields/select.mdx +++ /dev/null @@ -1,202 +0,0 @@ ---- -title: "Select Field" -description: "Dropdown selection field with single or multiple options" ---- - -The Select Field component provides a dropdown for selecting one or more options from a predefined list. - -## Basic Usage - - - -## With Colors - - - -## Multiple Selection - - - -## Cascading & Role-Gated Options - -An option can carry a `visibleWhen` CEL predicate — it is offered only when the -predicate is TRUE. The predicate is evaluated against the **live record** plus -**`current_user`**, the same engine and binding environment as a field-level -`visibleWhen`. This single mechanism covers two needs: - -- **Cascading / dependent options** — narrow a child list by a parent field - (country → province → city). -- **Role / context gating** — offer an option only to certain users. - -Declare the sibling field(s) a select reacts to with `dependsOn`. While any is -empty the control is **gated** (a "Select country first" hint) instead of -showing an unfiltered list; once the parent changes, the list re-evaluates and -any now-invalid selection is **cleared automatically** (no stale "China + -California" pair). The clear is saved like any other edit: a single select (or -radio) is emptied to `null`, which a save writes as "clear the stored value", and -a multiple select keeps only the values still offered. - -### Cascading (country → province) - - - -```json -{ - "type": "form", - "fields": [ - { "name": "country", "label": "Country", "type": "select", "options": [ - { "label": "China", "value": "cn" }, - { "label": "United States", "value": "us" } - ]}, - { "name": "province", "label": "Province", "type": "select", "dependsOn": "country", "options": [ - { "label": "Zhejiang", "value": "zj", "visibleWhen": "record.country == 'cn'" }, - { "label": "Guangdong", "value": "gd", "visibleWhen": "record.country == 'cn'" }, - { "label": "California", "value": "ca", "visibleWhen": "record.country == 'us'" }, - { "label": "Texas", "value": "tx", "visibleWhen": "record.country == 'us'" } - ]} - ] -} -``` - -Chain a third level (`city`, `dependsOn: "province"`) the same way — the gate and -cascade-clear propagate down the chain. - -### Role-gated option - - - -```json -{ "name": "visibility", "type": "select", "options": [ - { "label": "Private (only me)", "value": "private" }, - { "label": "My team", "value": "team" }, - { "label": "Whole organization", "value": "org" }, - { "label": "Public — external", "value": "public", "visibleWhen": "'admin' in current_user.positions" } -]} -``` - -> **Security — hiding is UX, not authorization.** A `visibleWhen` on an option -> only removes it from the dropdown on the client; a determined caller can still -> submit the value. When an option is gated for **access-control** reasons the -> **server must also reject** writes of that value (the rule-validator evaluates -> the picked value's `visibleWhen`). Use option `visibleWhen` for convenience and -> cascades freely; for real authorization, pair it with server-side enforcement. - -### When to use options vs. a lookup - -`visibleWhen` options are for **small, static dictionaries** (category → -subcategory, a handful of provinces). When the data is large, changes over time, -or is shared across forms (real country/province/city tables, org units, product -catalogs), model each level as a **`lookup`** with `dependsOn` instead — the -candidate query is filtered server-side and paginated. See -[Lookup Field](/docs/fields/lookup). - -## Field Schema - -A select field is authored as `SelectFieldMetadata` (`@object-ui/types`), which is the -source of truth for the key set: it extends `BaseFieldMetadata` with the option list -and the multiple/searchable switches. Each option is a `SelectOptionMetadata`, so the -options are checked by the same compiler that checks the field. - -```ts -import type { SelectFieldMetadata } from '@object-ui/types'; - -const status: SelectFieldMetadata = { - type: 'select', - name: 'status', - label: 'Status', - placeholder: 'Select a status', - required: true, - multiple: false, - searchable: true, - options: [ - { label: 'Draft', value: 'draft', color: 'gray' }, - { label: 'Active', value: 'active', color: 'blue' }, - // Offered only when the predicate is true, evaluated against the live record. - { label: 'Archived', value: 'archived', color: 'red', visibleWhen: "current_user.is_admin" }, - { label: 'Locked', value: 'locked', disabled: true }, - ], -}; -``` - -### A blank `label` falls back to the `value` - -`label` is **required** on an option and an empty string is a legal value for it — -`@objectstack/spec`'s `SelectOptionSchema` accepts `{ "value": "low", "label": "" }` -and refuses an option with no `label` key at all. An option authored that way (the -field designer writes it whenever you fill only the value box) is therefore a document -the platform stores and serves, and every option widget — `select`, `multiselect`, -`radio`, `checkboxes` — **displays its `value`** for it rather than an empty row, on -both the editable and the read-only path -(`20b507aff`). A label that -is only whitespace is treated the same way. - -This is a display fallback, not a repair: the stored option keeps its empty `label`. -Author a real label whenever the value is not something you want a person to read — -`low` is a tolerable row, `acct_st_2` is not. - -Cascading option lists are driven by a sibling field's value, declared with -`dependsOn` — a `BaseFieldMetadata` member every field type inherits -([objectui#6153](https://github.com/objectstack-ai/objectui/issues/6153)), in the -shape `@objectstack/spec` declares at field level: an **array** of controlling -field names, or `{ field, param }` entries when the remote parameter name differs -from the local field name. While any controlling value is empty the widget is -gated; once it is set, each option's `visibleWhen` decides whether it is offered, -and a selection the parent no longer offers is cleared. - -```ts -import type { SelectFieldMetadata } from '@object-ui/types'; - -const province: SelectFieldMetadata = { - type: 'select', - name: 'province', - label: 'Province', - dependsOn: ['country'], - options: [ - { label: 'Zhejiang', value: 'zj', visibleWhen: "record.country == 'cn'" }, - { label: 'California', value: 'ca', visibleWhen: "record.country == 'us'" }, - ], -}; -``` - -A bare parent name (`dependsOn: "country"`, as in the form schema above) is the -**form-level** shape, `FormField.dependsOn`; on field metadata the spec accepts -only the array, and `SelectFieldMetadata` refuses the string for the same reason. -The metadata key wins over the `dependsOn` widget prop a host may pass. -[objectui#7357](https://github.com/objectstack-ai/objectui/issues/7357) retired -objectui's snake_case twin `depends_on`, which was never a `@objectstack/spec` -key; `dependsOn` is the only spelling any widget reads. Metadata that still -spells it the old way has no effect — rename it. - -The value being edited, and the `className` / `disabled` a host supplies, are **not** -metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## Available Colors - -- `gray` - Default neutral color -- `red` - For errors, urgent items -- `orange` - For warnings, high priority -- `yellow` - For pending, attention needed -- `green` - For success, completed -- `blue` - For info, in progress -- `indigo` - For special items -- `purple` - For creative, design -- `pink` - For featured items - -## Cell Renderer - -In tables/grids, select values are displayed as colored badges: - -```ts -import { SelectCellRenderer } from '@object-ui/fields'; - -// Single value: Colored badge -// Multiple values: Multiple badges in a row -``` - -## Use Cases - -- **Status Fields**: Order status, task status -- **Categories**: Product categories, content types -- **Tags**: Multi-tag selection -- **Priorities**: Task or ticket priorities -- **Roles**: User roles or permissions diff --git a/ui/content/docs/fields/summary.mdx b/ui/content/docs/fields/summary.mdx deleted file mode 100644 index a4a00dd5d6c..00000000000 --- a/ui/content/docs/fields/summary.mdx +++ /dev/null @@ -1,180 +0,0 @@ ---- -title: "Summary Field" -description: "Read-only aggregation field for rollup calculations" ---- - -The Summary Field component displays aggregated values from related records. This is a read-only field where the value is automatically calculated by the backend through rollup aggregations. - -## Count Summary - - - -## Sum Summary - - - -## Average Summary - - - -## Field Schema - -A summary field is authored as `SummaryFieldMetadata` (`@object-ui/types`), which is -the source of truth for the key set: it extends `BaseFieldMetadata` with the roll-up -definition `summaryOperations`. `summaryOperations` is -`@objectstack/spec`'s own `FieldSchema.summaryOperations`, typed by reference: the -child `object`, the child `field` to aggregate, the aggregation `function` — a closed -union of `'count' | 'sum' | 'min' | 'max' | 'avg'` — and optionally the child's -`relationshipField` and a `filter` restricting which child rows are aggregated. - -```ts -import type { SummaryFieldMetadata } from '@object-ui/types'; - -const totalRevenue: SummaryFieldMetadata = { - type: 'summary', - name: 'total_revenue', - label: 'Total Revenue', - readonly: true, - summaryOperations: { - object: 'opportunities', - field: 'amount', - function: 'sum', - filter: { stage: 'closed_won' }, - }, -}; -``` - -The computed value, and the `className` a host supplies, are **not** metadata keys — -they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## Aggregation Functions - -The function decides what the backend computes, not how the value is drawn. The -form and the table cell read the same rule for every function, so one stored -value reads the same in both: - -- **a number**: Formatted as a number field formats it, in the viewer's display - locale (`15750.5` reads `15,750.5` in en-US). With no `scale` on the field it - keeps its own precision, so a `count` reads as a whole number (`12000` reads - `12,000`); a `scale` on the field sets a fixed width in both places (`scale: 2` - reads `15,750.50`) -- **anything else**: The value as text; an empty value reads as empty - -The five functions: - -- **count**: Count of related records -- **sum**: Sum of numeric field values -- **avg**: Average of numeric field values -- **min**: Minimum value in the field -- **max**: Maximum value in the field - -## Examples - -### Count Related Records - -```plaintext -{ - type: 'summary', - name: 'task_count', - label: 'Open Tasks', - summaryOperations: { - object: 'tasks', - field: 'id', - function: 'count' - } -} -``` - -### Sum Amounts - -```plaintext -{ - type: 'summary', - name: 'total_sales', - label: 'Total Sales', - summaryOperations: { - object: 'invoices', - field: 'amount', - function: 'sum' - } -} -``` - -### Average Value - -```plaintext -{ - type: 'summary', - name: 'avg_response_time', - label: 'Avg Response Time', - summaryOperations: { - object: 'support_tickets', - field: 'response_time_hours', - function: 'avg' - } -} -``` - -### Min/Max Values - -```plaintext -{ - type: 'summary', - name: 'highest_bid', - label: 'Highest Bid', - summaryOperations: { - object: 'bids', - field: 'amount', - function: 'max' - } -} -``` - -## Cell Renderer - -In tables/grids, a `summary` value is rendered by the formula cell renderer — -`getCellRenderer('summary')` resolves to this same component: - -```ts -import { FormulaCellRenderer } from '@object-ui/fields'; - -// renders the computed -// value as a number, through the number cell: 15,750.5 (en-US), not monospace -// A null or empty value renders an em-dash instead. -``` - -## Backend Implementation - -Summary fields are calculated through database aggregations: - -```plaintext -// Example backend aggregation -const calculateSummary = async (summaryOperations: SummaryOperations, parentId: string) => { - const { object, field, function: fn } = summaryOperations; - - switch (fn) { - case 'count': - return db.count(object, { parent_id: parentId }); - - case 'sum': - return db.sum(object, field, { parent_id: parentId }); - - case 'avg': - return db.avg(object, field, { parent_id: parentId }); - - case 'min': - return db.min(object, field, { parent_id: parentId }); - - case 'max': - return db.max(object, field, { parent_id: parentId }); - } -}; -``` - -## Use Cases - -- **Order Management**: Total order value, order count -- **CRM**: Number of contacts, total deal value -- **Project Management**: Task count, total hours -- **Analytics**: Average ratings, min/max values -- **Financial**: Sum of expenses, average transaction size diff --git a/ui/content/docs/fields/text.mdx b/ui/content/docs/fields/text.mdx deleted file mode 100644 index 9f80b763387..00000000000 --- a/ui/content/docs/fields/text.mdx +++ /dev/null @@ -1,88 +0,0 @@ ---- -title: "Text Field" -description: "Single-line text input field for basic text entry" ---- - -The Text Field component provides a single-line text input for collecting basic text data from users. It's the most commonly used field type for names, titles, and short text entries. - -## Basic Usage - - - -## With Placeholder - - - -## Required Field - - - -## Read-Only - - - -## Field Schema - -A text field is authored as `TextFieldMetadata` (`@object-ui/types`), which is the -source of truth for the key set: it extends `BaseFieldMetadata` with the -text-specific validation keys. The length bounds are `@objectstack/spec`'s own -`FieldSchema.minLength` / `maxLength`, typed by reference; the snake_case -`min_length` / `max_length` are retired, refused by the spec by name and read by -nothing. - -```ts -import type { TextFieldMetadata } from '@object-ui/types'; - -const productName: TextFieldMetadata = { - type: 'text', - name: 'product_name', - label: 'Product Name', - placeholder: 'Enter a product name', - inlineHelpText: 'Shown on the storefront and in search results.', - required: true, - minLength: 2, - maxLength: 120, -}; -``` - -## Format Validation - -A regex check is declared on the **object**, not on the field. `@objectstack/spec` -has a validation rule for it: an entry of the object definition's `validations[]` -whose `type` is `'format'`. For the field above it carries: - -- `name`: the rule's own snake_case name, e.g. `'product_name_characters'`; -- `field`: the field it checks, `'product_name'`; -- `regex`: `'^[A-Za-z0-9 -]+$'`. It is not anchored for you, so write `^` and `$` - to match the whole value; -- `message`: `'Letters, digits, spaces and hyphens only.'`, the reason given when - the value is refused. - -The server runs the rule on every write that touches the field and refuses a -non-empty value the `regex` does not match, with the rule's `message`. `FieldSchema` -has no field-level `pattern` or `pattern_message`: it refuses both by name, and -`TextFieldMetadata` refuses them at compile time. - -The form does not pre-check this rule in the browser. A value that breaks it is -refused when the record is saved, and the rule's `message` is the reason given. - -The value being edited, and the `className` / `disabled` a host supplies, are **not** -metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## Use Cases - -- **User Names**: Collecting first names, last names, or usernames -- **Titles**: Post titles, document names, or product names -- **Short Text**: Any short text input that doesn't require multiple lines -- **IDs**: Custom identifiers or reference numbers - -## Cell Renderer - -When used in data tables or grids, the text field is rendered as simple truncated text: - -```ts -import { TextCellRenderer } from '@object-ui/fields'; - -// Automatically used for type: 'text' in grids/tables -// Displays value or '-' if empty -``` diff --git a/ui/content/docs/fields/textarea.mdx b/ui/content/docs/fields/textarea.mdx deleted file mode 100644 index 510af499de1..00000000000 --- a/ui/content/docs/fields/textarea.mdx +++ /dev/null @@ -1,61 +0,0 @@ ---- -title: "TextArea Field" -description: "Multi-line text input for longer content" ---- - -The TextArea Field component provides a multi-line text input for collecting longer text content from users, such as descriptions, comments, or notes. - -## Basic Usage - - - -## Custom Rows - - - -## Required Field - - - -## Field Schema - -A textarea field is authored as `TextareaFieldMetadata` (`@object-ui/types`), which is -the source of truth for the key set: it extends `BaseFieldMetadata` with the length -bounds and the editor's visible row count. The length bounds are `@objectstack/spec`'s -own `FieldSchema.minLength` / `maxLength`, typed by reference; `maxLength` also sets the -editor's native stop and its `{n}/{max}` character counter. - -```ts -import type { TextareaFieldMetadata } from '@object-ui/types'; - -const description: TextareaFieldMetadata = { - type: 'textarea', - name: 'description', - label: 'Description', - placeholder: 'Describe this record…', - rows: 6, - required: false, - minLength: 0, - maxLength: 2000, -}; -``` - -The value being edited, and the `className` / `disabled` a host supplies, are **not** -metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## Use Cases - -- **Descriptions**: Product descriptions, bio information -- **Comments**: User feedback, review comments -- **Notes**: Meeting notes, task descriptions -- **Messages**: Contact form messages, support tickets - -## Cell Renderer - -When displayed in tables or grids, long text is truncated: - -```ts -import { TextCellRenderer } from '@object-ui/fields'; - -// Same renderer as text field - truncates long content -``` diff --git a/ui/content/docs/fields/time.mdx b/ui/content/docs/fields/time.mdx deleted file mode 100644 index 1bed6a2847a..00000000000 --- a/ui/content/docs/fields/time.mdx +++ /dev/null @@ -1,59 +0,0 @@ ---- -title: "Time Field" -description: "Time-only input field for hour and minute selection" ---- - -The Time Field component provides a time-only input for collecting hour and minute information without a date component. - -## Basic Usage - - - -## With Default Value - - - -## Required Field - - - -## Read-Only - - - -## Field Schema - -A time field is authored as `TimeFieldMetadata` (`@object-ui/types`), which is the -source of truth for the key set: it extends `BaseFieldMetadata` with a display format. - -```ts -import type { TimeFieldMetadata } from '@object-ui/types'; - -const startTime: TimeFieldMetadata = { - type: 'time', - name: 'start_time', - label: 'Start Time', - placeholder: 'HH:mm', - required: true, - format: 'HH:mm', -}; -``` - -The value being edited, and the `className` / `disabled` a host supplies, are **not** -metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## Time Format - -The time field uses 24-hour format: `HH:mm` - -Examples: -- `09:00` - 9:00 AM -- `14:30` - 2:30 PM -- `23:45` - 11:45 PM - -## Use Cases - -- **Operating Hours**: Business hours, shift times -- **Scheduling**: Daily task times, recurring events -- **Alarms**: Reminder times, notification schedules -- **Time Tracking**: Work start/end times diff --git a/ui/content/docs/fields/url.mdx b/ui/content/docs/fields/url.mdx deleted file mode 100644 index 1a9a6d7fc5a..00000000000 --- a/ui/content/docs/fields/url.mdx +++ /dev/null @@ -1,70 +0,0 @@ ---- -title: "URL Field" -description: "URL input with validation and link rendering" ---- - -The URL Field component provides a text input with URL validation and clickable link rendering. - -## Basic Usage - - - -## Required URL - - - -## Field Schema - -A URL field is authored as `UrlFieldMetadata` (`@object-ui/types`), which is the source -of truth for the key set: it extends `BaseFieldMetadata` with a single length bound, -`@objectstack/spec`'s own `FieldSchema.maxLength`, typed by reference. - -```ts -import type { UrlFieldMetadata } from '@object-ui/types'; - -const website: UrlFieldMetadata = { - type: 'url', - name: 'website', - label: 'Website', - placeholder: 'https://example.com', - required: false, - maxLength: 2048, -}; -``` - -The value being edited, and the `className` / `disabled` a host supplies, are **not** -metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## Validation - -The URL field automatically validates: -- Proper URL protocol (http:// or https://) -- Valid domain structure -- No invalid characters - -## Cell Renderer - -In tables/grids, URLs are clickable external links: - -```ts -import { UrlCellRenderer } from '@object-ui/fields'; - -// Renders as: https://example.com -// Opens in new tab with security attributes -``` - -## Use Cases - -- **Websites**: Company or personal websites -- **Social Media**: Social media profile links -- **Documentation**: External documentation links -- **Resources**: Reference materials, articles -- **Portfolios**: Portfolio or project links - -## Features - -- **Link Preview**: Clickable links in read-only mode -- **New Tab**: External links open in new tab -- **Security**: Proper rel="noopener noreferrer" attributes -- **Validation**: Real-time URL format validation -- **Accessible**: Proper ARIA labels for screen readers diff --git a/ui/content/docs/fields/user.mdx b/ui/content/docs/fields/user.mdx deleted file mode 100644 index b7373506512..00000000000 --- a/ui/content/docs/fields/user.mdx +++ /dev/null @@ -1,169 +0,0 @@ ---- -title: "User Field" -description: "User and owner selector with avatar display" ---- - -The User Field component provides a user selector for assigning users or owners to records. It displays user avatars and supports both single and multiple user selection. - -## Basic Usage - - - -## Multiple Users - - - -## Read-Only Record Owner - -A record-owner field is a plain `user` field whose NAME carries the ownership -meaning — there is no separate owner type. Marking it `readonly` is what makes -it display-only. - - - -## Field Schema - -A user field is authored as `UserFieldMetadata` (`@object-ui/types`), which is the -source of truth for the key set: it extends `BaseFieldMetadata` with the avatar field -and the candidate filters. `user` is a lookup specialised to the `sys_user` system -object, so its filters are `LookupFilterDef`. - -```ts -import type { UserFieldMetadata } from '@object-ui/types'; - -const owner: UserFieldMetadata = { - type: 'user', - name: 'owner_id', - label: 'Owner', - required: true, - multiple: false, - avatar_field: 'image', - lookupFilters: [{ field: 'banned', operator: 'ne', value: true }], -}; -``` - -The field stores the selected user's id (or an array of ids when `multiple` is set), -not a user object; the picker resolves names and avatars from `sys_user` itself. - -The picker is the search-first people picker, with each candidate's business unit and -email beside its name. Both are picker configuration rather than object metadata: -`FieldSchema` refuses `picker` and `subtitle` by name, so a user field definition -cannot carry them, and `UserFieldMetadata` does not declare them. A form entry may set -them (`FormField`); `picker: 'default'` opens the classic record-picker dialog instead: - -```ts -import type { FormField } from '@object-ui/types'; - -const reviewer: FormField = { - name: 'reviewer_id', - type: 'user', - label: 'Reviewer', - picker: 'search', - subtitle: ['primary_business_unit_id.name', 'email'], -}; -``` - -The value being edited, and the `className` / `disabled` a host supplies, are **not** -metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## User vs Owner - -Both are the same field **type**. What differs is the field's name and whether -it is writable: - -- **Assignment field**: a selectable `user` field for assignees, team members, etc. -- **Owner field**: a `user` field named `owner`, typically `readonly` and - defaulted to the record creator. - -There is no `owner` field type. It existed as a synonym until objectui#4814 -retired it (it resolved to the very same widget, and it was never a member of -`@objectstack/spec`'s `FieldType`). Authoring `type: 'owner'` now renders a -visible refusal naming this migration rather than silently falling back to a -text input. Write `{ type: 'user', name: 'owner' }` instead. - -## Display Features - -- **Avatar Badges**: User avatars with initials if no image -- **Color Coding**: Consistent colors for user avatars -- **Multiple Display**: Shows up to 3 users, then "+N more" -- **Hover Info**: User details on avatar hover - -## Cell Renderer - -In tables/grids, displays avatar with name: - -```ts -import { UserCellRenderer } from '@object-ui/fields'; - -// Single user: Avatar + name -// Multiple users: Overlapping avatars + count -``` - -## How it works - -`user` fields are a **lookup specialized to the framework's `sys_user` -object** — there is no custom user API to wire up. The `UserField` widget -delegates to the shared lookup picker with the reference fixed to `sys_user`, -reusing the same debounced search, record-picker dialog and id resolution as any -lookup field: - -- The field stores the selected user's **id** (a foreign key to `sys_user`); - `multiple: true` stores an array of ids. -- A `dataSource` (provided by `SchemaRenderer` / the app shell) supplies the - candidate search — the picker queries `sys_user` by name/email as you type. -- On read, `$expand` resolves the id(s) to the full user record so cells and - read-only views show the name/avatar (via `UserCellRenderer`). - -No custom user-management integration is required when a `dataSource` is present. - -## Permission Patterns - -Common permission configurations: - -```plaintext -// Record owner only — a `user` field whose NAME carries the ownership meaning -{ - type: 'user', - name: 'owner', - label: 'Owner', - readonly: true, - defaultValue: 'current_user' -} - -// Assignable user -{ - type: 'user', - name: 'assigned_to', - label: 'Assigned To', - required: true -} - -// Team members -{ - type: 'user', - name: 'collaborators', - label: 'Collaborators', - multiple: true -} -``` - -## Use Cases - -- **Task Management**: Task assignee, reviewer -- **CRM**: Account owner, opportunity owner -- **Support**: Ticket assignee, support agent -- **Project Management**: Project members, task owners -- **Approval Workflows**: Approvers, reviewers -- **Collaboration**: Document collaborators, editors - -## Backend behavior - -The platform handles the common cases natively — you generally don't write -backend code for user fields: - -- **Owner stamping** — declare `defaultValue: 'current_user'` and the acting - user's id is filled in at insert time (no create hook required). -- **Display resolution** — request `expand=` and the stored id(s) resolve - to the full `sys_user` record (name / avatar) for rendering. -- **Ownership & permissions** — record ownership and row-level security continue - to use the platform's `owner_id` convention and security rules. diff --git a/ui/content/docs/fields/vector.mdx b/ui/content/docs/fields/vector.mdx deleted file mode 100644 index ba2c75ac896..00000000000 --- a/ui/content/docs/fields/vector.mdx +++ /dev/null @@ -1,225 +0,0 @@ ---- -title: "Vector Field" -description: "Vector embeddings viewer for AI/ML data" ---- - -The Vector Field component displays vector embeddings used in AI/ML applications. This is a read-only field that shows a preview of the embedding values and dimensionality. - -## Basic Usage - - - -## Different Dimensions - - - -## Field Schema - -A vector field is authored as `VectorFieldMetadata` (`@object-ui/types`), which is the -source of truth for the key set: it extends `BaseFieldMetadata` with the embedding -dimensionality, `dimensions`, the one vector key `@objectstack/spec`'s `FieldSchema` -declares. There is no normalisation key: the spec refuses `normalize` by name and the -widget never read it, so `VectorFieldMetadata` refuses it at compile time. - -```ts -import type { VectorFieldMetadata } from '@object-ui/types'; - -const embedding: VectorFieldMetadata = { - type: 'vector', - name: 'embedding', - label: 'Embedding', - readonly: true, - dimensions: 1536, -}; -``` - -The computed value, and the `className` a host supplies, are **not** metadata keys — -they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). - -## Display Format - -Vectors are displayed with a preview: - -``` -[0.1234, -0.5678, 0.9012...] (768D) -``` - -Shows: -- First 3 values (formatted to 4 decimal places) -- Total dimensionality in parentheses - -## Common Embedding Dimensions - -Different AI models use different embedding sizes: - -- **OpenAI text-embedding-3-small**: 512, 1536, or 3072 dimensions (configurable) -- **OpenAI text-embedding-3-large**: 256, 1024, or 3072 dimensions (configurable) -- **OpenAI text-embedding-ada-002**: 1536 dimensions (fixed) -- **Sentence Transformers (BERT)**: 768 dimensions -- **OpenAI CLIP**: 512 dimensions -- **Word2Vec**: 100-300 dimensions -- **Custom Models**: Variable dimensions - -## Data Format - -Vectors are stored as arrays of floating-point numbers: - -```plaintext -// Example embedding -const embedding: number[] = [ - 0.1234, -0.5678, 0.9012, 0.3456, -0.7890, - // ... 763 more values for 768D embedding -]; -``` - -## Cell Renderer - -In tables/grids, a `vector` value is shown as a compact placeholder, not as its -components. It has no named renderer export of its own — the component is -resolved by field type, which is the supported path for every type: - -```ts -import { getCellRenderer } from '@object-ui/fields'; - -const VectorCell = getCellRenderer('vector'); - -// renders: [Vector] -``` - -An empty value (`null`, `undefined`, `''` or `[]`) renders the shared "No value" -affordance instead, so an empty cell is distinguishable from a populated one. - -## Vector Operations - -Common operations with vector fields (performed on backend): - -### Similarity Search - -```plaintext -// Find similar vectors using cosine similarity -const findSimilar = async (queryVector: number[], limit: number = 10) => { - return await db.raw(` - SELECT - id, - 1 - (embedding <=> $1) as similarity - FROM documents - ORDER BY embedding <=> $1 - LIMIT $2 - `, [queryVector, limit]); -}; -``` - -### Distance Metrics - -- **Cosine Similarity**: Measures angle between vectors -- **Euclidean Distance**: Straight-line distance -- **Dot Product**: Inner product of vectors - -## Use Cases - -- **Semantic Search**: Find similar documents, products, or content -- **Recommendation Systems**: Product recommendations, content suggestions -- **Text Analysis**: Document clustering, topic modeling -- **Image Search**: Similar image finding, visual search -- **Anomaly Detection**: Identify outliers in data -- **AI Applications**: Any machine learning feature vectors - -## Backend Implementation - -Example vector storage and search: - -```plaintext -// PostgreSQL with pgvector extension -import { Client } from 'pg'; - -// Create table with vector column -const createTable = async () => { - await db.raw(` - CREATE TABLE IF NOT EXISTS documents ( - id SERIAL PRIMARY KEY, - content TEXT, - embedding vector(768) - ); - - CREATE INDEX ON documents - USING ivfflat (embedding vector_cosine_ops); - `); -}; - -// Generate embedding (using OpenAI) -const generateEmbedding = async (text: string): Promise => { - const response = await openai.embeddings.create({ - model: 'text-embedding-3-small', - input: text - }); - return response.data[0].embedding; -}; - -// Store document with embedding -const storeDocument = async (content: string) => { - const embedding = await generateEmbedding(content); - return db.insert('documents', { - content, - embedding - }); -}; - -// Search similar documents -const searchSimilar = async (query: string, limit = 10) => { - const queryEmbedding = await generateEmbedding(query); - return db.raw(` - SELECT - id, - content, - 1 - (embedding <=> $1::vector) as similarity - FROM documents - ORDER BY embedding <=> $1::vector - LIMIT $2 - `, [queryEmbedding, limit]); -}; -``` - -## Database Support - -Vector storage is supported by: - -- **PostgreSQL**: pgvector extension -- **Pinecone**: Specialized vector database -- **Weaviate**: Vector search engine -- **Milvus**: Open-source vector database -- **Qdrant**: Vector similarity search engine -- **Chroma**: AI-native embedding database - -## Performance Considerations - -1. **Indexing**: Use approximate nearest neighbor (ANN) indices -2. **Quantization**: Consider dimension reduction for storage -3. **Batch Operations**: Generate embeddings in batches -4. **Caching**: Cache frequently used embeddings -5. **Async Generation**: Generate embeddings asynchronously - -## Example: Semantic Search - -```plaintext -// 1. Generate embedding for user query -const query = "Find documentation about authentication"; -const queryEmbedding = await generateEmbedding(query); - -// 2. Search for similar documents -const results = await db.raw(` - SELECT - id, - title, - content, - 1 - (embedding <=> $1::vector) as similarity - FROM documents - WHERE 1 - (embedding <=> $1::vector) > 0.7 - ORDER BY embedding <=> $1::vector - LIMIT 10 -`, [queryEmbedding]); - -// 3. Display results with similarity scores -results.forEach(doc => { - console.log(`${doc.title} (${(doc.similarity * 100).toFixed(1)}% match)`); -}); -``` diff --git a/ui/content/docs/fields/widget-props.mdx b/ui/content/docs/fields/widget-props.mdx deleted file mode 100644 index dfc53a37ee0..00000000000 --- a/ui/content/docs/fields/widget-props.mdx +++ /dev/null @@ -1,119 +0,0 @@ ---- -title: "Field Widget Props" -description: "What a field widget receives at runtime, and why those keys are not field metadata" ---- - -Every field reference page in this section documents one thing: the **metadata you -author** for that field type. This page documents the other half — the props a field -**widget** receives when it renders. - -The two are different shapes with different producers, and confusing them is the most -common authoring mistake this section can cause. A key like the live value, the -host-supplied `className`, or the form's `disabled` state belongs to the widget at -runtime; writing it on an object's field definition publishes a key -`@objectstack/spec`'s strict schemas reject. - -## The type is the source of truth - -`FieldWidgetComponentProps` is exported from `@object-ui/fields`. It is a **closed** -type — there is no `[key: string]: any` in it (objectui#3221), so a misspelled prop is -a compile error rather than a permanent `undefined`. That is what makes it usable as a -reference: the compiler answers "is this a real prop", and this page does not have to. - -A widget implements it by taking it as its props type: - -```tsx -import { toDomProps, type FieldWidgetComponentProps } from '@object-ui/fields'; - -/** A custom single-line widget, registered for a field type of your own. */ -export function SlugWidget(props: FieldWidgetComponentProps) { - const { value, onChange, field, readonly, disabled, className, error } = props; - - return ( - onChange(event.target.value)} - placeholder={field.placeholder} - readOnly={readonly} - disabled={disabled} - // The widget drives the a11y state; the message TEXT stays with the form - // renderer, and the required MARKER is drawn by its label. - aria-invalid={Boolean(error)} - aria-required={Boolean(field.required)} - /> - ); -} -``` - -Read the full member list from the type — in your editor, or from -`packages/fields/src/widgets/types.ts`, where every key carries a doc comment naming -its producer and its consumer. This page deliberately does not copy that list: a -hand-maintained restatement of a declared surface is exactly the drift these pages are -being fixed for. - -## What the categories are - -The type is assembled from five groups, and knowing which group a key is in tells you -who supplies it: - -1. **The controlled-input contract.** The current value, the callback that changes it, - the field's metadata carrier, and the display-state flags every widget interprets. - Every widget in the package implements this group; the rest are optional. -2. **Host plumbing.** What a rendering host forwards to widgets that need more than a - value — a data source for widgets that query records, live sibling-field values for - cascading and dependent options, resolved labels and hints for the copy those gates - render, a compact mode for grid cells, and record-selection callbacks for pickers. - A widget that needs none of it destructures none of it. -3. **DOM pass-through.** The identity, focus and event keys that may legitimately land - on the element a widget renders — the field's `id`, the `aria-describedby` the form - control minted, and so on. `toDomProps` is this group's runtime executor, bound to - the declaration in both directions by compile-time assertions, so a key cannot be - declared here and silently never delivered. Every other registered widget is held - to the same rule with the SDUI contract's own key set — the plugin-widget contract - is stated once, in [What Reaches the DOM](/docs/guide/plugin-development#what-reaches-the-dom). -4. **The ARIA attribute family**, intersected in whole from React's `AriaAttributes`. - This group is the bulk of the member count, which is why the count is not a useful - thing to quote. -5. **`data-*` attributes**, open by design and expressed as a template-literal key so - `keyof` stays finite — an undeclared prop still fails. - -## Metadata and props are two shapes, not one - -The authored metadata arrives at the widget under a single carrier -(objectui#3233 converged it at the producers; there is no second key to check). The -live value never lives on metadata, and the metadata never lives on the DOM: - -```ts -import type { FieldWidgetComponentProps } from '@object-ui/fields'; -import type { FieldMetadata, TextFieldMetadata } from '@object-ui/types'; - -// What you AUTHOR: object metadata, validated at publish. -const slug: TextFieldMetadata = { - type: 'text', - name: 'slug', - label: 'Slug', - maxLength: 80, -}; - -// What the widget RECEIVES at runtime. -declare const props: FieldWidgetComponentProps; - -const carrier: FieldMetadata = props.field; // the authored metadata, unchanged -const live: string = props.value; // never a metadata key - -export { slug, carrier, live }; -``` - -Assigning `slug` into `props.field` type-checks; the reverse — writing `props.value` -into `slug` — does not, and that asymmetry is the whole distinction. - -## Where each half is documented - -- **Metadata keys** — the `Field Schema` section of each field page in this section, - as a literal annotated with that field type's exported `*FieldMetadata`. -- **Runtime props** — this page, and the type it names. diff --git a/ui/content/docs/guide/agent-skills.md b/ui/content/docs/guide/agent-skills.md deleted file mode 100644 index 96cbe301816..00000000000 --- a/ui/content/docs/guide/agent-skills.md +++ /dev/null @@ -1,106 +0,0 @@ ---- -title: "Agent Skills" -description: "Install the ObjectUI Agent Skill into Claude Code, Cursor, Codex, GitHub Copilot, Windsurf, Gemini, Cline, and other agents with a single command." ---- - -# Agent Skills - -ObjectUI ships an official **Agent Skill** — a structured bundle of rules, guides, and evals that teaches AI coding agents how to build pages, plugins, and integrations the *right* way (schema-first, no-touch zones respected, English-only, expression-aware). - -The skill is published on [skills.sh](https://skills.sh/objectstack-ai/objectui) and lives in [`skills/objectui/`](https://github.com/objectstack-ai/objectstack/tree/main/skills/objectui) inside this repository. - -[![skills.sh](https://skills.sh/b/objectstack-ai/objectui)](https://skills.sh/objectstack-ai/objectui) - -## Install - -One command, in your project root: - -```bash -npx skills add objectstack-ai/objectui -``` - -The `skills` CLI auto-detects which AI agent you're using and writes the skill to the location that agent reads from — for example: - -| Agent | Install location | -| --- | --- | -| Claude Code | `.claude/skills/objectui/` | -| GitHub Copilot | `.github/copilot/skills/objectui/` | -| Cursor | `.cursor/skills/objectui/` | -| Codex | `.codex/skills/objectui/` | -| Windsurf, Gemini, Cline, Goose, Kilo, Droid, Trae, … | per-agent path | - -See [skills.sh/docs](https://skills.sh/docs) for the full, up-to-date list of supported agents. - -## What you get - -Once installed the skill activates automatically whenever you describe ObjectUI work in chat. Nothing to import, nothing to configure. - -The skill is structured as a single entry point plus deep-dive guides: - -``` -skills/objectui/ -├── SKILL.md # Entry point — core principles, tech stack, scope -├── rules/ # Non-negotiable global constraints -│ ├── protocol.md -│ ├── styling.md -│ └── composition.md -├── guides/ # Domain-specific deep dives, loaded on demand -│ ├── architecture.md -│ ├── app-composition.md -│ ├── page-builder.md -│ ├── plugin-development.md -│ ├── schema-expressions.md -│ ├── data-integration.md -│ ├── project-setup.md -│ ├── testing.md -│ ├── i18n.md -│ ├── mobile.md -│ └── auth-permissions.md -└── evals/ # Machine-checkable prompts (one per guide) -``` - -Two files that once shipped in this bundle — `rules/no-touch-zones.md` and -`guides/console-development.md` — moved into the repo-internal -[`.claude/skills/objectui-contributor/`](https://github.com/objectstack-ai/objectui/tree/main/.claude/skills/objectui-contributor) -skill instead. Both talk about paths that only exist inside this monorepo — `apps/console/` -and `packages/components/src/ui/` — which a customer install of the published skill never -has, so they stayed behind as contributor-only guidance rather than shipping to every -consumer. - -When the agent picks up a task it: - -1. Reads `SKILL.md` for core principles, scope boundaries, and the package map. -2. Loads the relevant `rules/*.md` so non-negotiables (Shadcn purity, expression syntax, layout composition) are respected. -3. Pulls the matching `guides/*.md` for the task at hand. - -## What the skill enforces - -The skill bakes the ObjectUI worldview into every answer your agent gives you: - -- **Schema-first**: page output is JSON for ``, not bespoke React. -- **Shadcn-native aesthetics**: components stay in Tailwind + `cn()` + `cva`; no inline styles, no CSS-in-JS. -- **Protocol agnostic**: data goes through the `DataSource` interface, not raw `fetch`/`axios`. -- **Expression-aware**: `visible`, `hidden`, `disabled` use `${data.*}` / `${props.*}` templates, never raw JS. -- **No-touch zones**: upstream Shadcn primitives in `packages/components/src/ui/**` are never edited — extensions live in `custom/` wrappers. -- **English-only**: all generated component text, labels, comments, and docs are English. -- **Scope discipline**: the skill explicitly defers backend/data-modelling questions to the `objectstack-*` skills, so it stays focused on the UI engine. - -Each rule has a machine-checkable eval under `evals/`, so regressions in agent behaviour are caught the same way regressions in code are. - -## When *not* to use it - -If your question is purely about data modelling, kernel plugins, ObjectQL queries, CEL formulas, or server-side automation, prefer the matching `objectstack-*` skill. The ObjectUI skill's frontmatter explicitly defers those topics so multiple skills can coexist cleanly in your agent. - -## Updating - -To pull the latest version of the skill after we ship improvements: - -```bash -npx skills add objectstack-ai/objectui -``` - -The CLI is idempotent — running it again refreshes the local copy in place. - -## Contributing - -The skill source lives in [`skills/objectui/`](https://github.com/objectstack-ai/objectstack/tree/main/skills/objectui). PRs are welcome — see [`skills/objectui/README.md`](https://github.com/objectstack-ai/objectstack/blob/main/skills/objectui/README.md) for the maintenance rules (guide ↔ eval naming, English-only, eval assertion format). diff --git a/ui/content/docs/guide/architecture-overview.md b/ui/content/docs/guide/architecture-overview.md deleted file mode 100644 index bcaaf1c44e2..00000000000 --- a/ui/content/docs/guide/architecture-overview.md +++ /dev/null @@ -1,342 +0,0 @@ ---- -title: Architecture Overview -description: Deep dive into the ObjectUI 3-layer architecture, data flow, plugin system, state management, and action system. ---- - -# Architecture Overview - -ObjectUI is a **Server-Driven UI (SDUI) engine** that transforms JSON schemas into fully interactive React interfaces built on Shadcn/Tailwind. This document covers the internal architecture, data flow, and extension points. - -## The 3-Layer Architecture - -ObjectUI enforces a strict separation across three layers. Each layer has clear constraints on what it may import and what it may contain. - -``` -┌─────────────────────────────────────────────────────────────────────┐ -│ Layer 1: @objectstack/spec (The Protocol) │ -│ Pure TypeScript type definitions — 12 export modules │ -│ ❌ No runtime code. No React. No dependencies. │ -└──────────────────────────────┬──────────────────────────────────────┘ - │ imports (never redefines) -┌──────────────────────────────▼──────────────────────────────────────┐ -│ Layer 2: @object-ui/types (The Bridge) │ -│ Re-exports spec types + ObjectUI-specific schemas │ -│ ❌ No runtime code. Zero runtime dependencies. │ -└──────────────────────────────┬──────────────────────────────────────┘ - │ consumed by -┌──────────────────────────────▼──────────────────────────────────────┐ -│ Layer 3: Implementations (The Runtime) │ -│ core, react, components (91+), fields (35+), plugins, etc. │ -└─────────────────────────────────────────────────────────────────────┘ -``` - -### Layer 1 — `@objectstack/spec` (The Protocol) - -The upstream JSON specification for all ObjectStack products. ObjectUI **imports** these types but never redefines them. Located externally and consumed as `@objectstack/spec`; the range each package installs is declared in that package's own `package.json`, under `dependencies`. - -### Layer 2 — `@object-ui/types` (The Bridge) - -Lives in `packages/types/`. Re-exports spec types and adds ObjectUI-specific schemas (component schemas, widget props, field props). Has **zero** runtime dependencies — only type-level imports of `@objectstack/spec` and `zod` for validation schemas. Marked `sideEffects: false`. - -### Layer 3 — Implementations (The Runtime) - -All packages that ship runnable code: `core`, `react`, `components`, `fields`, `layout`, `i18n`, `auth`, `permissions`, and every `plugin-*` package. - -## Package Dependency Graph - -``` -@objectstack/spec - │ - ▼ -@object-ui/types ◄─────────────────────────────────────┐ - │ │ - ▼ │ -@object-ui/core │ - │ (registry, evaluator, actions, validation) │ - ▼ │ -@object-ui/react ──────► @object-ui/i18n │ - │ (SchemaRenderer, hooks, contexts) │ - ├──────────────────────────────────────┐ │ - ▼ ▼ │ -@object-ui/components @object-ui/fields │ - │ (91+ Shadcn atoms) (35+ inputs) │ - ▼ ▼ │ -@object-ui/layout @object-ui/plugin-* │ - (AppShell, Header, (grid, kanban, charts, │ - Sidebar, routing) dashboard, form, etc.) │ - │ │ - └─────────────────┘ - (all packages import types) -``` - -**Strict rules:** - -| Package | May Import | Must NOT Import | -|---------|-----------|-----------------| -| `types` | `@objectstack/spec` | Any runtime package | -| `core` | `types`, `lodash`, `zod` | React, any UI library | -| `react` | `core`, `types`, `i18n` | Plugin packages directly | -| `components` | `types` (for props) | `core`, `react` | -| `fields` | `types` (for `FieldWidgetProps`) | `core` business logic | -| `plugin-*` | Any package | Other plugins | - -## Data Flow: Schema → Screen - -The rendering pipeline transforms a JSON schema into a live React component tree: - -``` - JSON Schema (from API or static file) - │ - ▼ - ┌─────────────┐ ┌──────────────────┐ - │ SchemaRenderer │───►│ ExpressionEvaluator │ - │ (packages/ │ │ Resolves ${...} │ - │ react/src/) │ │ expressions │ - └──────┬────────┘ └──────────────────┘ - │ - ▼ - ┌─────────────────┐ - │ ComponentRegistry │ ← registry.resolve(schema.type) - │ (packages/core/ │ - │ src/registry/) │ - └──────┬──────────┘ - │ - ▼ - ┌──────────────────┐ - │ React Component │ ← Wrapped in ErrorBoundary - │ (Button, Grid, │ with ARIA attributes - │ Kanban, etc.) │ - └──────────────────┘ -``` - -1. **SchemaRenderer** (`packages/react/src/SchemaRenderer.tsx`) receives a JSON schema object. -2. It evaluates dynamic expressions via `ExpressionEvaluator` (`packages/core/src/evaluator/`). -3. It looks up the component type in the `ComponentRegistry` (`packages/core/src/registry/Registry.ts`). -4. The matched component renders with schema props, wrapped in a per-component `ErrorBoundary` with ARIA accessibility attributes (`aria-label`, `aria-describedby`, `role`). - -## The Plugin System - -Plugins are self-contained packages that register heavy or complex views (grids, kanbans, charts). They follow a consistent pattern defined in `packages/core/src/registry/PluginSystem.ts`. - -### Plugin Lifecycle - -``` - import 'plugin-kanban' - │ - ▼ - PluginSystem.load(plugin) - │ ├─ Check dependencies - │ ├─ Prevent duplicate loading - │ └─ Call plugin.register(scope) - ▼ - PluginScope.registerComponent(type, Component, meta) - │ └─ Auto-prefixes with plugin namespace - ▼ - ComponentRegistry.register('object-kanban', ObjectKanbanRenderer, { - namespace: 'plugin-kanban', - category: 'view' - }) -``` - -### Lazy Loading - -Plugins use `React.lazy()` wrapped by `LazyPluginLoader` (`packages/react/src/LazyPluginLoader.tsx`): - -- **Retry logic**: 2 retries with 1-second delay by default -- **Custom fallbacks**: Loading skeleton + error boundary -- **Tree-shaking**: Plugins are code-split from the initial bundle - -### Plugin Registration Pattern - -Every plugin follows the same structure (see `packages/plugin-kanban/`, `packages/plugin-grid/`): - - - -```typescript -// 1. Lazy-load the heavy implementation -const LazyKanban = React.lazy(() => import('./KanbanImpl')); - -// 2. Define a thin wrapper with Suspense -const KanbanRenderer: React.FC = ({ schema }) => ( - }> - - -); - -// 3. Register in the global registry -ComponentRegistry.register('object-kanban', KanbanRenderer, { - namespace: 'plugin-kanban', - label: 'Object Kanban', - category: 'view', - inputs: [/* schema config */] -}); -``` - -Scaffold a new plugin with `npx @object-ui/create-plugin`. - -## State Management - -ObjectUI uses **React Context** for all state management, with contexts scoped to specific concerns: - -### Core Contexts (`packages/react/src/context/`) - -| Context | Purpose | -|---------|---------| -| `SchemaRendererContext` | Data scope + debug mode for the rendering tree | -| `ActionContext` | Action runner instance for handling user interactions | -| `ThemeContext` | Theme tokens and dark/light mode | -| `NotificationContext` | Toast and notification system | -| `DndContext` | Drag-and-drop state for sortable views | - -### Domain Contexts (separate packages) - -| Context | Package | Purpose | -|---------|---------|---------| -| `AuthContext` | `packages/auth/` | Authentication state | -| `PermissionContext` | `packages/permissions/` | RBAC/ABAC permissions | -| `I18nProvider` | `packages/i18n/` | Locale and translations | - -Contexts are composed at the application root and consumed by plugins and components via hooks (e.g., `useActionRunner`, `useExpression`, `useViewData`). - -## Expression Evaluation - -The expression engine lives in `packages/core/src/evaluator/` and powers dynamic schemas: - -### Components - -- **`ExpressionEvaluator.ts`** — Main engine: parses and evaluates `${...}` template expressions. -- **`ExpressionContext.ts`** — Variable scope: provides `data`, `user`, `params` to expressions. -- **`ExpressionCache.ts`** — Caches parsed expressions for repeated evaluations. -- **`FormulaFunctions.ts`** — Built-in functions available inside expressions. - -### Expression Types - -```json -{ - "visible": "${data.role === 'admin'}", - "label": "Hello, ${data.user.name}!", - "disabled": "${data.status !== 'draft'}", - "className": "${data.priority === 'high' ? 'text-red-500' : 'text-gray-500'}" -} -``` - -Expressions support: -- **String interpolation**: `"Welcome, ${data.name}"` -- **Conditionals**: `"${data.age > 18}"` -- **Ternary operators**: `"${data.active ? 'Yes' : 'No'}"` -- **Variable references**: `data.*`, `user.*`, `params.*` - -## Action System - -The action system (`packages/core/src/actions/`) handles user interactions defined in schemas. - -### ActionRunner (`ActionRunner.ts`) - -Executes action schemas and returns directives: - -``` - User Click → Schema Action - │ - ▼ - ActionRunner.execute(action, context) - │ ├─ Check confirmation dialog - │ ├─ Evaluate conditions - │ └─ Execute by action type - ▼ - ActionResult - ├─ reload: boolean - ├─ redirect: string - ├─ modal: SchemaObject - └─ toast: { message, type } -``` - -### Supported Action Types - -| Type | Description | -|------|-------------| -| `script` | Dispatch a named/registered script action — an `action.body` always executes **server-side** | -| `url` | Navigate to a URL | -| `api` | Make an API request (AJAX) | -| `modal` | Open a modal with a nested schema | -| `flow` | Start a server-side flow by name (`target`). In the console, a click on a declared `type: 'flow'` action goes through the action endpoint, `POST /api/v1/actions/{object}/{action}`, so every server-side gate the action declares applies; a flow start that names no declared action (an inline page button, a dashboard header action) goes through `POST /api/v1/automation/{flow}/trigger`. A run that pauses at a `screen` node opens the flow runner. Whether the run pauses or goes straight through, it closes on one success toast: the action's `successMessage`, else the flow's own `successMessage`, else a default sentence. A run that ends refused or fails shows no success toast | - -### Server Action Dispatch (`serverActionHandler.ts`) - -`ActionSchema.body` (the spec's preferred binding for script actions) executes -server-side via `POST /api/v1/actions/{object}/{action}` — the client -dispatches, it never interprets a body (a browser cannot enforce the L2 -sandbox's capabilities/timeout/memory contract, and L1 is formula-engine CEL, -a different dialect from the `${...}` evaluator). - -`createServerActionHandler` is the core factory every consumer uses to build -that dispatch. It owns the protocol (name-based action identity, record-id -resolution, re-entrancy guard, the `/actions` response-envelope rule) and -injects the three things core has no opinion about: - - - -```typescript -import { createServerActionHandler } from '@object-ui/core'; - -const script = createServerActionHandler({ - fetch: myAuthenticatedFetch, // auth is yours - baseUrl: 'https://api.example.com', // origin is yours ('' = same-origin) - resolveObject: () => currentObject, // fallback object scope is yours - onRefresh: () => notifyDataChanged(), // data invalidation is yours -}); - -// Registered handlers beat the built-in executors: - -``` - -The console builds on the same factory (`@object-ui/app-shell`'s -`createConsoleServerActionHandler` adds the popup pre-open dance and the -`redirectUrl` convention on top). - -### TransactionManager (`TransactionManager.ts`) - -Wraps multi-step actions in transactions for consistency, supporting rollback on failure. - -## Key Directories Reference - -``` -packages/ -├── types/src/ # Layer 2 — Pure type definitions -├── core/src/ -│ ├── evaluator/ # Expression engine -│ ├── registry/ # Component & plugin registries -│ ├── actions/ # ActionRunner, TransactionManager -│ ├── validation/ # Schema validation engine -│ ├── adapters/ # Data source adapters (API, Value) -│ ├── data-scope/ # ViewDataProvider, element data sources -│ ├── query/ # Query AST (filtering/sorting) -│ ├── theme/ # ThemeEngine -│ └── builder/ # Schema builder utilities -├── react/src/ -│ ├── SchemaRenderer.tsx -│ ├── LazyPluginLoader.tsx -│ ├── context/ # All React contexts -│ └── hooks/ # 20+ hooks (useExpression, useActionRunner, etc.) -├── components/ # 91+ Shadcn UI atoms -├── fields/ # 35+ field renderers -├── layout/ # AppShell, Header, Sidebar -├── i18n/ # Internationalization -├── auth/ # AuthContext + providers -├── permissions/ # RBAC/ABAC -└── plugin-*/ # 20 plugin packages - ├── plugin-grid/ - ├── plugin-kanban/ - ├── plugin-charts/ - ├── plugin-dashboard/ - ├── plugin-form/ - └── ... (15 more) -``` - -## Further Reading - -- [Schema Overview](/docs/guide/schema-overview) — JSON schema structure -- [Schema Rendering](/docs/guide/schema-rendering) — How schemas become UI -- [Component Registry](/docs/guide/component-registry) — Registering components -- [Plugin Development](/docs/guide/plugin-development) — Building plugins -- [Expressions](/docs/guide/expressions) — Expression syntax reference -- [Theming](/docs/guide/theming) — Theme configuration diff --git a/ui/content/docs/guide/architecture.md b/ui/content/docs/guide/architecture.md deleted file mode 100644 index 0d7bf6e7568..00000000000 --- a/ui/content/docs/guide/architecture.md +++ /dev/null @@ -1,475 +0,0 @@ ---- -title: "Architecture Overview" -description: "Understanding ObjectUI's architecture, design principles, and how the pieces fit together" ---- - -# Architecture Overview - -ObjectUI is a universal, server-driven UI (SDUI) engine built on React, Tailwind CSS, and Shadcn UI. This guide explains the core architecture and how all the pieces work together. - -## Core Philosophy - -ObjectUI follows three fundamental principles: - -1. **JSON-First**: Every UI element is described as JSON metadata, not hardcoded React components -2. **Backend Agnostic**: Works with any backend system (ObjectStack, custom APIs, etc.) -3. **Component Library Quality**: Combines low-code speed with Shadcn/Tailwind design quality - -## Architecture Layers - -``` -┌─────────────────────────────────────────────┐ -│ JSON Schema (Protocol) │ ← Backend sends this -├─────────────────────────────────────────────┤ -│ @object-ui/react (Renderer) │ ← Interprets schema -├─────────────────────────────────────────────┤ -│ Component Registry + Field Registry │ ← Lookup system -├─────────────────────────────────────────────┤ -│ @object-ui/components (UI Primitives) │ ← Buttons, Cards, etc. -│ @object-ui/fields (Form Inputs) │ ← Text, Date, Select -│ @object-ui/layout (Page Structure) │ ← AppShell, Sidebar -│ @object-ui/plugin-* (Advanced Widgets) │ ← Grid, Charts, Kanban -├─────────────────────────────────────────────┤ -│ Shadcn UI + Radix UI (Primitives) │ ← Accessible components -│ Tailwind CSS (Styling) │ ← Utility-first CSS -└─────────────────────────────────────────────┘ -``` - -## Package Structure - -ObjectUI is organized as a PNPM monorepo with clear separation of concerns: - -### Core Packages - -#### `@object-ui/types` -- **Role**: The Protocol -- **Contains**: Pure TypeScript interfaces for JSON schemas -- **Constraint**: ZERO dependencies, no React code -- **Example**: `BaseSchema`, `ActionSchema`, `FieldSchema` - -#### `@object-ui/core` -- **Role**: The Engine -- **Contains**: Schema validation, expression evaluation, registries -- **Constraint**: No UI library dependencies, logic only -- **Features**: - - Expression engine (`visible: "${record.age > 18}"`) - - Schema registry and validation - - Event system - -#### `@object-ui/react` -- **Role**: The Runtime -- **Contains**: `SchemaRenderer` and React integration -- **Purpose**: Transforms JSON schemas into live React components - -### UI Packages - -#### `@object-ui/components` -- **Role**: The Atoms -- **Contains**: Shadcn primitives (Button, Badge, Card, Dialog, etc.) -- **Constraint**: Pure UI, no business logic -- **Style**: Tailwind CSS with `class-variance-authority` - -#### `@object-ui/fields` -- **Role**: The Inputs -- **Contains**: Standard field renderers (Text, Number, Select, Date, etc.) -- **Implements**: `FieldWidgetProps` interface -- **Purpose**: Reusable form inputs with consistent API - -#### `@object-ui/layout` -- **Role**: The Shell -- **Contains**: Page structure components (AppShell, Page, Sidebar, Header) -- **Purpose**: Routing-aware composition and app scaffolding - -### Plugin Packages - -Each plugin provides specialized, complex widgets: - -- `@object-ui/plugin-grid` - Data tables with ObjectStack integration -- `@object-ui/plugin-kanban` - Kanban board view -- `@object-ui/plugin-charts` - Recharts-based visualizations -- `@object-ui/plugin-calendar` - Calendar and event views -- `@object-ui/plugin-map` - Map visualization -- `@object-ui/plugin-form` - Advanced forms -- `@object-ui/plugin-editor` - Code editor (Monaco) -- `@object-ui/plugin-markdown` - Markdown renderer -- `@object-ui/plugin-gantt` - Gantt chart timeline -- `@object-ui/plugin-timeline` - Event timeline -- `@object-ui/plugin-dashboard` - Dashboard layouts -- `@object-ui/plugin-chatbot` - Chat interface - -**Important**: Heavy dependencies (like Monaco, Recharts) are only allowed in plugin packages to keep the core bundle small. - -### Utility Packages - -Development tools and integration utilities: - -- `@object-ui/cli` - Command-line tool for building apps from schemas -- `@object-ui/create-plugin` - Interactive plugin scaffolder -- `@object-ui/runner` - Universal runtime for testing and demos -- `@object-ui/data-objectstack` - ObjectStack data backend adapter - -[Learn more about utilities →](/docs/utilities) - -## How Schema Rendering Works - -### 1. JSON Schema Input - -A backend system sends a JSON schema: - -```json -{ - "type": "card", - "title": "Welcome", - "children": { - "type": "text", - "content": "Hello, ${user.name}!" - } -} -``` - -### 2. SchemaRenderer Processing - -The `SchemaRenderer` component: - -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 { PredicateScopeProvider, SchemaRenderer } from '@object-ui/react' -import type { DeclaredNode } from '@object-ui/types' - -// The schema from step 1, as the object the renderer receives. -const schema: DeclaredNode = { - type: 'card', - title: 'Welcome', - children: { - type: 'text', - content: 'Hello, ${user.name}!', - }, -} - -function App() { - const scope = { user: { name: 'Alice' } } - - 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: - - -```typescript -// During app initialization -ComponentRegistry.register('card', CardComponent) -ComponentRegistry.register('text', TextComponent) - -// At runtime -const Component = ComponentRegistry.get('card') // → CardComponent -``` - -### 4. React Component Rendering - -The registered component renders with evaluated props: - - -```tsx - - - -``` - -## The Registry Pattern - -ObjectUI uses two registry systems for extensibility: - -### Component Registry - -Maps schema types to React components: - - -```tsx -import { ComponentRegistry } from '@object-ui/core' - -// Register a component -ComponentRegistry.register('my-widget', MyWidgetComponent, { - label: 'My Widget', - category: 'Custom', - icon: 'box', - inputs: [ - { name: 'title', type: 'string' } - ] -}) -``` - -### Field Registry - -Maps field types to input components: - - -```tsx -import { registerFieldRenderer } from '@object-ui/fields' - -// Register a field renderer -registerFieldRenderer('rating', RatingFieldComponent) -``` - -This allows: -- ✅ Overriding standard components -- ✅ Adding custom field types -- ✅ Plugin system for complex widgets -- ✅ Keeping bundles small (lazy loading) - -## Expression System - -ObjectUI includes a powerful expression engine for dynamic UIs: - -### String Interpolation - -```json -{ - "type": "text", - "content": "Welcome, ${user.firstName} ${user.lastName}!" -} -``` - -### Conditional Rendering - -```json -{ - "type": "button", - "label": "Submit", - "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. Only -`objectui validate` refuses the misspelling, by name: the rendering face is -`.passthrough()` — `BaseSchema` KEEPS the unknown key — and the renderer, which reads -`schema.label`, never looks at it. -Measured on the node above with `text`: the button renders with an empty `textContent`, -so it appears on screen as a blank rectangle with no text. - -### Data Transformations - -```json -{ - "type": "statistic", - "label": "Orders", - "value": "${orders.length}", - "description": "${orders.length > 10 ? 'Above target' : 'On track'}" -} -``` - -`statistic` rather than `badge`: an expression is evaluated only on a key the node's own -type carries, and `expressionBindableTextKeysFor` gives `statistic` the rows `label`, -`value` and `description` while giving `badge` none. A badge's text is its `label`, and it -has to arrive already resolved. - -See the [Expressions Guide](/docs/guide/expressions) for complete details. - -## Data Flow - -``` -Backend API - ↓ -JSON Schema + Data - ↓ -SchemaRenderer (evaluates expressions) - ↓ -Component Registry (maps types) - ↓ -React Components (render UI) - ↓ -User Interactions (events) - ↓ -Event Handlers (update data) - ↓ -Re-render (React state updates) -``` - -## Styling System - -ObjectUI uses **Tailwind CSS** exclusively for styling: - -### Class-Variance-Authority (CVA) - -All component variants use `cva` for type-safe variants: - - -```tsx -import { cva } from 'class-variance-authority' - -const buttonVariants = cva( - 'inline-flex items-center justify-center rounded-md', - { - variants: { - variant: { - default: 'bg-primary text-primary-foreground', - destructive: 'bg-destructive text-destructive-foreground', - }, - size: { - default: 'h-10 px-4 py-2', - sm: 'h-9 px-3', - lg: 'h-11 px-8', - } - } - } -) -``` - -### Class Merging - -Use `cn()` helper (tailwind-merge + clsx) for class overrides: - - -```tsx -import { cn } from '@/lib/utils' - - - - -// The grid re-resolves `view` whenever `activeView` changes. - -``` - -Selecting **Active** re-queries with the `active` view's `filter` -(`status != Done`) and `sort` (`priority asc`) applied — you never assemble -`$filter` / `$orderby` by hand. A view name your backend does not publish is -reported, not silently ignored: swap `activeView` for a name outside -`listViews` and the grid renders a configuration-error panel in place of the -table, the same way an unresolved `objectName` does (Step 5). A page that -instead fell back to the object's full, unfiltered scope would look like it -worked while returning every record regardless of which button was pressed — -so this block does not offer that fallback. - -**Search needs no binding at all.** `object-grid` renders its own search box -in the toolbar — on by default — and typing there drives -`DataSource.find()`'s `$search` parameter directly; there is no separate -query-param key to author. Add `searchableFields: ['title', 'description']` -to the grid's `properties` bag to narrow which fields the server matches; leave -it out and the server decides. - -## Step 8: Add a Detail View - -Create a detail page that renders a single record with all its fields: - -```tsx -import { SchemaRenderer } from '@object-ui/react'; - -function TaskDetail({ taskId, onBack }: { taskId: string; onBack: () => void }) { - return ( -
- - -
- ); -} -``` - -Render `TaskDetail` inside the same `SchemaRendererProvider` as the grid — it -reads the injected data source from context, exactly as `object-grid` and -`object-form` do. - -Two keys differ from the form above, and both matter: - -- **`resourceId`, not `recordId`.** `detail-view` sources the record id from - `resourceId`; `recordId` is `object-form`'s spelling. The two blocks are not - interchangeable here. -- **No `data`.** On `detail-view`, `data` means *"here is the record already, - do not fetch"* — so handing it anything (including the object's metadata) - makes the block skip `findOne` entirely and render that value as if it were - the record. Omit it and the block loads the record for itself. - -Use this component in your main app with simple routing state, or integrate with a router like React Router or TanStack Router for URL-based navigation. - -## Deployment Considerations - -**Environment config** — Keep your API URL configurable: - - -```ts -const dataSource = new RestDataSource( - import.meta.env.VITE_API_URL || 'http://localhost:3000/api' -); -``` - -**Performance** — Use server-side pagination via `$top`/`$skip` query params. ObjectUI plugins support lazy loading via `LazyPluginLoader` from `@object-ui/react`. Set `cache: { enabled: true, ttl: 300 }` on your schema for client-side caching. - -**Production build** — Run `pnpm build` and deploy the `dist/` folder to any static host (Vercel, Netlify, Cloudflare Pages). - -**Authentication** — Extend `RestDataSource` to inject auth headers: - - -```ts -class AuthenticatedDataSource extends RestDataSource { - constructor(baseUrl: string, private getToken: () => string) { - super(baseUrl); - } - // Override fetch calls to include: Authorization: `Bearer ${this.getToken()}` -} -``` - -## Next Steps - -- Explore the [Schema Overview](/docs/guide/schema-overview) for advanced schema features -- Add a Kanban board view using `@object-ui/plugin-kanban` (see the [Kanban Plugin](/docs/plugins/plugin-kanban) reference for runnable board schemas) -- Connect to a production backend with the [Data Connectivity](/docs/guide/data-source) guide -- Build multi-object apps with relationships using `lookup` / `master_detail` fields (see the [Lookup Field](/docs/fields/lookup) reference) diff --git a/ui/content/docs/guide/component-registry.md b/ui/content/docs/guide/component-registry.md deleted file mode 100644 index 639bfec04eb..00000000000 --- a/ui/content/docs/guide/component-registry.md +++ /dev/null @@ -1,465 +0,0 @@ ---- -title: "Component Registry" ---- - -The Component Registry is Object UI's system for mapping schema types to React components. Understanding the registry is key to extending Object UI with custom components. - -## Overview - -The registry acts as a lookup table that the `SchemaRenderer` uses to determine which React component to render for each schema type: - -``` -Schema Type → Component Registry → React Component -``` - -## Getting the Registry - -`ComponentRegistry` is a process-level singleton exported by `@object-ui/core`. -Import it directly — there is no accessor function and nothing to construct: - -```tsx -import { ComponentRegistry } from '@object-ui/core' -``` - -## Registering Components - -### Using Default Components - -The easiest way to get started is to register all default components: - -```tsx -import { initializeComponents } from '@object-ui/components' -// Side-effect import: loading the package runs its own field registration. -import '@object-ui/fields' - -// Call once at app initialization -initializeComponents() -``` - -Loading each package registers what it owns — the components and the field -widgets both land in the one `ComponentRegistry`; `initializeComponents()` exists -so a bundler cannot tree-shake the side-effect import away. The individual -renderers are not exported for hand-registration: registration is what loading -the package does. - -This registers all built-in components like: -- Forms: `input`, `textarea`, `select`, `checkbox`, etc. -- Data: `table`, `list`, `card`, `tree-view`, etc. -- Layout: `page`, `grid`, `flex`, `container`, etc. -- Feedback: `alert`, `dialog`, `toast`, etc. - -### Registering Custom Components - -Create and register your own components: - -```tsx -import { ComponentRegistry } from '@object-ui/core' -import type { BaseSchema } from '@object-ui/types' - -interface MyComponentSchema extends BaseSchema { - type: 'my-component' - title: string - content: string -} - -function MyComponent(props: MyComponentSchema) { - return ( -
-

{props.title}

-

{props.content}

-
- ) -} - -ComponentRegistry.register('my-component', MyComponent) -``` - -Now you can use it in schemas: - -```json -{ - "type": "my-component", - "title": "Hello", - "content": "This is my custom component!" -} -``` - -## Component Interface - -All registered components receive the schema as props: - - -```tsx -interface ComponentProps { - // The complete schema object - schema: T - - // Data context (optional) - data?: Record - - // Event handlers (optional) - onAction?: (action: any, context: any) => void - onChange?: (value: any) => void - onSubmit?: (data: any) => void -} - -function MyRenderer(props: ComponentProps) { - const { schema, data, onChange } = props - - return ( -
- {/* Your component implementation */} -
- ) -} -``` - -## Advanced Registration - -### With Metadata - -Register components with additional metadata: - - -```tsx -ComponentRegistry.register('my-component', MyComponent, { - label: 'My Custom Component', - category: 'Custom', - icon: 'component-icon', - inputs: [ - { name: 'title', type: 'string' }, - { name: 'content', type: 'string' } - ] -}) -``` - -This metadata is used by the Visual Designer to provide better editing experience. - -`inputs` is also the authoring contract the SDUI parser validates against. A -component that renders `schema.children` declares the slot as -`{ name: 'children', type: 'slot' }`; a child list authored under a component -whose `inputs` carry no such entry draws the `not-a-container` diagnostic. -`isContainer` is a separate fact — *layout* containment, which keeps a block out -of a react page's JSX scope — and does not stand in for the slot. - -### Lazy Loading - -Register components that load on demand: - - -```tsx -// The loader runs the first time a schema asks for `heavy-component`. -ComponentRegistry.registerLazy('heavy-component', () => import('./HeavyComponent')) -``` - -### Overriding Built-in Components - -Override default components with your own: - - -```tsx -import { ComponentRegistry } from '@object-ui/core' -import { initializeComponents } from '@object-ui/components' -import '@object-ui/fields' - -// Register defaults first -initializeComponents() - -// Override specific component -ComponentRegistry.register('button', MyCustomButton) -``` - -## Component Categories - -Default components are organized by category: - -### Form Components - -- `input` -- `textarea` -- `select` -- `checkbox` -- `radio` -- `switch` -- `slider` -- `date-picker` -- `time-picker` -- `file-upload` -- `color-picker` - -### Data Display - -- `table` -- `list` -- `card` -- `tree-view` -- `timeline` -- `calendar` -- `kanban` - -### Layout - -- `page` -- `container` -- `grid` -- `flex` -- `tabs` -- `accordion` -- `divider` -- `spacer` - -### Feedback - -- `alert` -- `toast` -- `dialog` -- `drawer` -- `popover` -- `tooltip` -- `progress` -- `skeleton` -- `spinner` - -### Navigation - -- `menu` -- `breadcrumb` -- `pagination` -- `steps` - -### Other - -- `button` -- `link` -- `text` -- `icon` -- `image` -- `video` -- `badge` -- `avatar` - -## Checking Registered Components - -### Get All Registered Types - -```tsx -import { ComponentRegistry } from '@object-ui/core' - -const types = ComponentRegistry.getAllTypes() -console.log(types) // ['input', 'button', 'form', ...] -``` - -### Check if Type is Registered - -```tsx -import { ComponentRegistry } from '@object-ui/core' - -if (ComponentRegistry.has('my-component')) { - console.log('Component is registered') -} -``` - -### Get Component Metadata - -```tsx -import { ComponentRegistry } from '@object-ui/core' - -const metadata = ComponentRegistry.getMeta('input') -console.log(metadata) -// { -// label: 'Input', -// category: 'Form', -// icon: 'input-icon', -// ... -// } -``` - -## Best Practices - -### 1. Register Once at App Initialization - -```tsx -// main.tsx or App.tsx -import { initializeComponents } from '@object-ui/components' -import '@object-ui/fields' - -initializeComponents() - -function App() { - // Your app code -} -``` - -### 2. Use TypeScript for Custom Components - -```tsx -import type { BaseSchema } from '@object-ui/types' - -interface CustomSchema extends BaseSchema { - type: 'custom' - customProp: string -} - -function CustomComponent(props: { schema: CustomSchema }) { - // TypeScript ensures type safety -} -``` - -### 3. Follow Naming Conventions - -Use kebab-case for component types: -- ✅ `my-component`, `custom-button`, `data-table` -- ❌ `MyComponent`, `customButton`, `DataTable` - -### 4. Provide Meaningful Metadata - - -```tsx -ComponentRegistry.register('rating', RatingComponent, { - label: 'Star Rating', - category: 'Form', - icon: 'star', - labelling: 'group' -}) -``` - -### 5. Handle Missing Props Gracefully - - -```tsx -function MyComponent(props: ComponentProps) { - const { schema } = props - const title = schema.title || 'Default Title' - const content = schema.content || '' - - return ( -
-

{title}

-

{content}

-
- ) -} -``` - -## Creating Plugin Packages - -Group related components into plugin packages: - - -```tsx -// @my-org/objectui-plugin-charts -import { ComponentRegistry } from '@object-ui/core' -import { BarChart } from './BarChart' -import { LineChart } from './LineChart' -import { PieChart } from './PieChart' - -export function registerChartComponents() { - ComponentRegistry.register('bar-chart', BarChart) - ComponentRegistry.register('line-chart', LineChart) - ComponentRegistry.register('pie-chart', PieChart) -} -``` - -Usage: - - -```tsx -import { initializeComponents } from '@object-ui/components' -import '@object-ui/fields' -import { registerChartComponents } from '@my-org/objectui-plugin-charts' - -initializeComponents() -registerChartComponents() -``` - -## Example: Custom Form Component - -Here's a complete example of a custom form component: - -```tsx -import { forwardRef, useState } from 'react' -import { ComponentRegistry } from '@object-ui/core' -import type { BaseSchema } from '@object-ui/types' -import { cn } from '@object-ui/components' - -interface RatingSchema extends BaseSchema { - type: 'rating' - name: string - label?: string - maxStars?: number - required?: boolean - disabled?: boolean - onChange?: (value: number) => void -} - -const RatingComponent = forwardRef( - ({ schema }, ref) => { - const [value, setValue] = useState(0) - const maxStars = schema.maxStars || 5 - - const handleClick = (rating: number) => { - if (schema.disabled) return - setValue(rating) - schema.onChange?.(rating) - } - - return ( -
- {schema.label && ( - - )} -
- {Array.from({ length: maxStars }).map((_, index) => ( - - ))} -
-
- ) - } -) - -RatingComponent.displayName = 'Rating' - -// Register the component -ComponentRegistry.register('rating', RatingComponent, { - label: 'Star Rating', - category: 'Form', - labelling: 'group', - inputs: [ - { name: 'name', type: 'string', required: true }, - { name: 'label', type: 'string' }, - { name: 'maxStars', type: 'number', description: 'Defaults to 5 — the renderer\'s own fallback' }, - { name: 'required', type: 'boolean' }, - { name: 'disabled', type: 'boolean' } - ] -}) - -export { RatingComponent } -``` - -## Next Steps - -- [Expression System](./expressions.md) - Learn about dynamic expressions -- [Schema Rendering](./schema-rendering.md) - Understand the rendering engine -- [Custom Plugin Development](/docs/guide/plugin-development) - Deep dive into component creation - -## Related Documentation - -- [`@object-ui/core` README](https://github.com/objectstack-ai/objectui/tree/main/packages/core) - Component registry API -- [`@object-ui/react` README](https://github.com/objectstack-ai/objectui/tree/main/packages/react) - React integration -- [Schema Type Reference](/docs/api/schema-reference) - Component metadata reference diff --git a/ui/content/docs/guide/console.md b/ui/content/docs/guide/console.md deleted file mode 100644 index 04b102dae9f..00000000000 --- a/ui/content/docs/guide/console.md +++ /dev/null @@ -1,148 +0,0 @@ ---- -title: Console App -description: Getting started with the ObjectStack Console — the reference SDUI application for ObjectUI. ---- - -# ObjectStack Console - -The **Console** is the reference application for [ObjectUI](/docs/guide). It renders a full-featured admin interface from JSON metadata — objects, views, dashboards, and actions — with zero custom pages required. - -## Quick Start - -```bash -# From the repository root -pnpm run setup # builds @objectstack/* at .objectstack-sha, then installs -pnpm dev # starts the console dev server (Vite) -``` - -The console opens at **http://localhost:5180** (the port is fixed in `apps/console/vite.config.ts`). There is no bundled mock backend — `apps/console/.env.development` ships `VITE_SERVER_URL` **empty** (same origin), and the Vite dev server proxies `/api/*` to `http://localhost:3000` by default, so an ObjectStack server has to be listening there. See [Running with a Real Backend](#running-with-a-real-backend) to point dev at a different one. - -## Key Features - -| Feature | Description | -|---------|-------------| -| **Multi-App Switcher** | Switch between the apps discovered from the connected server. | -| **Dynamic Navigation** | Sidebar renders from the app's `navigation` tree (objects, groups, URLs, pages). | -| **Object Views** | List / Grid / Kanban / Calendar — backed by `@object-ui/plugin-view`. | -| **CRUD Dialogs** | Create & edit records via schema-driven forms. | -| **Expression Visibility** | Show/hide navigation items with a CEL `visible` predicate over `current_user` and `features`, e.g. `visible: "'admin' in current_user.positions"`. A `visible` that reads `data` needs the entry to declare a `dataSource`. | -| **Branding** | Per-app colors, favicons, and logos via `AppShell` branding. | -| **Command Palette** | `⌘+K` opens a searchable command bar for quick navigation. | -| **Search in Studio** | On the Studio landing (`/studio`) the header's search, or `⌘+K`, opens the command palette over the Studio rather than an app: its packages, their objects and their flows, matched by label or machine name. A package opens its Data pillar, an object opens in its package's Data pillar, a flow in its package's Automations pillar, and a flow that belongs to no package in `/studio/~org/automations`. App entries, record search and the full-search page are not offered there, since no app is open. | -| **Studio Access** | Studio needs the `studio.access` capability. A caller without it who opens any Studio URL stays on that URL and sees one sentence saying Studio access is needed, in their language, with a **Back to home** link to the declared landing (the launcher where none is declared). The Studio builder is not loaded for them. | -| **Studio Package Scope** | Metadata counts, diagnostics and the metadata lists follow the selected package. The Studio app has no home page of its own: its root opens the first navigation entry that leads to a page, by the same rule as every app. | -| **Package-less Flows in Studio** | Flows that belong to no package — such as a clone of a packaged flow, made from Setup › Packaged automation — are listed and edited at `/studio/~org/automations`, reached from the Studio home ("Not in a package") or the package switcher. They open editable, edits save as drafts, and Publish promotes those drafts. | -| **Studio Automations Rail** | The Automations pillar's flow rail has a search box that matches a flow's label or its machine name, case-insensitively. A long flow name wraps instead of being cut off. | -| **Create App in Studio** | In a package that has no app yet, the Studio header's **Create app** saves the new app as a draft and then opens the **Interfaces** pillar, where that app is designed. Leaving a pillar that holds an unsent edit asks first. | -| **Read-only Packages in Studio** | The Studio home lists code and installed packages under "Installed (read-only · browsable)". Clicking a card opens the package for browsing (`/studio/:packageId/data`). The card's **Customize with an overlay** link opens the package's metadata directory (`/apps/setup/metadata?package=:packageId`). Whether an item can take an environment overlay is decided per metadata type (`allowEnvironmentOverlay`), not per package, and the directory marks each type. Unless the runtime reports `features.marketplace: false`, the section also links to the marketplace (`/apps/setup/system/marketplace`), the same route as Home's "Start with a template". | -| **Edit app (Studio)** | Workspace admins get an **Edit app** button in the top bar inside a running app (the pencil alone on narrow screens), and an **Edit APP in Studio** row at the end of the app switcher, both opening the app's owning package on the Studio design surface. On an interface route — a dashboard, page, or report — they deep-link straight to that surface's design page in the Interfaces pillar (`/studio/:packageId/interfaces?surface=:`, e.g. `surface=page:showcase_crm_workbench`); elsewhere (objects, the app root) they open the package's Data tab (`/studio/:packageId/data`). These interfaces are authored in Studio — there is no in-page edit panel. | -| **Studio Copilot Drafts** | When the AI copilot saves a change to a live app as a draft, its card offers **Review changes and publish** and **Undo**. Review opens Studio's **Changes** drawer for the package: in the Studio copilot it opens in place (same pillar, same selection), and from the full-page chat it opens the package's Studio with the drawer open. The drawer's link is `/studio/:packageId/:tab?changes=review`; Studio opens the drawer to review the pending drafts and drops the parameter. Undo discards the package's pending drafts (`POST /api/v1/packages/:id/discard-drafts`) and asks first when other drafts are pending. The top bar's **Changes** count follows the copilot as its tools answer, including a change the environment publishes immediately. The Studio copilot draws no floating pending-changes bar over its composer: there the top bar's **Changes** and **Publish** are the way to review and publish. The full-page chat and the console dock show that bar while the package has pending drafts. | -| **Dashboard Refresh** | A dashboard page shows a **Refresh All** button at the right of its page header, and a dashboard that sets `refreshIntervalSeconds` (Studio's auto-refresh field) re-reads its widgets' data every that many seconds. Widgets re-read in place, so they are not remounted, and a dataset-bound single-value (KPI) tile re-reads too. `0` or no value means no automatic refresh. | -| **Refused Dashboards and List Views** | The server lists only the dashboards and list views a user may open, so one gated by `requiredPermissions` is not in their lists. When such a dashboard or list view is opened by its URL, the console asks the server about it by name. If the server refuses it with `403 PERMISSION_DENIED`, the page says "You don't have access" and shows the server's sentence, which names the item. The page does not say "not found", and the list page does not switch to the default view. For a name that does not exist, the dashboard page still says it was not found and the list page still opens the default view. An object's own list view that the server sends whole to a user who may edit the object is listed and opens. | -| **Setup Positions** | Setup → Positions lists, for each position, how many people hold it in the active organization and which permission sets it grants. Both columns come from the server (`GET /meta/position?include=grants`), which computes them where it enforces them. The column lists only the permission sets the position's definition names; a set that merely shares the position's name is not granted and not listed. A holder count the server did not serve (no active organization, or no read on position assignments) shows "—", not 0. On every Setup metadata list, a click anywhere on a row opens that item. New Position asks for the permission sets with the label and name, so a position is created and bound in one save. On a position's page, Add holder offers the members of the active organization only (its `sys_member` rows), and Remove asks first, naming the person. | -| **App Creation Wizard** | 4-step wizard (Basic Info → Objects → Navigation → Branding) to create or edit apps. | -| **Record Approvals Tab** | A record with approval requests grows an Approvals tab on its detail page (peer of Details/Related, with a request-count badge) — current step, decision progress, resolved "waiting on" approvers, the merged decision timeline, and a submitter remind button — visible to every viewer who can read the record, not just approvers. | -| **Approval Request Pages** | A request opens on its record page, read through the approvals routes rather than the data API: the request with its decision tally and the `viewer` block its decision actions gate on, and its Timeline from the request's actions route. Those routes serve a request to each party to it, so an approver who is not an administrator sees the full page — fields, decision actions and Timeline — although the data API stays closed to them on both approval objects. | -| **Error Boundary** | Graceful error handling with a retry button. | - -### Object design (Studio Data tab) - -Selecting an object in Studio's **Data** pillar (`/studio/:packageId/data`) opens -two tabs over that object, **Records · Form**, and an **Advanced** menu that holds -**Validations · Hooks · Actions · API · Settings** (objectui#5813). The -**Advanced** trigger keeps its own name whichever of those panels is open: the -open panel is the checked item inside the menu, and the trigger shows as -selected while one of its panels is open. Each of Validations, Hooks and Actions -is a no-code **config panel driven by the corresponding metadata**, and each -supports **adding** new entries — no code round-trip required: - -| Advanced item | Edits | Panel | -|-----|-------|-------| -| **Validations** | the object's inline `validations[]` (spec `ValidationRuleSchema`) | Master-detail covering **every** rule type — `script`, `cross_field`, `state_machine`, `format`, `json_schema`, `conditional`. The **New** menu opens on common rules in plain words (*End date on or after start date*, *Number can't be negative*, *Reject the save when…*); each writes a working rule, or waits for the condition the author gives. Every rule type is under **Advanced**. A new rule whose type carries a condition (`script`, `cross_field`, `conditional`) is saved only once it has one; a rule's type can still be switched in place. CEL predicates reuse the shared `ConditionBuilder`, fed the object's draft fields. | -| **Hooks** | the separate `hook` metadata type targeting this object | Master-detail whose editor is the platform `SchemaForm` **driven by the live `hook` JSONSchema from `/meta/types`**, so its fields and enums always match the running server's contract. | -| **Actions** | the object's inline `actions[]` (spec `ActionSchema`) | Master-detail using the type-aware `ActionDefaultInspector`; anything not curated falls through to a **"More fields"** form fed the live `action` JSONSchema, so no spec property is un-editable. | - -Validations and Actions persist with the object's own **Save draft**; Hooks (a -distinct metadata type) save per-hook. Nothing goes live until the package is -published from the top-bar **Publish** flow. - -#### Why a configured action can be missing from the app - -An action that is saved, published and correctly placed can still appear on no -surface at all. There are two reasons, and the Actions panel states both — the -running app deliberately states neither, because an end user should not be told -about capabilities they do not have. - -* **No placement.** `locations` is empty, so the action surfaces nowhere. - A selection-only action is legitimate here: it is placed by a list view's - `bulkActions` / `bulkActionDefs` instead. -* **The capability gate.** `action.requiredPermissions` (ADR-0066 D4) is - enforced with a 403 on the platform action route and **mirrored as a UI - hide**: a viewer who does not hold every listed capability gets no button, - no greyed-out control and no message, at every declared location at once. - This is intended — the client hide mirrors an enforcement the server applies - regardless, and an unentitled user is never shown a control they cannot use. - -The **Placement** section of the action inspector names the gating capabilities -and says the action is hidden rather than disabled; when the signed-in session -is itself missing one of them it says so too, so "I configured the buttons and -I see none of them" has an answer on the screen where the buttons were -configured. The read-only **action preview** beside it carries the same -capability line, so its *Where it appears* frames are not read as a promise -that everyone will see the button there. - -An unheld capability is not the same problem as a *misspelled* one: a -capability string registered nowhere is caught separately, by the advisory -capability-reference lint that runs over pending drafts during **Publish**. -It resolves a reference against the platform's built-in capabilities, the -capabilities the registry serves (a package's `defineCapability` declarations) -and every permission set's `systemPermissions`, so a declared capability that -no set grants yet is not reported. - -## Configuration - -**The console has no configuration file.** It declares no apps, objects or views of its own — -it renders whatever the server it is pointed at publishes. There are only two inputs: - -**1. `VITE_SERVER_URL` — which backend to talk to.** A build-time Vite variable, and the only -setting the console itself owns. It seeds the data adapter's base URL and the runtime-config -fetch; `apps/console/.env.development` ships it **empty**, which means same origin — the -Vite dev server proxies `/api/*` to the backend. See [Running with a Real Backend](#running-with-a-real-backend). - -**2. Server-pushed runtime config — everything else.** Before React mounts, the console -resolves `/api/v1/runtime/config` from that server and applies it: product branding, feature -flags (marketplace, AI Studio, SSO, custom domain, the storage-usage reading), the cloud URL, and a -host's request to draw no app switcher for a viewer served one app (`hideSingleAppSwitcher`). Operators configure -these on the **server**, not in the SPA, which is why changing them needs no console rebuild. - -Apps, objects and views themselves are metadata fetched over HTTP — discovered at connect -time and loaded on demand. To change what the console shows, change the metadata on the -server: author it in the ObjectStack server project (`objectstack.config.ts` lives **there**, -not here) or edit and publish it from Studio. See -[ObjectOS Integration](/docs/guide/objectos-integration) for the server-side configuration -shape. - -## Running with a Real Backend - -`VITE_SERVER_URL` is the setting that decides which backend the console talks to — the data adapter, auth, i18n and action endpoints all hang off it. - -1. In dev, leave `VITE_SERVER_URL` empty and point the dev proxy at your server instead. - Only `/api/*` is proxied, to `DEV_PROXY_TARGET` when set and `http://localhost:3000` - otherwise: - ```bash - DEV_PROXY_TARGET=https://demo.objectstack.ai pnpm dev - ``` - This keeps the page and the API on one origin. Setting `VITE_SERVER_URL` to an absolute origin still works, but it opts dev out of same-origin and into CORS — an absolute `VITE_SERVER_URL` is the right setting for a *built* console deployed apart from its backend. -2. The console will use the ObjectStack client to discover metadata and perform CRUD operations against the server. - -## Where the Code Lives - -Most of what you see in the console does not live in `apps/console`. The shell and layout, sidebar, header, command palette, object list and record detail views all ship from **`packages/app-shell`** (`@object-ui/app-shell`), so any host application can mount the same experience; the heavier view surfaces (grid, kanban, calendar, charts, designer) come from the `@object-ui/plugin-*` packages. - -`apps/console` is the assembly layer on top: it owns the route tree, registers the plugin set, wires the backend connection, and adds the surfaces specific to this app (auth pages, the docs portal, system and settings pages). So when you want to change something you *see* in the console, look in `packages/app-shell` first. - -## See Also - -- [Console Architecture](/docs/guide/console-architecture) — data flow, routing, and plugin integration -- [Schema Overview](/docs/guide/schema-overview) — the JSON protocol that drives the console -- [Data Source](/docs/guide/data-source) — how the adapter fetches and caches data diff --git a/ui/content/docs/guide/dashboard-filters.md b/ui/content/docs/guide/dashboard-filters.md deleted file mode 100644 index db0f06650fb..00000000000 --- a/ui/content/docs/guide/dashboard-filters.md +++ /dev/null @@ -1,359 +0,0 @@ ---- -title: "Dashboard-Level Filters" ---- - -A dashboard often needs one top-level filter — a date range, a region select — -that drives **several charts at once**. ObjectUI models this as a -**dashboard-level parameter**, not a shared dataset: - -- The **filter control and its value live on the dashboard** — hosted as - dashboard-level variables (the page/dashboard variables primitive). -- Each widget declares which of **its own** fields a filter binds to via - `filterBindings` — a small mapping, not a copied query. -- At render time the dashboard **broadcasts** the active values into every - bound widget's own query, `AND`-combined with the widget's own `filter`. - -Charts stay inline and self-contained; one place owns the filter; each chart -edit stays local. - -> **Working examples**: the schema catalog ships a -> `plugin-dashboard/filtered-dashboard` example plus variants for dynamic -> options, text/number/lookup filter types, widgets over two datasets, the -> `targetWidgets` allow-list, and date presets with a custom range. Every -> widget in them binds a `dataset`, exactly as the tutorial below does. They -> are **presentation** examples — the filter declarations are what they teach. -> The docs gallery draws them with no application behind it by answering each -> dataset query with placeholder rows that no filter reaches, so the numbers -> on the page are not real. - -## Tutorial: from zero to a filtered dashboard - -### Step 1 — a plain dashboard - -Start from two charts over **different** datasets. Without filters they always -show everything: - -```json -{ - "type": "dashboard", - "columns": 2, - "widgets": [ - { - "id": "invoices_by_status", - "title": "Invoices by Status", - "type": "bar", - "dataset": "invoices", - "dimensions": ["status"], - "values": ["count"] - }, - { - "id": "accounts_signed", - "title": "Accounts Signed", - "type": "line", - "dataset": "accounts", - "dimensions": ["signed_month"], - "values": ["count"] - } - ] -} -``` - -#### Where a widget's data comes from - -A widget binds a semantic-layer **dataset** (ADR-0021) — `"dataset": "invoices"` -— and selects its `dimensions` and `values` from it by name. It never carries -rows. Filters scope that dataset query: the dashboard merges the scoped filter -into it as `runtimeFilter`. - -> **Not an authoring surface: inline widget data.** `options.data` (whether an -> array of rows or a `{ "provider": "object", … }` query), `options.xField` / -> `options.yField`, a metric's `options.value` / `options.description` / -> `options.trend`, and a `component` chart's `chartType` / `xAxisKey` / -> `series` are not dashboard widget keys. `@object-ui/types`' strict authoring -> face (`StrictAnyComponentSchema`) refuses each of them by name, and -> `@objectstack/spec` requires `dataset` on every widget. The renderer still -> draws a stored widget that carries them, but that path is renderer-internal -> (ADR-0021), not something to author against: bind the widget to a dataset. -> One stored form no longer draws at all: a single-value widget (`metric`, -> `gauge`, `solid-gauge`, `kpi`, `bullet`, or a widget with no `type`) whose -> `options.data` is a `{ "provider": "object", … }` query shows the -> retired-format prompt below instead of its number (objectui#11525), as a -> `pivot` widget with that query already did. And one key no longer draws on -> any widget: `options.description`, the metric sub-caption, is retired at both -> ends (objectui#11389), so a stored one draws nothing. A widget's one authored -> description is `widget.description`, the card-header subtitle. - -> **Retired: the top-level inline analytics shape.** `object` + -> `categoryField` / `valueField` / `aggregate` on the widget itself (and the -> pivot `rowField` / `columnField` pair) was **removed** — the renderer no -> longer reads those keys, and a stored widget still carrying them renders a -> visible *"This widget uses a retired data format. Edit it to bind a dataset."* -> prompt instead of a chart. Rebind such a widget to a `dataset` (select its -> `dimensions` and `values` by name). `@objectstack/spec` refuses the retired -> shape at publish, so this is not a soft deprecation. - -### Step 2 — add the built-in date range - -Declare `dateRange` at the dashboard level. A preset/custom date-range control -appears in the filter bar above the widgets: - -```json -{ - "dateRange": { - "field": "created_at", - "defaultRange": "last_30_days", - "allowCustomRange": true - } -} -``` - -- `field` — the **default** field the range applies to on every bound widget - (falls back to `created_at` when omitted). -- `defaultRange` — the initially selected preset: `today`, `yesterday`, - `this_week`, `last_week`, `this_month`, `last_month`, `this_quarter`, - `last_quarter`, `this_year`, `last_year`, `last_7_days`, `last_30_days`, - `last_90_days`, or `custom` (starts empty and lets the user pick). When - omitted, the default `@objectstack/spec` declares for this key applies - (`this_month`), so a bare `dateRange: { "field": "created_at" }` opens - filtered to the current month, not unfiltered. -- `allowCustomRange` — offer a "Custom…" item that opens a from/to calendar - (default `true`). - -`dateRange` is the `@objectstack/spec` `DashboardSchema.dateRange` shape, taken -by reference on both the validator and the TypeScript type: a preset name -outside the list above, or any other key inside the object (`preset`, `range`, -`dateField`, …), is refused at validation rather than silently ignored. - -Presets stay **symbolic** until query time: they compile to date-macro tokens -(`{30_days_ago}`, `{current_month_start}`, …) that each widget resolves -exactly like hand-authored widget filters — so a dashboard saved today still -means "last 30 days" tomorrow. - -### Step 3 — add a global filter - -Add a `globalFilters` entry. Each entry renders one control in the filter bar: - -```json -{ - "globalFilters": [ - { - "name": "region", - "field": "region", - "label": "Region", - "type": "select", - "options": [ - { "value": "EMEA", "label": "EMEA" }, - { "value": "APAC", "label": "APAC" }, - { "value": "AMER", "label": "AMER" } - ] - } - ] -} -``` - -- `name` — the **stable filter name**: the variable key the value is published - under, and the key widgets reference in `filterBindings`. Defaults to - `field`. (`"dateRange"` is reserved for the built-in date range.) -- `field` — the default field the filter applies to on bound widgets. -- `object` — optional: the object `field` lives on. Declaring it opts the - filter into that object's translations: its field label and option labels - resolve through the same `fields..` translation-bundle - convention lists and forms use, with `label` as the fallback (see - [i18n](#i18n)). Not the same key as `optionsFrom.object` below, which names - the object dynamic options are fetched from. -- `type` — the control type: `text`, `number`, `select`, `lookup`, or `date`. - -| Type | Control | Generated condition | -| --- | --- | --- | -| `text` | input | `{ field: { "$contains": value } }` | -| `number` | numeric input | `{ field: value }` (equality) | -| `select` / `lookup` | dropdown | `{ field: value }` (or `$in` for arrays) | -| `date` | preset/custom range | `{ field: { "$gte": from, "$lte": to } }` | - -A `date` filter's `defaultValue` is a **string**, and exactly three spellings -are accepted: - -- a **preset name** from the `defaultRange` list above (`"last_7_days"`) — it - is lifted to that preset's range, the same as picking it in the control; -- an **ISO date** (`"2026-01-15"`) — equality on that day; -- a **date-macro token** (`"{today}"`, `"{7_days_ago}"`) — resolved at query - time like any other filter token. - -Anything else — a misspelled preset such as `"last_7_dayz"` — is **skipped**, -and the runtime logs a `console.warn` naming the filter and the value. It is -deliberately not compared as-is: `field = "last_7_dayz"` matches no row, and -the widget would render a perfectly healthy-looking `0`. - -Static `options` are `@objectstack/spec` object pairs — -`{ "value": "amer", "label": "AMER" }`, where `label` is a string or an inline -per-locale map (`{ "en": "AMER", "zh-CN": "美洲" }`). This is the only form the -platform accepts: a dashboard is validated against `GlobalFilterSchema` when it is -published, and anything else is refused there. objectui's own validator -(`@object-ui/types`, which `objectui validate` runs) uses the same spec schema, so -it refuses the same documents before they reach the platform. - -> **Not accepted: the bare-string shorthand.** `"options": ["EMEA", "APAC"]` is -> refused by `GlobalFilterSchema` at publish, and the runtime no longer lifts it -> either ([objectui#4356](https://github.com/objectstack-ai/objectui/issues/4356)): -> a bare-string member yields no option, so a stored dashboard authored this way -> renders that filter with an empty option list, and in development a single -> `console.warn` names the filter and the dropped members. Rewrite each string -> `X` as `{ "value": "X", "label": "X" }`. - -Options can also be fetched from an object at runtime: - - -```json -{ - "name": "industry", - "field": "industry", - "label": "Industry", - "type": "select", - "optionsFrom": { - "object": "accounts", - "valueField": "industry", - "labelField": "industry" - } -} -``` - -`object`, `valueField` and `labelField` are all required; when the options have -no separate label column, name the value field again, as above. An optional -`filter` narrows the source records and is a filter-condition object -(`{ "status": "active" }`), not an array. - -With a dataset-capable data source, `optionsFrom` resolves distinct values -**server-side** (a GROUP BY over the source object), so the option list is -complete regardless of row count. Data sources without dataset queries fall -back to a best-effort client-side dedupe over the first 200 records. - -### Step 4 — bind each widget's own fields - -By default every filter applies to its own `field` on every widget. When a -widget stores the concept under a different field — or should ignore a filter -— declare `filterBindings` on the widget: - -```json -{ - "widgets": [ - { - "id": "invoices_by_status", - "type": "bar", - "dataset": "invoices", - "dimensions": ["status"], - "values": ["count"] - }, - { - "id": "accounts_signed", - "type": "line", - "dataset": "accounts", - "dimensions": ["signed_month"], - "values": ["count"], - "filterBindings": { "dateRange": "signed_at", "region": "sales_region" } - }, - { - "id": "total_invoices", - "title": "Total Invoices (all regions)", - "type": "metric", - "dataset": "invoices", - "values": ["count"], - "filterBindings": { "region": false } - } - ] -} -``` - -Binding rules, in precedence order: - -1. `filterBindings[name]` as a **string** — apply the filter to that field. -2. `filterBindings[name]: false` — opt this widget out of that filter. -3. Legacy `targetWidgets` on the filter — when set, only listed widget ids get - the default binding (an explicit `filterBindings` entry still wins). -4. Otherwise the filter applies to its own `field` (the built-in date range - defaults to `dateRange.field ?? 'created_at'`). - -That's the whole feature: changing any filter live re-scopes every bound -widget, each against **its own** field. Here it is running in the showcase -app's *Revenue Pulse* dashboard — the date range's default field is the -invoice `issued_on`, account widgets re-map it to `signed_on`, and the -"Accounts (all time)" KPI opts out of both filters: - -![Revenue Pulse — dashboard-level date + region filters over two objects](/img/guide/dashboard-filters/revenue-pulse.png) - -Selecting **EMEA** re-scopes every bound widget live (invoices via their own -`region`, accounts via `sales_region`), while the opted-out KPI holds steady — -and a Reset button appears once any filter deviates from its default: - -![Revenue Pulse re-scoped to EMEA — bound widgets update, the opted-out KPI holds](/img/guide/dashboard-filters/revenue-pulse-emea.png) - -Bindings can also be edited visually: the Studio dashboard widget inspector -shows a **Dashboard filter bindings** section (one row per declared filter) -with an Apply toggle (opt-out) and a field picker for the override — no JSON -editing required. - -![The widget inspector's Dashboard filter bindings section](/img/guide/dashboard-filters/filter-bindings-inspector.png) - -## Reading filter values in expressions - -Filter values are hosted as dashboard variables, so any widget expression can -read them under the `page.` scope, keyed by the filter's `name`: - -```json -{ - "type": "text", - "content": "Region: ${page.region || 'All'}" -} -``` - -The built-in date range is an object under `page.dateRange` — a preset selection -is `{ "preset": "last_30_days" }`, a custom range is -`{ "from": "2026-01-01", "to": "2026-03-31" }` (either bound may be absent). - -## Dataset widgets - -Every widget is bound to a semantic-layer `dataset`, and the dashboard scopes -each one the same way: it merges the scoped filter into the widget's `filter`, -which the dataset widget forwards to the dataset query as `runtimeFilter`. -Widgets over different datasets mix freely on one filtered dashboard — the -`plugin-dashboard/filtered-dashboard-dataset-widgets` catalog entry binds two -widgets to a `sales` dataset beside one bound to `invoices`, and each is scoped -through its own query. - -## Nested variable scopes - -When a filtered dashboard is embedded inside a Page that declares its own -`variables`, the two scopes **merge**: inside the dashboard subtree, `page.*` -resolves the outer Page's variables plus the dashboard's filter values, and a -dashboard filter only shadows an outer variable that has the **same name**. -Writes route to the scope that defines the variable — setting an outer-page -variable from inside the dashboard updates the outer scope, so both subtrees -stay in sync. - -## Known limitations - -- **A binding is applied as written** — the dashboard does not know a - dataset's fields, so it cannot check a binding target for you. A default - binding whose field the widget's data does not have produces an empty - widget rather than a silent no-op, which is the visible, fixable failure: - map the filter explicitly with `filterBindings: { "": "" }`, or - opt out with `false`. (`buildWidgetScopedFilter` can skip an unknown default - field with a console warning when a host passes it the widget's known field - names; the dashboard renderer does not.) - -## i18n - -The filter bar's strings resolve from the `dashboard.filters.*` keys -(`@object-ui/i18n` ships `en` and `zh` entries). A control's label comes from -its filter's `label`, so translate that in your schema metadata — unless the -filter declares `object`. Then the app's translation bundle wins: the field -label resolves from the `fields..` entry and each option label -from `fieldOptions...`, the same convention lists and -forms use, and the authored `label` (the option's own `label`, for an option) -is only the fallback when the bundle has no entry. - -## Spec alignment - -`DashboardSchema.dateRange`, `GlobalFilterSchema` (including `name`) and -`DashboardWidgetSchema.filterBindings` are part of `@objectstack/spec` -(framework#2501). Author dashboards against the spec shapes; ObjectUI renders -them. diff --git a/ui/content/docs/guide/data-source.md b/ui/content/docs/guide/data-source.md deleted file mode 100644 index a80d57fae4f..00000000000 --- a/ui/content/docs/guide/data-source.md +++ /dev/null @@ -1,623 +0,0 @@ ---- -title: "Data Connectivity" ---- - -ObjectUI follows the **Universal Adapter Pattern**. UI components do not hardcode transport details. They receive a `DataSource` implementation from `SchemaRendererProvider` and call a stable CRUD/query contract. - -This keeps the renderer backend-agnostic: ObjectStack, REST, GraphQL, and proprietary backends can all be adapted behind the same interface. - -## The Interface - -The canonical interface lives in `@object-ui/types`: - -```typescript -import type { BatchTransactionOperation, QueryParams, QueryResult } from '@object-ui/types'; - -export interface DataSource { - find(resource: string, params?: QueryParams): Promise>; - findOne(resource: string, id: string, params?: QueryParams): Promise; - create(resource: string, data: Partial): Promise; - update( - resource: string, - id: string, - data: Partial, - opts?: { ifMatch?: string }, - ): Promise; - delete( - resource: string, - id: string, - opts?: { ifMatch?: string }, - ): Promise; - - // Optional: atomically persist an ordered set of cross-object operations - // (master-detail save). `{ $ref: }` links a child to a parent - // created earlier in the same batch. Adapters without server-side atomicity - // may emulate it — see below. - batchTransaction?( - operations: BatchTransactionOperation[], - ): Promise<{ results: any[] }>; - - getObjectSchema(objectName: string): Promise; -} -``` - -`find()` returns a `QueryResult` so components can receive both rows and pagination metadata: - -```typescript -interface QueryResult { - data: T[]; - total?: number; - page?: number; - pageSize?: number; - hasMore?: boolean; - cursor?: string; - metadata?: Record; -} -``` - -## Available Adapters - -### ObjectStack Adapter (Official) - -Use `@object-ui/data-objectstack` for ObjectStack-compatible backends. - -```bash -pnpm add @object-ui/data-objectstack -``` - -```typescript -import { createObjectStackAdapter } from '@object-ui/data-objectstack'; - -const dataSource = createObjectStackAdapter({ - baseUrl: 'https://api.your-instance.com' -}); -``` - -## Usage - -Inject the data source at the renderer boundary: - -```tsx -import '@object-ui/components'; -import '@object-ui/fields'; -import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; -import { createObjectStackAdapter } from '@object-ui/data-objectstack'; -import type { DeclaredNode } from '@object-ui/types'; - -const dataSource = createObjectStackAdapter({ - baseUrl: 'https://api.example.com' -}); - -// `object-grid` is a data-bound node: it reads `objectName` from its -// `properties` bag and queries the injected data source for the rows. -const mySchema: DeclaredNode = { type: 'object-grid', properties: { objectName: 'users' } }; - -function App() { - return ( - - - - ); -} -``` - -## Creating a Custom Adapter - -If you have a proprietary backend, wrap its SDK or client in a `DataSource` implementation. Keep transport details in the adapter, not in renderers. - -```typescript -import type { DataSource, QueryParams, QueryResult } from '@object-ui/types'; - -type User = { - id: string; - name: string; - email: string; -}; - -type BackendClient = { - listUsers(params?: QueryParams): Promise<{ rows: User[]; total?: number }>; - getUser(id: string | number): Promise; - createUser(data: Partial): Promise; - updateUser(id: string | number, data: Partial): Promise; - deleteUser(id: string | number): Promise; - describeObject(name: string): Promise; -}; - -class UserDataSource implements DataSource { - constructor(private readonly client: BackendClient) {} - - async find(resource: string, params?: QueryParams): Promise> { - if (resource !== 'users') { - return { data: [], total: 0 }; - } - - const result = await this.client.listUsers(params); - return { - data: result.rows, - total: result.total, - }; - } - - findOne(_resource: string, id: string | number): Promise { - return this.client.getUser(id); - } - - create(_resource: string, data: Partial): Promise { - return this.client.createUser(data); - } - - update(_resource: string, id: string | number, data: Partial): Promise { - return this.client.updateUser(id, data); - } - - delete(_resource: string, id: string | number): Promise { - return this.client.deleteUser(id); - } - - getObjectSchema(objectName: string): Promise { - return this.client.describeObject(objectName); - } -} -``` - -## Query Parameters - -ObjectUI uses OData-style query keys for broad compatibility: - - -```typescript -await dataSource.find('users', { - $select: ['id', 'name', 'email'], - $filter: { status: 'active' }, - $orderby: { name: 'asc' }, - $skip: 0, - $top: 25, - $count: true, -}); -``` - -Data-aware plugins may also use optional methods such as `batchTransaction`, `bulkUpdate`, `bulkDelete`, `getView`, or `listViewOverrides` when an adapter supports them. Keep the required CRUD methods implemented first, then add optional capabilities as your UI needs them. - -### Bulk writes: per-record results and one refresh per run - -`bulkUpdate` and `bulkDelete` resolve to either a plain count or a `BulkWriteResult`: the count plus one `{ id, error }` entry per record the server refused, with the server's reason. An adapter whose server reports per-row outcomes should return the `BulkWriteResult` (the `@object-ui/data-objectstack` adapter does, from the `results[].errors[]` of `updateMany` / `deleteMany`). An adapter that has only a count keeps returning the number. A caller handles both: - -```typescript -import type { DataSource } from '@object-ui/types'; - -declare const dataSource: DataSource; - -const res = await dataSource.bulkUpdate?.('order', ['o1', 'o2', 'o3'], { status: 'closed' }); -if (typeof res === 'number') { - console.log(`${res} updated`); -} else if (res) { - console.log(`${res.succeeded} updated`); - for (const { id, error } of res.errors) console.warn(`${id}: ${error}`); -} -``` - -The grid's bulk actions read both shapes. A refused record shows up in the result dialog and the error CSV under its own id and reason, and **Retry** works on it, whatever the action's `batchSize`. - -Every `create` / `update` / `delete` normally fires one `onMutation` event, and every bound view refetches once per event. A loop that writes many rows (a bulk action on its per-row path, an undo) would refetch the list once per row. Wrap such a loop in the optional `batchMutationEvents(work)`: the events its writes fire are held and delivered when `work` settles, one per touched resource and mutation type, so bound views refresh once. The grid's bulk executor does this when the adapter has the method. - -```typescript -import type { DataSource } from '@object-ui/types'; - -declare const dataSource: DataSource; - -const ids = ['o1', 'o2', 'o3']; -const write = async () => { - for (const id of ids) await dataSource.update('order', id, { status: 'closed' }); -}; -await (dataSource.batchMutationEvents ? dataSource.batchMutationEvents(write) : write()); -``` - -`listMetadataItems(type)` lists one metadata registry type as `{ name, label }` headers, for the `position-ref` / `permission-ref` field widgets (see the [Fields guide](./fields.md)). An empty array means the registry has no item of that type, so a failed load must **reject**, never resolve `[]`. - -### Cross-object atomic writes (`batchTransaction`) - -Master-detail saves (a parent record plus its child line items) go through -`dataSource.batchTransaction(operations)` — one ordered list of cross-object -create/update/delete operations, where a child's foreign key can be -`{ $ref: }` to point at a parent created in the same batch. -The `@object-ui/data-objectstack` adapter maps this to the published -`@objectstack/client` `data.batchTransaction` SDK method, which drives the -server's atomic `POST /api/v1/batch` endpoint (commit-all-or-roll-back-all). -Adapters without a -transactional endpoint don't need to hand-write orchestration: call -`emulateBatchTransaction(dataSource, operations)` from `@object-ui/core`, which -executes the operations sequentially (resolving `$ref`s) with best-effort -compensation on failure. UI components never branch on atomicity — they call -`runBatchTransaction(dataSource, operations)` (also from `@object-ui/core`), -which uses the adapter's method when present and emulates otherwise. - -The `@object-ui/data-objectstack` adapter decides whether it can trust server -atomicity **declaratively**, at connect time: it reads the -`capabilities.transactionalBatch` flag from `GET /api/v1/discovery` -(framework #3298). When the backend advertises `true`, the adapter treats any -`/batch` failure as a real error — no non-atomic client-side compensation. When -the flag is `false` or absent (a backend predating #3298), it keeps the legacy -behaviour: probe `/batch` and fall back to the non-atomic emulation on -`404`/`405`/`501`. Atomic cross-object saves are therefore guaranteed only -against backends that advertise the capability; older ones still save, but -best-effort. See the -[adapter README](https://github.com/objectstack-ai/objectui/blob/main/packages/data-objectstack/README.md#cross-object-atomic-batch-batchtransaction) -for the full capability table and minimum-backend note. - -## Per-element data binding on a page (`dataSource`) - -A metadata page component carries its own data binding — -`PageComponentSchema.dataSource`, the spec's `ElementDataSourceSchema` — so one -page can show several objects without a page-level object context: - -```json -{ - "type": "list-view", - "dataSource": { "object": "account", "view": "hot", "limit": 10 } -} -``` - -This is metadata, **not** the data-source adapter. The two share a name and are -different things: the adapter is injected by the host (`SchemaRendererProvider`), -while `dataSource` on a schema node is JSON describing *what to query*. A -renderer therefore reads the binding off `schema.dataSource` and gets its adapter -from context — never from a prop the schema could occupy. `SchemaRenderer` strips -the binding from the props it spreads for exactly this reason. - -`view` names a **saved view** of that object; its columns, filter, sort and page -size are applied to the render, so a page never has to keep a second copy of a -view's configuration. `filter` is *additional* criteria — it AND-combines with the -view's filter rather than replacing it — while `sort` and `limit` override the -view's. Only a usable `limit` overrides: a binding cap the contract refuses (`0`, -a negative, a non-integer) is treated as not authored and yields to the view's -cap exactly as an absent one would, and the view's cap is held to the same rule. A `view` name that does not resolve is reported as a configuration error; -it never degrades into an unfiltered query for the object. - -`@object-ui/react` exposes `useElementDataSource(schema, dataSource?)` for -renderers that need the same resolution, and `@object-ui/core` exposes the pure -parts (`isElementDataSourceConfig`, `resolveSavedView`, -`composeElementDataSource`). - -### Which blocks consume it, and which keys each one honours - -The binding is declared on every page component, but a component can only honour -the keys it has a read site for — a calendar has no page to cap, a metric is one -aggregated number, a form edits one record. Each block therefore maps the keys it -reads and leaves the rest alone; a key written onto a schema slot the block -ignores would be accepted and dropped, which is the defect this binding removes. - -| block | `object` | `view` | `filter` | `sort` | `limit` | -|---|---|---|---|---|---| -| `list-view` | ✅ (`data`) | ✅ | ✅ | ✅ | ✅ | -| `object-grid` | ✅ | ✅ | ✅ | ✅ | ✅ | -| `element:record_picker` | ✅ | ✅ | ✅ | ✅ | ✅ | -| `element:number` | ✅ | filter | ✅ | — single value | — single value | -| `element:repeater` | ✅ | filter / sort / limit | ✅ | ✅ | ✅ | -| `record:related_list` | ✅ | columns / filter / sort / limit | ✅ | ✅ | ✅ | -| `object-calendar` | ✅ | filter / sort | ✅ | ✅ | — platform ceiling | -| `object-kanban` | ✅ | filter / limit | ✅ | — no ordering | ✅ (`limit`) | -| `object-chart` | ✅ | filter | ✅ | — engine orders | — no page | -| `object-metric` | ✅ | filter | ✅ | — single value | — single value | -| `object-gantt` | ✅ | filter / sort | ✅ | ✅ | — platform ceiling | -| `object-map` | ✅ | filter / sort | ✅ | ✅ | — platform ceiling | -| `object-pivot` | ✅ | filter | ✅ | — grouping orders | — totals need all rows | -| `object-timeline` | ✅ | filter / sort / limit | ✅ | ✅ | ✅ (`limit`) | -| `object-form` | ✅ | error-checked only | — no collection query | — | — | -| `embeddable-form` | ✅ | error-checked only | — no collection query | — | — | -| `object-master-detail-form` | ✅ | error-checked only | — no collection query | — | — | -| `record:line_items` | ✅ (`childObject`) | filter / sort / limit | ✅ (AND parent scope) | ✅ | ✅ (`limit`) | -| `record:details` | ✅ (its record) | filter / sort | ✅ | ✅ (picks the first row) | — one row is read | -| `record:highlights` | ✅ (its record) | filter / sort | ✅ | ✅ (picks the first row) | — one row is read | -| `record:quick_actions` | ✅ (its record) | filter / sort | ✅ | ✅ (picks the first row) | — one row is read | - -Reading the `view` column: it lists what a named saved view actually contributes -on that block. A view name that does not resolve is reported as a configuration -error on **every** block in the table, including the ones that take nothing else -from the view — so a typo never passes silently, whatever the block. - -Reading the `object` column: on a block with an object key it lands on that key, -which is `objectName` everywhere except `list-view` and `record:line_items`. A -`list-view` node names its object in `data` (`{ "provider": "object", "object": … }`), -the spec's list view spelling, and a bound `dataSource.object` outranks -`data.object`; the node's own `objectName` is not read. On `record:line_items` the collection the panel -lists, fetches and writes is `childObject`. Its `relationshipField` is *not* part -of the binding and stays the author's — it has to name a field on the bound child -object, so rebinding `object` without updating it is an authoring error the panel -cannot paper over. A `record:line_items` node bound this way needs no `childObject` -of its own, and the page compile accepts it; a node that names its child object in -neither place shows a configuration hint naming `childObject` and loads nothing. - -Two kinds of block have no object key for the binding to land on. The -`element:*` blocks read `dataSource.object` themselves (see below). The three -one-record blocks, whose rows read "its record", take the binding's `object` as -the object of the record they show, and the bound row becomes that record. - -`record:details`, `record:highlights` and `record:quick_actions` show the -record their record context carries. Bound, a block reads the FIRST row of -the bound set by the binding's `sort`, under the caller's own identity, and that -row becomes its record, so a product page shows a record without being a -`type: 'record'` page: - -```json -{ - "type": "record:details", - "properties": { "fields": ["name", "environment_type"] }, - "dataSource": { - "object": "sys_environment", - "filter": [{ "field": "environment_type", "operator": "equals", "value": "production" }], - "limit": 1 - } -} -``` - -When no row matches — or the server refuses the read — the block draws its -authored `emptyState` in its place, when its `properties` declare one. That is -the spec's empty-state shape, the one `object-grid` takes: `{ title, message, -icon, link }`, with `title` and `message` as plain strings or inline locale maps -and `link` as `{ label, url }`. A block that declares none draws what it draws -for a record that is not there: `record:details` its "record not found" state, -`record:highlights` nothing, `record:quick_actions` an empty bar. The empty state -is never drawn while the read loads, on a block with no `dataSource`, or for a -read that fails any other way, which is reported in the block's place. `record:quick_actions` -runs the object's declared actions against the bound row's id. The bound row is -re-read whenever a write to its object is announced on the page (the data -source's mutation events, or `notifyDataChanged`). `objectui validate` warns on a -record block whose binding has no `limit: 1`, so the binding says which record it -means. On a record page, a block with no `dataSource` still shows the page's -record; a bound one shows its bound record instead, read-only. - -Because the binding lands on `objectName` (on `list-view`, it stands in for -`data`), a node bound this way needs no `objectName` (or `data`) of its own. The schema validator (`StrictAnyComponentSchema`, which -`objectui validate` runs, and the tolerant `safeValidateSchema` alike) counts the binding as a record source of `list-view`, -`object-grid`, `object-kanban`, `object-calendar`, `object-gantt` and `object-map`, -and still refuses one of those nodes that declares no other record source and names -its object in neither place. A binding with an empty `object` names nothing. - -The page compile accepts such a node too, on every row above whose object lands on -`objectName`, and on `list-view`: those registrations do not declare `objectName` -(on `list-view`, `data`) required, because the binding can supply it. The exception is `record:related_list`, whose spec row -requires `objectName`, so the page compile still refuses one of those nodes without -it. A node that names its object in neither place renders a short hint, "No object -named: set objectName or dataSource.object.", in place of an empty list, board, -form, chart, metric or pivot. On `list-view` the hint names the key a page node -writes: "No object named: set data.object or dataSource.object.". `object-grid` -shows its own "Object name required for data fetching" error instead. A block with another record source (inline `data` -rows, a form's inline fields — `customFields`, or `sections` whose every field is -inline — a chart's `dataset`, a `bind` path, or a metric's `fallbackValue`) draws -from that source and shows no hint. - -`element:record_picker` and `element:number` keep their display configuration -in the node's `properties` bag and take their query from the binding **only**: -every query key either one reads comes from `dataSource` and from nowhere else. -The flat `properties.object`, `properties.filter`, `properties.sort` and -`properties.limit` on these two elements are not read, and `@objectstack/spec` -retires them (objectstack#11509). `element:number` no longer offers its flat -`object` or `filter` (objectui#12085), so the page validator reports either one -as a prop the metric does not have. The binding's filter is AND-combined with its -view's filter, as on every block above. On `element:number`, -`{ "dataSource": { "object": "contact" }, "properties": { "aggregate": "count" } }` -is a complete metric; its `sort` and `limit` are not read, because an aggregate -has no ordering and a capped count would be a wrong number, and a filter the -converter refuses shows the configuration-error panel instead of a count. -Either of the two whose node names no `dataSource.object` issues no query: an -`element:number` that sets `aggregate` shows a short "No object named: set -dataSource.object." notice instead of a count, and an `element:record_picker` -offers no records. The Studio page designer writes the metric's object into -`dataSource`. - -`element:repeater` reads its query from the binding alone. `@objectstack/spec` -18 retired the flat `properties.object`, `properties.filter`, `properties.sort` -and `properties.limit` (objectstack#11509), and the repeater reads none of them -(objectui#12345); the spec's conversion moves them into `dataSource`. A node -that still carries one is refused by the spec's row and by the -`@object-ui/types` mirror, and the page validator reports it as a prop the -repeater does not have. The repeater requires no prop (objectui#12085): a node -whose `dataSource` names no object issues no query and shows its empty state. -With a named saved view, the binding's own `sort` and `limit` win and the -view's are the baseline, and the view's filter is AND-combined with the -binding's. The Studio page designer writes the repeater's object and its row -limit into `dataSource`, as it does the metric's object. - -```json -{ - "type": "element:repeater", - "dataSource": { - "object": "task", - "filter": [{ "field": "status", "operator": "equals", "value": "open" }], - "sort": [{ "field": "due_date", "order": "asc" }], - "limit": 5 - }, - "properties": { "titleField": "subject", "fields": ["due_date"] } -} -``` - -#### A related list's parent record: `parentDataSource` - -`record:related_list` reads two bindings. Its `dataSource` is the CHILD rows the -list draws (its row in the table above). Its parent record — the record the -list hangs off — is the record page's record, or, when the block's -`properties` carry `parentDataSource`, the record that binding reads. The key -is a member of the spec's `record:related_list` row and takes the same object -binding `dataSource` takes. It is read the way the three one-record blocks -above read their record: the FIRST row of the binding by its `sort`, under the -caller's own identity, re-read whenever a write to its object is announced. -The list then draws that record's children through `relationshipField`, and -`relationshipValueField` is read on that row. So a product page lists a -record's children without being a `type: 'record'` page: - -```json -{ - "type": "record:related_list", - "properties": { - "objectName": "sys_member", - "relationshipField": "environment_id", - "parentDataSource": { - "object": "sys_environment", - "filter": [{ "field": "environment_type", "operator": "equals", "value": "production" }], - "limit": 1 - } - } -} -``` - -When no row matches — or the server refuses the read — the block says so in its -place, naming the object, and draws no list: an empty list would read as "this -record has no related records". A read that fails any other way is reported in -the block's place. `objectui validate` warns on a `parentDataSource` without -`limit: 1`, and refuses the key written on the node instead of in `properties`. -On a record page, a related list without `parentDataSource` lists the page -record's children, as before; with it, it lists the bound record's children. - -#### Scoping a filter to the record in view: `{record_id}` - -On a `type: 'record'` page, a filter value can name the record the page shows -with `{record_id}`, in a component's own filter and in its `dataSource.filter` -alike. On a person's record page this counts that person's open tasks, and the -next person's page counts theirs: - -```json -{ - "type": "element:number", - "dataSource": { - "object": "task", - "filter": [{ "field": "assignee", "operator": "equals", "value": "{record_id}" }] - }, - "properties": { "aggregate": "count" } -} -``` - -The id is the page's mounted record context, never a URL parameter or a page -variable. Anywhere with no record in context (a list view, a dashboard, a report, -a page that is not a record page) the token is refused by name: the renderer -warns, the value is left as written rather than dropped or blanked, and the -ObjectStack server refuses the query (`FILTER_TOKEN_UNRESOLVED`), so the number -never silently becomes a count over every record. `{record_id}` scopes what a -component *shows*; it is not access control, which stays with the server's -row-level security (objectui#7297). - -On `record:related_list` and `record:line_items` the composed filter is -AND-combined with the parent relationship condition, never substituted for it: a -child panel is always scoped to the record it appears on, and an *additional* -criterion can only narrow that set further. (Until objectstack#7118 -`record:related_list` declared `filter` without reading it, so a named view -contributed its columns / sort / limit while its filter was dropped — the list -could be wider than the view it named. That gap is closed; the `filter` cell above -is what closed it.) - -`object-timeline` and `record:line_items` were the two residual gaps in this table -until objectstack#7137. Neither had a `filter` / `sort` read site at all — the -timeline's whole fetch was `find(objectName, { options: { $top: 100 } })` and the -line-items panel's was the parent FK plus a fixed `$top: 500` — so a `view` named -on either resolved (a typo reported) and then contributed nothing: the rendered -rows could be **wider than the view they named**, with no error anywhere. Both now -read `filter`, `sort` and `limit`, so the cells above are ✅. Two notes on what -came with that: - -- The timeline's default window is `limit ?? 100` and it is now a real `$top`. - The old `{ options: { $top: 100 } }` nested the cap under a key that is not a - `QueryParams` field and that no adapter in this repo reads, so the intended cap - never reached the wire; a timeline over a large object fetched whatever the - server chose to return. Authoring `limit` (or a view's `pagination.pageSize`) - now sets it. -- `record:line_items` still does **not** take a view's `columns`: they are editable - `GridColumn` objects (`{ field, type, … }`) rather than a field-name projection, - so a view's column list would be the wrong *shape*, not merely a wider answer. - -`object-kanban` carried the same nesting, and until objectui#4025 this table read -`— fixed window` in its `limit` cell. There was no window: the board's cap was -written `{ options: { $top: 100 } }` — `$filter` at the top level where the -adapters read it, the cap one level down under a key that is not a `QueryParams` -field — so a board over a large object fetched every row the server would return -and grouped all of it into lanes, client-side. The cap is now a real `$top`, -defaulting to 100, and the `limit` cell is ✅ because the read site exists: a -`limit` authored on the block, the binding's `limit`, or the named view's -`pagination.pageSize` all set it. The board's `sort` cell still reads `— no -ordering` — lanes come from `groupBy` and the fetch declares no `$orderby`, so -mapping `sort` would be the accepted-and-dropped defect one block over. - -Note the two `limit`s on a board are different keys at different levels: the row -cap above is `limit` on the **board**, while `limit` on a **column** is that -lane's WIP limit (how many cards it may hold before it warns), which is display -behaviour and never touches the query. - -Reading `— platform ceiling` on `object-calendar` / `object-gantt` / `object-map` -(and on `object-tree`, which predates this table): those four are the **non-grid** -visualisations, and objectui#7210's maintainer ruling settled what their row -behaviour is. They fetch the whole **filtered** result set — a gantt cannot -compute a truthful `min(start) → max(end)` from one page, a map fits its camera to -every marker, and a tree assembled from a page loses every node whose parent fell -outside it — but the fetch is **bounded** by `NON_GRID_ROW_CEILING` -(`@object-ui/react`, currently 2,000). Past it the view draws the first N rows and -shows a footnote naming both N and the total. Silent truncation is the failure -that ruling exists to prevent: a cut-off schedule still looks like a schedule. - -The `limit` cell stays "not ✅" for all four because the ceiling is **not -authorable and must not become one** — a named constant in the renderer, by the -same ruling. An authored `limit`, a binding's `limit` and a named view's -`pagination.pageSize` all still fail to reach these queries, which is what the -cell has always meant. What changed is only that "no cap at all" is no longer -true. - -Remaining gap, recorded rather than papered over: - -- `object-form` / `embeddable-form` / `object-master-detail-form` resolve `view` - only to report an unresolvable name; a view that does resolve contributes - nothing, because a list view's columns are not a form layout. On the - master-detail form the bound object is the **parent**; child collections come - from `details[]`, by FK. - -Blocks not in the table (`dashboard`, the other `record:*` panels) do not consume -the binding yet. - -## Binding a page element to an API endpoint (`provider: 'api'`) - -`PageComponentSchema.dataSource` has a second shape beside the object binding: -the spec's `ElementApiDataSourceSchema`. It binds one same-origin endpoint -instead of the rows of an object: - -```json -{ - "type": "stat", - "dataSource": { "provider": "api", "read": { "url": "/api/v1/cloud/home" }, "path": "data" }, - "properties": { "label": "Members", "value": "${data.members.count}" } -} -``` - -`SchemaRenderer` honours it on every node: - -- It reads `read.url` with `GET`, through the host's authenticated fetch - (`SchemaRendererProvider`'s `apiFetch`; the console passes its signed-in - fetch), and selects `path` from the answer — dotted keys, a numeric segment - indexing a list. Omit `path` to bind the whole answer. -- The selected value is `data` in the node's `${…}` holes and in its - `visibleWhen`, and in the holes of everything the node renders. A hole that is - the whole string (`"${data.members.count}"`) evaluates to the value itself — a - number, a boolean, a list — rather than to text. -- While the read is in flight the node draws a loading line; a failed read, or - a `path` that selects nothing, draws an error panel naming why. The node is - never drawn without its data. -- The binding is judged by the spec's own schema before anything is read. An - absolute or protocol-relative `url`, a credential key, or a `method` other - than `GET` is refused with the spec's message, and no request is sent. - -Elements that bind the same URL at the same time share one request. A host can -also keep one open's answers: inside `ElementApiOpenScope` (from -`@object-ui/react`), a read that has already answered in the same open is -answered again with no request. The console mounts it and counts one open per -visit of a history entry, so a navigation entry's `visible` and a page element bound to -the same URL make one request, even when the entry's read settled first: - -- following a link, a navigation entry, or Back and Forward starts a new open, - and the page reads again, even on an entry visited before; -- a redirect that replaces the address (the app root's landing on its first - entry) stays in the open; -- a failed read is not kept, and any declared data change (`notifyDataChanged`, - which every save and action reports) drops what the open kept, so a read - after a write asks the server. - -Outside such a scope nothing is kept beyond the request in flight: a page that -mounts again reads again. - -The console's product-page blocks — `hero`, `stat`, `usage-bar`, `checklist` and -`tile-grid` — accept this binding in their arms; see the `@object-ui/app-shell` -README. diff --git a/ui/content/docs/guide/deployment.md b/ui/content/docs/guide/deployment.md deleted file mode 100644 index 238d347b1c5..00000000000 --- a/ui/content/docs/guide/deployment.md +++ /dev/null @@ -1,295 +0,0 @@ ---- -title: Deployment -description: "Deploy ObjectUI applications to Docker, Vercel, Railway, Netlify, and other platforms with production-ready configurations." ---- - -# Deployment - -ObjectUI apps are standard Vite + React applications, so they can be deployed anywhere that serves static files or runs Node.js containers. This guide provides copy-paste-ready configurations for the most popular platforms. - -## Prerequisites - -- A production build: `pnpm build` (runs `turbo run build` across all packages) -- The build output lives in `apps/console/dist/` (or your app's `dist/` folder) -- Environment variables configured for your target environment - -`pnpm build` also writes `apps/console/dist/sdui.manifest.json`, the SDUI component manifest of the console it built. An ObjectStack host serving this `dist` registers that file under `@objectstack/metadata-protocol`'s `SDUI_MANIFEST_SERVICE`, and its metadata save door validates page sources against it. The console package's `build` script writes it after `vite build`, so a caller that runs `vite build` directly gets the bundle without the manifest. - -## Docker - -Create a multi-stage `Dockerfile` at the project root: - -```dockerfile -# Stage 1: Build -FROM node:22-alpine AS builder -RUN corepack enable && corepack prepare pnpm@10 --activate -WORKDIR /app - -COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ -COPY packages/ packages/ -COPY apps/ apps/ -RUN pnpm install --frozen-lockfile -RUN pnpm build - -# Stage 2: Serve -FROM nginx:alpine AS runner -COPY --from=builder /app/apps/console/dist /usr/share/nginx/html -COPY <<'EOF' /etc/nginx/conf.d/default.conf -server { - listen 80; - root /usr/share/nginx/html; - index index.html; - - location / { - try_files $uri $uri/ /index.html; - } - - location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { - expires 1y; - add_header Cache-Control "public, immutable"; - } - - gzip on; - gzip_types text/plain text/css application/json application/javascript text/xml; -} -EOF - -EXPOSE 80 -CMD ["nginx", "-g", "daemon off;"] -``` - -Build and run: - -```bash -docker build -t objectui-app . -docker run -p 3000:80 objectui-app -``` - -## Vercel - -Create `vercel.json` in the project root: - -```json -{ - "buildCommand": "pnpm build", - "outputDirectory": "apps/console/dist", - "installCommand": "pnpm install --frozen-lockfile", - "framework": "vite", - "rewrites": [ - { "source": "/(.*)", "destination": "/index.html" } - ], - "headers": [ - { - "source": "/assets/(.*)", - "headers": [ - { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" } - ] - } - ] -} -``` - -Deploy with the Vercel CLI: - -```bash -npx vercel --prod -``` - -> **Note:** Set the **Root Directory** to the repository root so the monorepo workspace resolves correctly. - -## Railway - -Create `railway.json` in the project root: - -```json -{ - "$schema": "https://railway.com/railway.schema.json", - "build": { - "builder": "NIXPACKS", - "buildCommand": "corepack enable && pnpm install --frozen-lockfile && pnpm build" - }, - "deploy": { - "startCommand": "npx serve apps/console/dist -s -l tcp://0.0.0.0:$PORT", - "healthcheckPath": "/", - "restartPolicyType": "ON_FAILURE", - "restartPolicyMaxRetries": 3 - } -} -``` - -Push to your linked Railway project: - -```bash -railway up -``` - -## Netlify - -Create `netlify.toml` in the project root: - -```toml -[build] - command = "pnpm install --frozen-lockfile && pnpm build" - publish = "apps/console/dist" - -[build.environment] - NODE_VERSION = "22" - PNPM_VERSION = "10" - -# SPA fallback — redirect all routes to index.html -[[redirects]] - from = "/*" - to = "/index.html" - status = 200 - -[[headers]] - for = "/assets/*" - [headers.values] - Cache-Control = "public, max-age=31536000, immutable" -``` - -Deploy with the Netlify CLI: - -```bash -npx netlify deploy --prod -``` - -## Environment Variables - -ObjectUI uses Vite's `import.meta.env` for build-time configuration. Prefix all custom variables with `VITE_`. - -| Variable | Default | Description | -|----------|---------|-------------| -| `VITE_SERVER_URL` | `""` (same origin) | Absolute origin of the ObjectStack backend, e.g. `https://demo.objectstack.ai`. The one setting that matters — the data adapter, auth, i18n and action endpoints all hang off it. An empty value means same-origin, which is correct when the ObjectStack server serves the console itself; on a static host with no backend behind it, every `/api/v1/*` request then 404s. | -| `NODE_ENV` | `"development"` | Set automatically to `"production"` by `vite build`. | - -Because Vite inlines `import.meta.env` at **build time**, `VITE_SERVER_URL` has to be present when the build runs. Setting it only in a static host's runtime environment changes nothing — the value is already baked into the bundle. - -Create a `.env.production` file for production defaults: - -```bash -# Leave empty for same-origin; set an absolute origin for a split-origin deploy. -VITE_SERVER_URL=https://demo.objectstack.ai -``` - -For platform-specific configuration, set environment variables in each platform's dashboard or CLI: - -```bash -# Vercel -vercel env add VITE_SERVER_URL production - -# Railway -railway variables set VITE_SERVER_URL=https://demo.objectstack.ai - -# Netlify -netlify env:set VITE_SERVER_URL https://demo.objectstack.ai -``` - -> **Tip:** A split-origin deployment (console and backend on different origins) needs two things from the backend: CORS for the SPA origin (`Access-Control-Allow-Origin: `, `Access-Control-Allow-Credentials: true`), and auth cookies marked `SameSite=None; Secure` so they survive cross-site requests. - -## Build Optimization - -### Gzip and Brotli Compression - -Add the `vite-plugin-compression` plugin for pre-compressed assets: - -```bash -pnpm add -D vite-plugin-compression -``` - - - -```ts -// vite.config.ts -import compression from 'vite-plugin-compression'; - -export default defineConfig({ - plugins: [ - react(), - tailwindcss(), - compression({ algorithm: 'gzip' }), // .gz files - compression({ algorithm: 'brotliCompress', ext: '.br' }), // .br files - ], -}); -``` - -### Code Splitting - -Vite splits chunks automatically. For ObjectUI plugins, use dynamic imports to keep the initial bundle small: - -```tsx -import { createLazyPlugin } from '@object-ui/react'; - -const ObjectGrid = createLazyPlugin( - // The plugin package has no default export — name the component you want. - async () => ({ default: (await import('@object-ui/plugin-grid')).ObjectGrid }), - { fallback:
Loading grid...
} -); -``` - -### Bundle Analysis - -Visualize your bundle to find optimization opportunities: - -```bash -pnpm add -D rollup-plugin-visualizer -``` - - - -```ts -// vite.config.ts -import { visualizer } from 'rollup-plugin-visualizer'; - -export default defineConfig({ - plugins: [ - react(), - tailwindcss(), - visualizer({ open: true, gzipSize: true }), - ], -}); -``` - -### Production Build Command - -Bake the backend origin into the bundle and verify the output: - -```bash -VITE_SERVER_URL=https://demo.objectstack.ai pnpm build -``` - -`pnpm build` runs `turbo run build` across the workspace. Turbo runs in strict env mode, but it detects the console as a Vite package and passes `VITE_*` through automatically, so an inline `VITE_SERVER_URL` does reach the build. To build the console alone, use `pnpm build:console`. - -## Health Checks - -For containerized deployments, add a lightweight health check endpoint. Create `public/health.json` in your app: - -```json -{ "status": "ok" } -``` - -This file is copied to the build output as-is by Vite. Point your health check to `/health.json`: - -```dockerfile -# Docker HEALTHCHECK -HEALTHCHECK --interval=30s --timeout=3s --retries=3 \ - CMD wget -qO- http://localhost:80/health.json || exit 1 -``` - -```jsonc -// railway.json (excerpt) -{ - "deploy": { - "healthcheckPath": "/health.json" - } -} -``` - -For platforms that expect an HTTP 200 on `/`, the SPA `index.html` fallback already handles this. - -## Next Steps - -- [CI workflows](https://github.com/objectstack-ai/objectstack/tree/main/.github/workflows) — The repository-root workflows that build, test and release ObjectUI -- [Architecture Overview](/docs/guide/architecture) — How ObjectUI packages fit together -- [Quick Start](/docs/guide/quick-start) — Set up a new ObjectUI project from scratch -- [Theming](/docs/guide/theming) — Customize the look-and-feel before deploying diff --git a/ui/content/docs/guide/designing-app-navigation.md b/ui/content/docs/guide/designing-app-navigation.md deleted file mode 100644 index 0439dd978ab..00000000000 --- a/ui/content/docs/guide/designing-app-navigation.md +++ /dev/null @@ -1,140 +0,0 @@ ---- -title: "Designing App Navigation" -description: "How to choose between object navigation, named views, custom pages, and dashboards when composing an app" ---- - -# Designing App Navigation - -When you compose an app, the same requirement can often be expressed three -ways: a plain object menu entry, an object entry pinned to a named view, or a -custom page that embeds views. They are **not** interchangeable — this guide -gives you the decision rules. (The canonical, agent-facing version lives in -`skills/objectui/guides/app-composition.md`; keep the two in sync.) - -## The Three Ways to Reach a List - -Say your app has a `project` object: - -| You write | User lands on | You get for free | -|---|---|---| -| `{ "type": "object", "objectName": "project" }` | `/apps/my_app/project` — the object's **default view** | The full object shell: view switcher, object actions, a create button, record detail routing, search and recent-items integration | -| `{ "type": "object", "objectName": "project", "viewName": "project.by_status" }` | `/apps/my_app/project/view/project.by_status` | The same shell, with the entry **anchored** to a named view — users can still switch | -| `{ "type": "object", "objectName": "project", "filters": { "status": "open" } }` | `/apps/my_app/project/data?filter[status]=open` — the **bare data surface** | URL-defined conditions over everything permissions allow, bound to **no saved view**. Conditions show as removable chips; the full filter/sort/group toolbar is available; "Save as view" turns the slice into a named view | -| `{ "type": "page", "pageName": "project_overview" }` | `/apps/my_app/page/project_overview` | Nothing but your page schema. View switching, actions, and record links must be assembled by hand | - -The key asymmetry: a page can imitate the other two, but it **loses the object -shell** — and every future improvement to that shell (new actions, better view -switching, permission trimming) will skip your page. - -> **Creating a view at runtime ("Add View" / "Save as view").** Both entry -> points stage the new view as a per-item **draft** (ADR-0034) — invisible to -> other users until you Publish. Its identity is the canonical qualified name -> `.` (e.g. `project.by_status`), used as the metadata row key, -> the `body.name`, and the ViewTabBar tab id alike, so the draft → preview → -> publish loop resolves to a single row. After creating, the console navigates -> you to the new view in **draft-preview mode** (`?preview=draft`) so you can -> verify it and Publish from the DraftPreviewBar in one click. The create -> dialog asks for a display label **and** a machine key; the key auto-fills -> from the label for Latin text, and must be typed for non-Latin (CJK, …) -> labels rather than falling back to a random name. - -## The Rule of Least Power - -Use the least powerful construct that expresses the requirement: - -1. **Default: one plain `object` entry per core business object.** - No `viewName`. The default view is defined by view metadata, so the - navigation layer stays decoupled from presentation. - -2. **Add `viewName` when the menu item is a named slice.** - If the label is a *perspective* — "By Status", "Due This Week", - "My Tasks" — create a named view (convention: `.`, e.g. - `project.by_status`) and anchor the entry to it. `viewName` sets the - entry point; it does not lock the user in. - -3. **Use `filters` for one-off or parameterized slices.** - A dashboard drill-through, a shared link, "records assigned to me" — put - the condition in the URL (`filters: { "owner_id": "{current_user_id}" }`) - and let it land on the bare data surface instead of authoring a view. - Promote the slice to a named view only when it's curated and reused. - Note the surface is not a security feature: it shows exactly what - row-level permissions already allow. - -4. **Create a page only for composition a single object view cannot express.** - Multiple objects side by side or in tabs, KPI cards mixed with lists, - onboarding or static content, parameterized pages. A page that wraps a - single object's single view is an anti-pattern. - -5. **Use `dashboard` for metric/chart aggregation and `report` for tabular - analysis** — don't rebuild them as pages of chart blocks. - -6. **One entry per target.** Don't offer the same object through both a plain - entry and a page that wraps its default view. If one object needs several - menu entries, make all of them named-view entries — mixing styles breaks - active-state highlighting in the sidebar. - -As a rule of thumb, in a typical business app about 80% of navigation entries -should be `object` entries (with or without `viewName`); pages are for the -home screen, onboarding, and cross-object workbenches. - -## Write Spec-Shaped Items - -Navigation is a discriminated union on `type`. Each type has its own target -field — there is no generic `path` and no `kind`: - -```json -{ - "navigation": [ - { "id": "nav_projects", "type": "object", "objectName": "project", "label": "Projects" }, - { "id": "nav_by_status", "type": "object", "objectName": "project", "viewName": "project.by_status", "label": "By Status" }, - { "id": "nav_my_open", "type": "object", "objectName": "project", "filters": { "owner_id": "{current_user_id}", "status": "open" }, "label": "My Open Projects" }, - { "id": "nav_kpis", "type": "dashboard", "dashboardName": "company_kpis", "label": "KPIs" }, - { "id": "nav_workbench", "type": "page", "pageName": "cross_object_workbench", "label": "Workbench" }, - { "id": "nav_docs", "type": "url", "url": "https://docs.example.com", "target": "_blank", "label": "Docs" } - ] -} -``` - -Requirements: - -- `id` (snake_case) and `type` are mandatory on every item. `label` is - optional: an entry without one shows its target's **current** label, resolved - when the navigation renders — the view's label for an entry that names a - labelled view, else the object's or dashboard's label, else the target's - machine name. A renamed object, view or dashboard therefore shows its new - name on the next load without touching the navigation. Entries of other types - fall back to their target's name (`pageName`, `reportName`, the `url`) and a - group to its `id`, so give those a `label`. A `label` you do write renders - verbatim. Never write an empty `label` — omit the key instead. -- The target field must match the type: `objectName`, `pageName`, - `dashboardName`, `reportName`, or `url`. Keys like `path` or `kind` are - ignored at runtime and rejected at save. -- A `doc` entry opens the package documentation portal instead of a data - surface. It names a `book` (opens the book at its first readable page), a - `doc` (opens that page — the doc's name, i.e. its source filename stem such - as `crm_lead_guide`, never `crm_lead_guide.md` or a path), or both (that page - in that book). At least one of the two is required. Name the book by its - `name`, even when it declares a `slug`: the docs portal resolves the name and - redirects to the book's canonical `slug` URL. Who may read a book is the - book's `audience`, enforced by the server — - the entry itself carries only the usual `visible` / `requiredPermissions` - gates. You do not repeat the audience on the entry: a member who may not - read what it opens — a doc outside their audience, a book whose audience - excludes them or with no page they can read — does not see the entry. The - server leaves it out of the app it serves that member, and the console's - menu hides it too, from the member's own list of readable docs and books. -- Put items under the `navigation` key. `menu` is deprecated legacy and only - kept for backward compatibility. - -Object entries also support record deep-links — `recordId` (with template -variables like `{current_user_id}`) opens a specific record, which is how -"My Profile"-style entries are built. - -## Quick Checklist - -Before publishing an app, scan the navigation for: - -- Pages that merely wrap a single object view → replace with an object entry. -- Items carrying `path`/`kind` → rewrite as typed items. -- The same object reachable twice (plain entry + wrapper page) → keep one. -- View names not following `.`, ids not snake_case → rename. diff --git a/ui/content/docs/guide/expressions.md b/ui/content/docs/guide/expressions.md deleted file mode 100644 index c4e40a7e329..00000000000 --- a/ui/content/docs/guide/expressions.md +++ /dev/null @@ -1,760 +0,0 @@ ---- -title: "Expression System" ---- - -Object UI includes a powerful expression system that enables dynamic, data-driven UIs. Expressions allow you to reference data, compute values, and create conditional logic directly in your JSON schemas. - -## Overview - -Expressions are JavaScript-like code snippets embedded in schemas using the `${}` syntax: - -```json -{ - "type": "text", - "content": "Hello, ${user.name}!" -} -``` - -With this published as the expression scope: -```tsx -const scope = { user: { name: "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 - -### Simple Property Access - -Access data properties using dot notation: - -```json -{ - "type": "text", - "content": "${user.firstName}" -} -``` - -### Nested Properties - -Access nested objects: - -```json -{ - "type": "text", - "content": "${user.address.city}" -} -``` - -### Array Access - -Access array elements: - -```json -{ - "type": "text", - "content": "${users[0].name}" -} -``` - -### String Interpolation - -Mix expressions with static text: - -```json -{ - "type": "text", - "content": "Welcome, ${user.firstName} ${user.lastName}!" -} -``` - -### Localized Copy - -Copy written once per language is an inline locale map. Under a node's `properties`, each member -of the map is evaluated exactly like a plain string on the same key, against the same scope, and -the block then draws the member for the viewer's language: - -```json -{ - "type": "element:text", - "properties": { - "content": { - "en": "Welcome back, ${current_user.name}", - "zh-CN": "欢迎回来,${current_user.name}" - } - } -} -``` - -An object counts as a locale map when every key is a locale tag (`en`, `zh-CN`, or `default`) and -every value is a string. Any other object under `properties` reaches the block as written: a -`${…}` nested inside it is not evaluated. - -## Operators - -### Arithmetic Operators - -```json -{ - "type": "text", - "content": "Total: ${price * quantity}" -} -``` - -Supported: `+`, `-`, `*`, `/`, `%` - -### Comparison Operators - -```json -{ - "type": "text", - "content": "${score >= 90 ? 'Top grade' : 'Keep going'}" -} -``` - -Supported: `>`, `<`, `>=`, `<=`, `==`, `===`, `!=`, `!==` - -### Logical Operators - -```json -{ - "type": "button", - "visibleWhen": "${user.isAdmin && user.isActive}" -} -``` - -Supported: `&&`, `||`, `!` - -### Ternary Operator - -```json -{ - "type": "text", - "content": "${count > 0 ? count + ' items' : 'No items'}" -} -``` - -## Conditional Properties - -The node-level condition keys are `visibleWhen`, `hidden` and `disabled`. The -`visibleOn` / `hiddenOn` / `disabledOn` spellings are retired: `objectui validate` -refuses them by name and points at the surviving key (same predicate, same -polarity — rename the key, the value is unchanged). A form field's `fields[].visibleOn` -is a different, view-level slot and is unaffected. - -### visibleWhen - -Show component when expression is true: - -```json -{ - "type": "button", - "label": "Admin Panel", - "visibleWhen": "${user.role === 'admin'}" -} -``` - -### hidden - -Hide component when expression is true: - -```json -{ - "type": "section", - "hidden": "${user.settings.hideSection}" -} -``` - -### disabled - -Disable component when expression is true: - -```json -{ - "type": "button", - "label": "Submit", - "disabled": "${record.status === 'submitted'}" -} -``` - -## Data Context - -### Where the names come from - -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 -import { PredicateScopeProvider, SchemaRenderer } from '@object-ui/react' -import type { DeclaredNode } from '@object-ui/types' - -// The page schema — your own document. -declare const schema: DeclaredNode - -// Every name here becomes a root the schema's expressions can read. -const scope = { - user: { name: 'Alice' }, - settings: { theme: 'dark' }, -} - -function App() { - return ( - - - - ) -} -``` - -```json -{ - "type": "text", - "content": "Theme: ${settings.theme}" -} -``` - -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. - -### Lists - -`list` has no item template, and it provides no `item` or `index` scope for an expression to -read. It draws its `items` array — or the array its `bind` path resolves to — one row per entry: -a string, or an object whose `content` is a node. - -```json -{ - "type": "list", - "bind": "users" -} -``` - -## Built-in Functions - -### String Functions - -```json -{ - "type": "text", - "content": "${user.name.toUpperCase()}" -} -``` - -Available: -- `toUpperCase()`, `toLowerCase()` -- `trim()`, `trimStart()`, `trimEnd()` -- `substring(start, end)` -- `replace(search, replace)` -- `split(separator)` -- `includes(substring)` -- `startsWith(prefix)`, `endsWith(suffix)` - -### Array Functions - -```json -{ - "type": "text", - "content": "Total users: ${users.length}" -} -``` - -```json -{ - "type": "text", - "content": "${users.map(u => u.name).join(', ')}" -} -``` - -Available: -- `length` -- `map(fn)`, `filter(fn)`, `reduce(fn, initial)` -- `join(separator)` -- `slice(start, end)` -- `includes(item)` -- `find(fn)`, `findIndex(fn)` -- `some(fn)`, `every(fn)` - -### Number Functions - -```json -{ - "type": "text", - "content": "Price: ${price.toFixed(2)}" -} -``` - -Available: -- `toFixed(decimals)` -- `toPrecision(digits)` -- `toString()` - -### Math Functions - -```json -{ - "type": "text", - "content": "${Math.round(average)}" -} -``` - -Available: All standard `Math` functions -- `Math.round()`, `Math.floor()`, `Math.ceil()` -- `Math.min()`, `Math.max()` -- `Math.abs()` -- `Math.random()` - -### Date Functions - -```json -{ - "type": "text", - "content": "${new Date().toLocaleDateString()}" -} -``` - -## Complex Expressions - -### Nested Ternary - -```json -{ - "type": "text", - "content": "${status === 'active' ? 'Active' : status === 'pending' ? 'Pending review' : status === 'error' ? 'Failed' : 'Unknown'}" -} -``` - -### Combining Operators - -```json -{ - "type": "alert", - "visibleWhen": "${(user.role === 'admin' || user.role === 'moderator') && user.isActive && !user.isSuspended}" -} -``` - -### Array Methods - -```json -{ - "type": "text", - "content": "${users.filter(u => u.isActive).map(u => u.name).join(', ')}" -} -``` - -## Practical Examples - -### User Greeting - -```json -{ - "type": "text", - "content": "${new Date().getHours() < 12 ? 'Good morning' : new Date().getHours() < 18 ? 'Good afternoon' : 'Good evening'}, ${user.firstName}!" -} -``` - -### Status Badge - -`badge` has no row in the expression carriage map, so its `label` and `variant` -are read off the node exactly as written — a `${…}` in either reaches the screen -as those characters. Resolve both in the data you hand the renderer and author -the node with the resolved values; the condition keys are evaluated on every -type and stay expressions: - -```json -{ - "type": "badge", - "label": "Completed", - "variant": "secondary", - "visibleWhen": "${status !== 'draft'}" -} -``` - -Two neighbouring traps: `text` is not a `badge` key at all — the badge's text is -`label` — and `variant` is a closed set: `default`, `secondary`, `destructive`, -`outline`. - -### Price Formatting - -```json -{ - "type": "text", - "content": "$${(price * quantity).toFixed(2)}" -} -``` - -### Empty State - -```json -{ - "type": "empty", - "visibleWhen": "${items.length === 0}", - "title": "No items to display", - "description": "Start by adding your first item" -} -``` - -### Percentage Bar - -`progress` has no row in the expression carriage map, so its `value` and `label` -are read off the node exactly as written — a `${…}` in either reaches the screen -as those characters. Compute the percentage in the data you hand the renderer; -the condition keys are evaluated on every type and stay expressions: - -```json -{ - "type": "progress", - "value": 75, - "label": "75% complete", - "visibleWhen": "${total > 0}" -} -``` - -### Conditional Styling - -`className` has no row in the carriage map either — on `card` or on any other -type — so an expression written there lands in the rendered `class` attribute as -its own source text. Resolve the class list in the data you hand the renderer, -or author each variant and gate it with a condition key: - -```json -{ - "type": "card", - "title": "${task.name}", - "className": "border-red-500 border-2", - "visibleWhen": "${task.isPriority}" -} -``` - -## 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 - -A form's fields are its `fields` array, and each field's `visibleOn` is evaluated against that -row: - -```json -{ - "type": "form", - "fields": [ - { - "name": "country", - "type": "select", - "label": "Country", - "options": [ - { "label": "USA", "value": "USA" }, - { "label": "Canada", "value": "Canada" }, - { "label": "Mexico", "value": "Mexico" } - ] - }, - { - "name": "state", - "type": "select", - "label": "State/Province", - "visibleWhen": "${record.country === 'USA'}", - "options": [ - { "label": "CA", "value": "CA" }, - { "label": "NY", "value": "NY" }, - { "label": "TX", "value": "TX" } - ] - } - ] -} -``` - -### Validation - -An `input` declares no `validations` bag, and nothing reads one. `inputType: "email"` renders an -email input, which the browser validates, and `required` marks it required: - -```json -{ - "type": "input", - "name": "email", - "label": "Email", - "required": true, - "inputType": "email" -} -``` - -### Computed Fields - -`input` has no row in the expression carriage map either, so a computed total -cannot be carried by its `value`. Show it with a `text` node, whose `content` is -evaluated on every component type: - -```json -{ - "type": "text", - "content": "Total: ${record.price * record.quantity}" -} -``` - -## Performance Considerations - -### Expensive Computations - -Expressions are re-evaluated when data changes. Avoid expensive operations: - -❌ Bad: Complex computation in expression - -```json -{ - "type": "text", - "content": "${users.map(u => expensiveOperation(u)).join(', ')}" -} -``` - -✅ Good: Pre-compute, and publish the result - - - -```tsx -const scope = { - processedUsers: users.map(u => expensiveOperation(u)) -} -``` - -### Caching - -The expression engine automatically caches results when data doesn't change. - -## Security - -### Sandboxed Execution - -Expressions run in a sandboxed environment and can only access: -- The names the host published as scope, plus `record` and `page` -- The globals listed in `SAFE_GLOBALS` (`packages/core/src/evaluator/SafeExpressionParser.ts`): - `Math`, `JSON`, `parseInt`, `parseFloat`, `isNaN`, `isFinite` -- Methods on the values those names hold — string, number and array methods -- `new Date(...)` and `new RegExp(...)`, the only two constructors `new` accepts - -> **A global that is not on that list fails the way every unresolved name fails.** `Date`, -> `String` and `Array` are reachable only in the two forms above — `Date` as a constructor, -> `String` and `Array` as methods on a value you already hold. Written as globals they resolve -> to nothing: `${Date.now()}`, `${String(x)}` and `${Array.isArray(x)}` each raise inside the -> parser, and this surface fails soft, so the template is returned as its own source text with -> one line on the console — the characters you typed are what the reader sees. - -They **cannot** access: -- Browser APIs (window, document, localStorage) -- Node.js APIs (fs, path, etc.) -- Global variables -- Function constructors - -### Sanitization - -All expression outputs are automatically sanitized to prevent XSS attacks. - -## Debugging Expressions - -### Expression Errors - -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 -{ - "type": "text", - "content": "${user.invalidProperty}" -} -``` - -| 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 - -`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. It is orthogonal to the expression scope: -wrap this pair in a `PredicateScopeProvider` as well when you want both. - -## Advanced Usage - -### Custom Functions - -There is no global evaluator to extend: `SchemaRenderer` builds a fresh -`ExpressionEvaluator` for each evaluation, so a function has to reach it through -the evaluation context. Anything callable you put in the context is callable in -an expression, under exactly the name you gave it: - -```tsx -import { evaluateExpression } from '@object-ui/core' - -const formatCurrency = (value: number) => - new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(value) - -// => '$1,234.50' -evaluateExpression('${formatCurrency(price)}', { formatCurrency, price: 1234.5 }) -``` - -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`: - -```tsx -import { ExpressionEvaluator } from '@object-ui/core' - -const evaluator = new ExpressionEvaluator({ user: { role: 'admin' } }) - -evaluator.evaluate('${user.role === "admin"}') -``` - -### Custom Operators - -Expressions are JavaScript, evaluated against the context — operators are the -language's own. A membership test is written with the array method: - -```json -{ - "type": "button", - "visibleWhen": "${user.permissions.includes('admin')}" -} -``` - -## Best Practices - -### 1. Keep Expressions Simple - -❌ Bad: Too complex - -```json -{ - "content": "${users.filter(u => u.age > 18).map(u => ({...u, isAdult: true})).reduce((acc, u) => acc + u.score, 0)}" -} -``` - -✅ Good: Pre-compute complex logic - -```json -{ - "content": "${adultUsersScore}" -} -``` - -### 2. Use Meaningful Variable Names - -❌ Bad - -```json -{ - "visibleWhen": "${x && y || z}" -} -``` - -✅ Good - -```json -{ - "visibleWhen": "${isAdmin && isActive || isSuperUser}" -} -``` - -### 3. Handle Null/Undefined - -❌ Bad: Might throw error - -```json -{ - "content": "${user.address.city}" -} -``` - -✅ Good: Safe access - -```json -{ - "content": "${user.address?.city || 'N/A'}" -} -``` - -### 4. Use TypeScript - -Define the type of the scope you publish: - - - -```tsx -interface AppScope { - user: { - name: string - role: 'admin' | 'user' - isActive: boolean - } -} - -const scope: AppScope = { /* ... */ } - - - -``` - -## Next Steps - -- [Schema Rendering](./schema-rendering.md) - Learn the rendering engine -- [Component Registry](./component-registry.md) - Understand components -- [Schema Overview](/docs/guide/schema-overview) - Explore schema specifications - -## Related Documentation - -- [`@object-ui/core` README](https://github.com/objectstack-ai/objectui/tree/main/packages/core) - Expression evaluator API -- [Form Plugin](/docs/plugins/plugin-form) - Form-specific expressions -- [View Plugin](/docs/plugins/plugin-view) - Data view expressions diff --git a/ui/content/docs/guide/fields.md b/ui/content/docs/guide/fields.md deleted file mode 100644 index fec1b2d290f..00000000000 --- a/ui/content/docs/guide/fields.md +++ /dev/null @@ -1,237 +0,0 @@ ---- -title: "Field Registry" ---- - -Object UI uses a **Field Registry** system to decouple the core engine from specific UI implementations of fields. This allows for rich extensibility and plugin support. - -## Concept - -The `@object-ui/fields` package serves as the "Universal Language" for rendering values. - -When a component like `` needs to render a `date` field, it doesn't import a DatePicker directly. Instead, it asks the registry: - -> *"Hey, give me the component responsible for rendering type 'date'."* - -This architecture allows you to: -1. **Override standard fields** (e.g. replace the native date picker with a fancy one). -2. **Add new field types** (e.g. add a `rating` or `signature` field). -3. **Keep bundles small** (heavy components like Code Editors are loaded only if their plugin is registered). - -## Usage - -### 1. Registering a Custom Field - -You can register a custom renderer globally, typically at your app's entry point. - -```tsx -// src/setup.tsx -import { registerFieldRenderer, type CellRendererProps } from '@object-ui/fields'; - -const MyRatingField = ({ value, onChange }: CellRendererProps) => { - return ( -
- {[1, 2, 3, 4, 5].map(star => ( - onChange?.(star)} - style={{ color: star <= value ? 'gold' : 'grey' }} - > - ★ - - ))} -
- ); -}; - -// Register it -registerFieldRenderer('rating', MyRatingField); -``` - -### 2. Using in Schema - -Once registered, you can simply use the new type in your JSON schema. - -```json -{ - "type": "form", - "fields": [ - { - "name": "customer_satisfaction", - "type": "rating", - "label": "Satisfaction" - } - ] -} -``` - -## Standard Fields - -Object UI comes with built-in support for the standard [ObjectStack Protocol](https://github.com/objectstack-ai/objectstack/tree/main/packages/spec) types: - -| Type | Description | -|---|---| -| `text` | Single line text | -| `textarea` | Multi-line text | -| `number` | Numeric input | -| `currency` | Currency formatting | -| `percent` | Percentage values | -| `date` | Date picker | -| `datetime` | Date & Time picker | -| `boolean` | Checkbox / Switch | -| `select` | Dropdown | -| `lookup` | Reference to another object | -| `master_detail` | Parent-child relationship | -| `user` | Person picker — searches the `sys_user` object (a lookup specialized to users) | -| `owner` | Record owner — a `user` field, typically read-only and stamped with the current user | - -## What a number field silently rewrites - -`number`, `currency`, `percent` and `geolocation` render a native -`type="number"` input. The browser — not ObjectUI — decides what that box will -accept, and it rewrites some entries **before any widget code runs**. Two -different things can happen, and only one of them is announced. - -### Announced: text the browser cannot read - -If the box is left holding something that is not a complete number, the browser -reports `validity.badInput` and these widgets now say so: the control is marked -`aria-invalid="true"` and a message is drawn under it — - -> Not saved: the text in this box is not a number. Enter a plain decimal (example: 1234.56). - -The sentence follows the reader's language (objectui#8148) — it resolves through -the `fields.number.badInput` locale key, so a console running in Chinese, -Japanese or Arabic refuses in that language. The example numeral is the -widget's own (`1234` for `number`, `1234.56` for `currency`, `12.5` for -`percent`, `30.2741` / `120.1551` for the two `geolocation` boxes) and stays -verbatim in every language. - -Measured in Chromium 141, typing any of `1e`, `1e-`, `1e+`, `5e`, `-`, `.`, -`+`, `-.` or `e` leaves the box **visibly displaying** what was typed while its -value reads empty. Before this was announced, the field simply stored nothing -and said nothing. - -### ⚠️ NOT announced: entries the browser silently truncates - -This is the important limitation, and it is deliberate rather than an oversight. - -| you paste / type | the field stores | -|---|---| -| `1.2.3` | `1.23` | -| `0x10` | `10` | -| `12abc` | `12` | - -**No warning is shown for these, and no widget-side check can add one.** The -browser filters the keystrokes or the pasted text as it arrives, so by the time -ObjectUI sees the field the discarded characters are already gone — there is -nothing left to detect. This is native `type="number"` behaviour; recovering it -would mean giving up the numeric keyboard on mobile and the `min`/`max`/`step` -spinner on every numeric field in the product. - -⛔ **So do not read "no warning" as "the value is correct."** A warning means the -browser could not read the box at all. Silence means the browser read -*something* — which may be less than you typed. When exact input matters -(reference codes, serial numbers, anything where `1.2.3` is meaningful), declare -a `text` field, not a numeric one. - -## Polymorphic pointer pairs (`referenceVia`) - -A `text` field that declares `referenceVia` holds a record id of the object its -sibling column names, row by row: `sys_approval_request.record_id` points at the -record `object_name` names, and `sys_activity.source_id` at the one -`source_object` names. The record details grid resolves the pair from the row -with `resolveRecordPointer(field, row)` and draws it with the renderer -registered under `RECORD_POINTER_CARD_TYPE`. The fields package's default draws -the record id as text; the console registers a record preview card there, so a -pointer field on a record page shows the record it points at. When the card -could read that record, its title links to the record's page in the current -app, built by the same record-link builder the page's lookup values use; a -record the reader cannot read is drawn with no link, and the card never says -why. Nothing is authored for this: the `referenceVia` declaration on the field -is the whole contract. List cells are not a call site yet. - -When the platform deletes the record a pointer names, it can say so on the row. -A pointer field that also declares `referenceDeletedWhen: { field, value }` -names a sibling `select` field and the option the platform writes there, as -`sys_approval_request.record_id` declares -`{ field: 'cancel_reason', value: 'record_deleted' }`. On a row holding that -option the details grid draws a tombstone in the option's own label (localized -when the console's bundle localizes it), reads nothing and links nothing. A -record the card cannot read on any other row still gets the cause-free text: -only the declaration means deleted. - -## Naming a registry item by name (`position-ref`, `permission-ref`) - -A `text` field that stores the **machine name** of one item of a metadata -registry — a position, a permission set — declares the matching widget, and the -record form lists that registry instead of asking for a typed name: - -```json -{ - "position": { - "type": "text", - "label": "Position", - "required": true, - "maxLength": 100, - "widget": "position-ref" - } -} -``` - -| Widget | Lists | Stores | -|---|---|---| -| `position-ref` | the `position` registry (`GET /api/v1/meta/position`) | the position's `name` | -| `permission-ref` | the `permission` registry (`GET /api/v1/meta/permission`) | the permission set's `name` | - -Like every `widget` value, the name is looked up as `field:`. Each item -reads as its label beside its name, and the value written is the name alone: no -label, no id. A stored name the registry does not list stays on screen, flagged -*not found*, so a renamed or removed item is visible rather than dropped. -`position-ref` leaves out the audience anchors, the spec's -`AUDIENCE_ANCHOR_POSITIONS` (`everyone`, `guest`), because the server refuses an -anchor as a grant target; a stored anchor still shows as its own option. The -list comes from the data source's `listMetadataItems(type)`. When the adapter -has none, the list is empty, or the load fails, the field is a text box that -takes the typed name; a failure is shown with its cause, and the box stays -editable. - -## A widget's face on the record heading - -A `text` field that declares `widget: 'recipient-picker'` or `widget: 'object-ref'` is drawn -in a list column through the read cell registered under that widget's key, which names what -the stored value references. When that field is also the object's `nameField`, the record -page heading draws the value through the same cell: a record share heads its page with the -recipient's name, not the user id it stores. Nothing is authored for this; the `widget` -declaration on the field is the whole contract. - -The heading lives in `@object-ui/components`, which this package depends on and so cannot be -imported from here the other way round. `@object-ui/components` therefore owns a slot, -`setFieldReadFaceResolver` / `resolveFieldReadFace`, and this package fills it when it loads with the -lookup the list cells make. The widget's key draws the stored text until a host registers a -face under it (the console registers its own), and while it does, the heading prints the -stored value as before. So does a host that does not load `@object-ui/fields`. - -## Using Renderers in Custom Components - -If you are building your own custom component (like a Kanban board card), you can leverage the registry to render fields without reinventing the wheel. - -```tsx -import { getCellRenderer } from '@object-ui/fields'; - -export const KanbanCard = ({ task }: { task: { name: string; assignee: string } }) => { - // Get the standard renderer for a 'user' type field - const UserRenderer = getCellRenderer('user'); - - return ( -
-

{task.name}

-
- -
-
- ); -}; -``` diff --git a/ui/content/docs/guide/flow-designer.md b/ui/content/docs/guide/flow-designer.md deleted file mode 100644 index 4e8618016f6..00000000000 --- a/ui/content/docs/guide/flow-designer.md +++ /dev/null @@ -1,177 +0,0 @@ ---- -title: "Flow Designer" -description: "Authoring automation flows on the visual canvas — nodes, edges, the searchable add-node palette, the node inspector, validation, and simulation." ---- - -# Flow Designer - -The **Flow Designer** is the visual editor for `flow` metadata in the console's -metadata admin. It renders a flow's nodes and edges on a pan/zoom canvas so you -can assemble automation — create/update records, branch on conditions, wait for -events, call APIs, route for approval — without hand-editing JSON. The designer -is a thin, **spec-driven** view over `flow` metadata: everything you drop on the -canvas is a node in the flow's schema, so the same document runs unchanged on -the automation engine. - -Open it from a `flow` record's **Design** tab. The **Form** tab holds the same -flow as an editable tree; the two stay in sync. - -## The canvas - -Each node is a card showing its icon, label, `type`, and a one-line summary of -its config. Edges are the arrows between them. - -| Gesture | Result | -|---|---| -| Click a card | Select it (opens the node inspector) | -| Drag a card | Reposition it | -| Hover a card → click the bottom **+** | Append a connected child node | -| Drag a card's connect handle (the dot beside its bottom **+**) onto another node | Connect the two nodes. Refused, with the reason shown, for the node itself, a (source, target) pair another connection already joins, or a node the flow does not have | -| Zoom controls / **Fit** | Scale and re-center the graph | - -A selected connection's **From** and **To**, in its inspector, re-point it to -other nodes of the flow, under the same refusals. - -Structural problems (an undeclared cycle, an unreachable node) surface three -ways at once: a red ring on the offending card, a badge in its corner, and an -inline banner at the top-left of the canvas. Clicking a banner row selects and -pans to the element it refers to. - -## Adding nodes — the palette - -The **Add node** button (top-right, edit mode only) opens the node palette: a -searchable, grouped list of every node type the flow can use. - -### Search and keyboard - -Type in the box at the top to filter across **all** categories at once — the -match is a case-insensitive substring test over each node's **label**, **hint**, -and **type**, so `scr` finds *Screen*, `http` finds *HTTP request*, and a word -that only appears in a hint (`concurrently` → *Parallel*) still surfaces the -node. Clearing the box restores the full grouped list. - -The palette is keyboard-navigable end to end: - -| Key | Action | -|---|---| -| `↑` / `↓` | Move the highlight (wraps around) | -| `Enter` | Insert the highlighted node | -| `Esc` | Close the palette | - -The search box autofocuses when the palette opens, so you can open, type a few -letters, and press `Enter` without touching the mouse. - -### Categories - -Nodes are grouped into five sections, in this order: - -| Category | Node types | -|---|---| -| **Data** | Create / Update / Get / Delete record | -| **Logic** | Decision, Loop, Set variables, Parallel, Try / Catch | -| **Human** | Approval, Screen | -| **Integration** | HTTP request, Connector, Script | -| **Flow** | Subflow, Wait, End | - -Section headings are localized to the active console language (e.g. *数据 / -逻辑 / 人工 / 集成 / 流程* in Chinese); the underlying node types are unchanged. - -### Recently used - -When the search box is empty, a **Recently used** group tops the list with the -node types you inserted most recently (up to five, most-recent first) — so the -nodes you reach for repeatedly stop needing a scroll or a search. The list is -per-user and, when the console is connected to a backend, syncs across devices -(see [User-Scoped State Persistence](/docs/guide/user-state-persistence)); it -falls back to browser-local storage when offline. Types from a plugin that was -since uninstalled drop out of the list automatically. - -### Server-merged, plugin-extensible - -The palette is **server-driven**. Beyond the built-in node types, the running -engine publishes its registered actions at `GET /api/v1/automation/actions`, and -plugins contribute their own node types there (for example an `approval` node -from an approvals plugin, or third-party `connector_action` providers). The -designer overlays those descriptors onto the built-in list — adopting the -engine's labels and descriptions and appending engine-only types — so the -palette always matches what the connected backend actually supports. Plugin -nodes are searchable exactly like built-ins, including by their registered -`type`. When the endpoint is unreachable the designer falls back to its -built-in defaults, so authoring still works offline. - -## The node inspector - -Selecting a node opens its inspector: **ID**, **Label**, **Node Type**, and a -**Configuration** section. New nodes start with defaults where the spec allows -one: a *Wait* node already carries a timer config, and an *HTTP* node defaults to -`GET`. What the author must still supply is marked with a red `*`, the same -required marker the other metadata forms use. That covers an *HTTP* node's URL, a -record node's object, a decision branch's label and expression, and a screen -field's name. The marker follows the installed spec, so a key required only in -some configurations (a *Notify* node's title while it has no template) is marked -only while it applies. Until the value is supplied, the spec's error is shown on -that node and listed in the **Problems** panel. - -For node types whose engine executor publishes a `configSchema` (ADR-0018), the -inspector renders a **server-driven property form** from that schema — so a -plugin's node gets a real config UI without the designer hardcoding its fields. -The mapping covers scalars, enums (→ select), typed references (→ picker), -free-form maps (→ key/value), and array/object shapes: an array of objects -becomes a **repeater**, and a repeater column that is itself an array (of -strings, numbers, or objects) becomes a **nested repeater** — so an -engine-published nested-array config is editable inline rather than dropping to -the Advanced JSON block. - -An *Approval* node's approver **Value** cell takes its candidates from where the -schema's published binding for the row's **Type** says they live (`xRef.sources`): -a directory kind (`user`, `team`, `department`) is a lookup over its directory -object's records, and a kind bound to the metadata registry -(`{ source: 'registry', type }`, which is how `position` is bound on a server -carrying objectstack#22710) is a select over that type's declared items that -stores the item's name. Both offer a pencil toggle for typing a value by hand. - -A **Decision** node's Branches editor defines each branch's label, CEL -expression, **and target node** in one table: the **Target** column picks the -downstream node, wiring (creating, retargeting, or detaching) the branch's -outgoing edge with its condition, label, and default flag. The same binding can -also be edited from the edge side — select a connector and use its **Branch** -picker — and the two stay in sync, because the routing always lives on the -edges. - -An **Assignment** node's Assignments editor sets one variable per row. Each -value is either text or a CEL expression, chosen with the row's *Write as a CEL -expression* toggle. The node's `config` then reads: - -```json -{ - "assignments": { - "label": "{record.name}", - "digest": { "dialect": "cel", "source": "joinNonEmpty(names, \", \")" } - } -} -``` - -Text (`label`) is `{token}` interpolation and is stored exactly as typed. An -expression (`digest`) is stored as the `{ dialect: 'cel', source }` envelope and -evaluated by the expression engine, so the CEL stdlib is available. A malformed -envelope shows the spec's refusal under the cell. The toggle appears only on maps -whose values the spec declares may be an expression; other key/value editors -(field values, headers, inputs) have none. - -## Validate, simulate, inspect runs - -The toolbar toggles four side panels: - -| Panel | What it shows | -|---|---| -| **Variables** | The flow's declared variables | -| **Problems** | Structural + server validation issues, each clickable to reveal on canvas | -| **Debug** | A step-through **simulator** that walks the graph and highlights the active / visited nodes | -| **Runs** | Execution history for the published flow, fetched from the engine | - -## See also - -- [User-Scoped State Persistence](/docs/guide/user-state-persistence) — how the - "Recently used" list is stored and synced. -- [Console App](/docs/guide/console) — the reference app that hosts the metadata - admin and its designers. diff --git a/ui/content/docs/guide/index.md b/ui/content/docs/guide/index.md deleted file mode 100644 index c609e9fb2f0..00000000000 --- a/ui/content/docs/guide/index.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -title: "Guide" -description: "Complete guide to using ObjectUI - from getting started to advanced concepts" ---- - -# ObjectUI Guide - -Welcome to the ObjectUI Guide! This comprehensive guide covers everything you need to know to build powerful server-driven UIs with ObjectUI. diff --git a/ui/content/docs/guide/layout.md b/ui/content/docs/guide/layout.md deleted file mode 100644 index 29f3ea22112..00000000000 --- a/ui/content/docs/guide/layout.md +++ /dev/null @@ -1,926 +0,0 @@ ---- -title: "Layout System" -description: "Understanding ObjectUI's layout components for building application shells and page structures" ---- - -# Layout System - -ObjectUI provides a comprehensive layout system through the `@object-ui/layout` package. This guide explains how to use layout components to build professional application structures. - -## Overview - -The layout system provides: - -- **AppShell** - Full application container with a top navbar, sidebar, and content areas -- **Page** - Individual page wrapper with header and body -- **PageHeader** - Consistent page headers with title and actions -- **SidebarNav** - Navigation sidebar with menu items - -## Installation - -The layout package is included in the core ObjectUI installation: - -```bash -npm install @object-ui/react -``` - -Layout components are automatically registered when you import ObjectUI. - -## AppShell Component - -The `AppShell` provides a complete application structure with a top navbar, a sidebar, and -a main content area. - -**`AppShell` is a React component, not an authorable JSON node.** Four of its seven props -are `React.ReactNode` slots — `sidebar`, `navbar`, `children` and `rightRail` — and a JSON -document has no way to put a node into any of them. Compose the shell in React, and render -your JSON pages *inside* it. `app-shell` is not a component key either — what a -`{ "type": "app-shell" }` node does now is measured under -[There is no `app-shell` node](#there-is-no-app-shell-node) below. - -### Basic Usage - - -```tsx -import { AppShell, SidebarNav, type NavItem } from '@object-ui/layout'; -import { SchemaRenderer } from '@object-ui/react'; -import { LayoutDashboard, Settings, Users } from 'lucide-react'; - -const navItems: NavItem[] = [ - { title: 'Dashboard', href: '/dashboard', icon: LayoutDashboard }, - { title: 'Users', href: '/users', icon: Users }, - { title: 'Settings', href: '/settings', icon: Settings }, -]; - -My Application} - sidebar={} -> - - -``` - -Top-bar content goes in `navbar`. `AppShell` renders the sticky `
` element itself -and `{navbar}` is the only thing that fills it — there is no `header` prop. Main content is -the component's `children`; there is no `body` prop either. - -### Props - -`AppShellProps` (`packages/layout/src/AppShell.tsx`) declares exactly these seven. The -component destructures that fixed key list with **no rest element**, so anything else you -pass is built and then dropped on the floor. - -These are React props, passed in JSX. None of them is authorable in JSON — there is no -`app-shell` node to write them on. - -| Prop | Type | Required | Description | -| --- | --- | --- | --- | -| `sidebar` | `React.ReactNode` | no | Left sidebar node, a flex sibling of the content. Pass `SidebarNav`, or your own node. | -| `navbar` | `React.ReactNode` | no | Top-bar content. `AppShell` supplies the sticky `
` around it, so pass only what goes inside. | -| `children` | `React.ReactNode` | yes | Main content, rendered inside the content element, within the shell's one `
` landmark. | -| `className` | `string` | no | Tailwind overrides for the content element — **not** for the outer container. | -| `defaultOpen` | `boolean` | no | Initial open state of the underlying Shadcn `SidebarProvider`. Defaults to `true`. | -| `branding` | `AppShellBranding` | no | App branding, applied by `useAppShellBranding`: `primaryColor` / `accentColor` become CSS custom properties on the document root, `favicon` sets the icon link's `href`, and `title` sets `document.title`. | -| `rightRail` | `React.ReactNode` | no | Optional right-side rail. It reflows the content beside it rather than overlaying it; absent → unchanged single-pane layout. | - -### There is no `app-shell` node - -`app-shell` is not a component key. `registerLayout()` (`packages/layout/src/index.ts`) -does not register it, and nothing else in this repo does either, so a -`{ "type": "app-shell" }` node resolves to nothing and says so. Measured on this tree: - -- `SchemaRenderer` replaces the node with its error panel — - `Unknown component type: app-shell`, error code `OBJUI-001`. -- `sdui-parser` reports it before render, as an `error`-severity diagnostic with code - `unknown-component` and the message ` is not a known component`. - -It **was** registered until objectui#4841, and the registration could never produce a -shell. Four of the seven props are `React.ReactNode` slots that a JSON document cannot -fill, so a node had exactly two outcomes: `children` was stripped by `SchemaRenderer` -before a node's keys were spread as props — `AppShell` reads its `children` prop, never -`schema.children` — so the `
` element rendered **empty with nothing logged**; and a -schema written into `sidebar` / `navbar` / `rightRail` arrived as a plain object, which -React refuses to render, replacing the node with an error box. Only `className`, -`defaultOpen` and `branding` ever survived the JSON path, i.e. the best result JSON could -reach was a shell with no navigation, no top bar and an empty content area. The key was -retired under ADR-0049 (enforce-or-remove) so that this comes out as a named refusal -rather than a page that renders nothing. - -Two doors remain, one per capability: - -- **Compose in React** — `` as shown above, with your JSON pages rendered - *inside* it through `SchemaRenderer`. -- **The whole shell from metadata** — the `app-schema-renderer` node, which renders - `AppSchemaRenderer`. It builds the branding and the sidebar navigation from the app - document it carries under `schema`, below. It draws no page content: the node's - `children` are not rendered, and `objectui validate` refuses them on this node. Page - content reaches the shell only as React `children`, when a host renders - `AppSchemaRenderer` in JSX. - -The node takes three inputs: - -- `schema`: the app document, nested, with its own `"type": "app"`. It is validated as - the app document, so the document's own refusals apply inside it: `mobileNavMode` - there is refused, because the mode belongs on the node. -- `basePath`: the prefix of every href the shell generates. -- `mobileNavMode`: `"drawer"` (the default) or `"bottom_nav"`, which adds a fixed - bottom bar. - -```json -{ - "type": "app-schema-renderer", - "basePath": "/apps/crm", - "mobileNavMode": "bottom_nav", - "schema": { - "type": "app", - "name": "crm", - "title": "CRM", - "navigation": [ - { "id": "home", "type": "page", "pageName": "home", "label": "Home" } - ] - } -} -``` - -The document has one spelling. Its keys written flat on the node (`"navigation"`, -`"title"` beside `"type": "app-schema-renderer"`) are not drawn, and the strict authoring -face (`StrictAnyComponentSchema` in `@object-ui/types/zod`) refuses them as unrecognized -keys. A node without `schema` draws the shell with no branding and no navigation. - -### Features - -- Responsive layout that adapts to mobile/tablet/desktop -- Collapsible sidebar with state management -- Sticky header -- Scroll management for content area -- Consistent spacing and structure - -## Page Component - -The `Page` component provides a consistent wrapper for individual pages with optional headers. - -### Basic Usage - -```json -{ - "type": "page", - "pageType": "app", - "title": "User Management", - "description": "Manage users and permissions", - "children": { - "type": "container", - "children": [ - { "type": "text", "content": "User list goes here" } - ] - } -} -``` - -`pageType` decides whether the page draws its own `title` and `description`. `app`, `list`, -`utility` and `home` draw both, unless a titled `page:header` block in the page takes over -the heading. A page with no `pageType` is a `record` page, which draws neither: its heading -is a `page:header` block, as [Detail Page with Actions](#detail-page-with-actions) shows. -So without `"pageType": "app"`, the page above renders only its children. -[Content Width](#content-width) covers what else each type changes. - -### With Action Buttons - -A `page` node has no action row of its own. Buttons are NODES, and they go in `children`. -This is a list of products, so the page is a `list` page, which draws its `title` itself: - -```json -{ - "type": "page", - "pageType": "list", - "title": "Products", - "children": [ - { - "type": "flex", - "properties": { - "justify": "end", - "gap": 2, - "children": [ - { - "type": "button", - "label": "Add Product", - "variant": "default", - "icon": "plus" - }, - { - "type": "button", - "label": "Export", - "variant": "outline", - "icon": "download" - } - ] - } - }, - { - "type": "object-grid", - "properties": { "objectName": "products" } - } - ] -} -``` - -`label` is the button's text key — `text` is not a `ButtonSchema` key. `objectui validate` -refuses it by name, but nothing on the render path does: `BaseSchema` is `.passthrough()`, -so the rendering face keeps the unknown key and `button.tsx`, which reads `schema.label`, -renders a button with no text. - -> **⛔ `actions` on a `page` node is refused by name** (objectui#7926). This page used to -> teach `"actions": [ … ]` as a sibling of `title`, and it drew **nothing**: `PageRenderer` -> has never had a read point for the key, and `BaseSchema`'s `.passthrough()` kept the array -> rather than refusing it — so the author got a green validation and an empty page (before -> objectui#7933 it also reached the DOM as `actions="[object Object]"`). `PageNodeSchema` -> now declares the key as a refusal, so the same document fails with the remedy in the -> message instead of rendering silently short. Buttons in `children`, as above; on a record page, -> the `page:header` block's own `actions` — which are **action ids**, not nodes -> (see the [PageHeader reference](/docs/layout/page-header)). - -> **⛔ `breadcrumbs` on a `page` node is refused by name** (objectui#8871). This page used -> to declare it in the Schema API block below and author it in two passages, and it drew -> **nothing**: no renderer has ever read the key, and `BaseSchema`'s `.passthrough()` kept -> the array rather than refusing it — the same silent-accept shape as `actions`, retired -> under the same ADR-0049 enforce-or-remove gate. The trail is a **node**, not a key: put -> a `breadcrumb` node in `children`, as [Breadcrumbs for Deep Navigation](#2-breadcrumbs-for-deep-navigation) -> shows. ⛔ Not the `page:header` block's `breadcrumb` either — that one is a singular -> **boolean** the renderer ignores, not a list of links. - -### Schema API - - -```typescript -{ - type: 'page', - - // Header - title?: string, // Page heading; not drawn on a 'record' page, whose heading is a `page:header` block - description?: string, // Line under the heading; a 'record' page does not draw it - icon?: string, // Optional icon - // NO `actions` — refused by name (objectui#7926); put the buttons in `children` - // NO `breadcrumbs` — refused by name (objectui#8871); put a `breadcrumb` node in `children` - - // Content - children: SchemaNode, // Main page content - - // Layout - pageType?: 'record' | 'home' | 'app' | 'utility' | 'list', // default 'record'; sets the max width and who draws the heading - // NO `maxWidth` — refused by name (objectui#11318); use `pageType`, or a `container` in `children` - // NO `padding` — refused by name (objectui#11318); put a `container` with `padding` in `children` - - // Styling - className?: string, -} -``` - -### Content Width - -A `page` node has no `maxWidth` key. **`pageType`** sets how wide the page's content may -grow: `utility` is the narrowest and suits settings and forms, `record` (the default), -`list` and `app` share a wider cap, and `home` is the widest. `getPageMaxWidth` in -`packages/components/src/renderers/layout/page.tsx` holds the mapping. - -```json -{ - "type": "page", - "pageType": "utility", - "title": "Settings", - "children": { - "type": "form", - "fields": [] - } -} -``` - -A page laid out in `regions` can drop the cap altogether: a `main` region declared -`width: 'full'` makes the page full-bleed. That is the only `width` a `main` region takes. -The template decides how wide `main` is (the space `sidebar` and `aside` leave, or one grid -cell on `dashboard`), so `'small'`, `'medium'` and `'large'` on `main` are refused at that -region's `width`, as `@objectstack/spec` refuses them. Other regions keep all four values. - -`pageType` is more than a width. A `record` page leaves its heading to a `page:header` -block, so a `title` on a page with no `pageType` draws no heading. That is one more reason -the settings page above says `utility`. - -For a column narrower than the page's cap, wrap the content in a `container` and set -**its** `maxWidth`. That key is a `container` member, and the -[Container reference](/docs/components/layout/container) lists its values: - -```json -{ - "type": "page", - "pageType": "utility", - "title": "Profile", - "children": { - "type": "container", - "maxWidth": "2xl", - "children": { - "type": "form", - "fields": [] - } - } -} -``` - -> **⛔ `maxWidth` on a `page` node is refused by name** (objectui#11318). This page used to -> teach `"maxWidth": "lg"` beside `title`, with a list of sizes, and it changed **nothing**: -> `PageRenderer` has no read for the key and takes its width from `pageType`, while -> `BaseSchema`'s `.passthrough()` kept the value rather than refusing it. `PageNodeSchema` -> now declares the key as a refusal whose message names `pageType` and the `container`. - -## Page Header (`page:header`) - -`page:header` is a page's title bar: a title, an optional subtitle and, on a record page, the -record chip and the record's header actions. Its props contract is `PageHeaderProps`, the -`ComponentPropsMap['page:header']` row, and it is rendered by the record-aware -`PageHeaderRenderer` in `@object-ui/components`. The -[Page Header reference](/docs/layout/page-header) documents every key, both layouts and the -styling. - -### Usage - -```json -{ - "type": "page:header", - "properties": { - "title": "{first_name} {last_name}", - "subtitle": "View and edit customer information", - "actions": ["edit_customer", "send_email"] - } -} -``` - -The props go in `properties`, which is where `PageHeaderProps` judges them: a misspelled or -removed key there is refused by name. `className` is the one key that belongs on the node -itself, beside `type`. - -`title` and `subtitle` both interpolate `{field.path}` tokens against the surrounding -record context, so the title above resolves on a record page. Unresolvable tokens collapse to -an empty string rather than leaking the raw template. - -`actions` holds action **ids**, resolved from the object's own `actions` metadata, which -keeps the definitions in one place. They are **not** `SchemaNode` nodes: the contract refuses -an entry that is not a string. Nor does the header draw `children`, so a button written there -does not render. - -### Schema API - - -```typescript -{ - type: 'page:header', - className?: string, // on the node, beside `type` - properties?: { - title?: string | Record, // {field.path} tokens interpolated; derived from the record when omitted on a record page - subtitle?: string | Record, // secondary line; same interpolation - actions?: string[], // action ids, resolved against the object's own actions - recordChrome?: boolean, // default true — the record chip on a record page - showCopyId?: boolean, // default true - maxVisible?: number, // positive integer; inline action buttons (renderer default 3) - mobileMaxVisible?: number, // positive integer; the same on mobile (renderer default 1) - aria?: { ariaLabel?, ariaDescribedBy?, role? }, - }, -} -``` - -The favorites star beside the record title draws where the host wires a favorites toggle, and metadata does not control it. - -> **Write `subtitle`; there is no `description` and no `icon`.** The contract refuses -> `description` on this node and names `subtitle` in its place. It refuses `icon` by name as -> an ADR-0087 D2 tombstone — "`page:header` property `icon` was removed in -> @objectstack/spec 17.0.0 (ADR-0087 D2) — no renderer ever read it … Delete the key." The -> header's identity comes from the record chip (`recordChrome`), and each action carries its -> own `icon`. - -> **There is no `breadcrumbs` array, and `breadcrumb` draws nothing.** The contract still -> accepts a singular **boolean** `breadcrumb` until objectstack#20758 retires it; the renderer -> ignores it. A trail of links is a `breadcrumb` node — see -> [Breadcrumbs for Deep Navigation](#2-breadcrumbs-for-deep-navigation). - -> **`page-header` is retired; author `page:header`.** `page-header` (and its namespaced form -> `layout:page-header`) is no longer registered (objectui#10859): a node written that way -> renders the "Unknown component type" panel, and `objectui validate` refuses it at `type`. -> Write `{ "type": "page:header", "properties": { "title": "…", "subtitle": "…" } }`; the -> secondary line is `subtitle`. - -## SidebarNav Component - -The `SidebarNav` provides a collapsible navigation sidebar with menu items. - -**`SidebarNav` is a React component, and `sidebar-nav` is not a component key at all.** -`registerLayout()` (`packages/layout/src/index.ts`) registers two keys — `page:card` and -`app-schema-renderer` — and nothing in this repo registers `sidebar-nav`. What a -`{ "type": "sidebar-nav" }` node actually does is measured under -[There is no `sidebar-nav` node](#there-is-no-sidebar-nav-node) below. Compose the nav in -React, or declare it as the app's `navigation` metadata when the tree has to come from JSON. - -### Basic Usage - -`SidebarNav` renders a Shadcn `Sidebar`, so it must be inside a `SidebarProvider` — -`AppShell` supplies one. Its rows are `NavLink`s, so it also needs a router above it. - - -```tsx -import { AppShell, SidebarNav, type NavItem } from '@object-ui/layout'; -import { SchemaRenderer } from '@object-ui/react'; -import { BarChart3, LayoutDashboard, Users } from 'lucide-react'; - -const navItems: NavItem[] = [ - { title: 'Dashboard', href: '/dashboard', icon: LayoutDashboard }, - { title: 'Users', href: '/users', icon: Users, badge: '12' }, - { - title: 'Reports', - href: '/reports', - icon: BarChart3, - children: [ - { title: 'Sales', href: '/reports/sales' }, - { title: 'Analytics', href: '/reports/analytics' }, - ], - }, -]; - -}> - -; -``` - -Three things that block a copy-paste: `icon` is a **component**, not an icon name — -`SidebarNav` renders it as ``. Nested rows go in `children`; `items` is a key -on `NavGroup`, never on a `NavItem`. And `href` is **required** on every item, including -one that only expands children — it is the row's React key as well as its link target. - -### Props - -`SidebarNavProps` (`packages/layout/src/SidebarNav.tsx`) declares exactly these six. - -| Prop | Type | Required | Description | -| --- | --- | --- | --- | -| `items` | `NavItem[] \| NavGroup[]` | yes | The rows. A flat `NavItem[]`, or `NavGroup[]` for labelled sections — the array's first element decides which. | -| `title` | `string` | no | Group label shown above a flat, ungrouped `items` list. Defaults to `"Application"`. | -| `className` | `string` | no | Tailwind overrides for the `Sidebar` root. | -| `collapsible` | `"offcanvas" \| "icon" \| "none"` | no | How the sidebar collapses at or above 768px. Defaults to `"icon"`. Not a boolean. | -| `searchEnabled` | `boolean` | no | Show a search input that filters rows by title, children included. Defaults to `false`. | -| `searchPlaceholder` | `string` | no | Placeholder for that input. Defaults to `"Search…"`. | - -#### `NavItem` - -| Key | Type | Required | Description | -| --- | --- | --- | --- | -| `title` | `string` | yes | The row's label. | -| `href` | `string` | yes | Link target, and the row's React key. | -| `icon` | `React.ComponentType<{ className?: string }>` | no | Rendered as `` — pass the Lucide component, not its name. | -| `badge` | `string \| number` | no | Trailing badge content. Rendered whenever it is not `null`/`undefined`, so `0` shows. | -| `badgeVariant` | `'default' \| 'destructive' \| 'outline'` | no | Badge styling. Defaults to `'default'`. | -| `children` | `NavItem[]` | no | One level of nested rows; the parent becomes a collapsible trigger. | - -#### `NavGroup` - -| Key | Type | Required | Description | -| --- | --- | --- | --- | -| `label` | `string` | yes | Section heading, shown in place of `title`. | -| `items` | `NavItem[]` | yes | The section's rows. | - -There is no `active` key — the active row is derived from the router -(`pathname === item.href`), never declared. There is no `disabled` key either, and no -`defaultOpen`: that one is `AppShell`'s prop, not this component's. - -### There is no `sidebar-nav` node - -`sidebar-nav` was never registered, so the node does not resolve to anything. Rendering -`{ "type": "sidebar-nav", "items": [...] }` produces the renderer's red error box instead -of a sidebar: - -``` -Unknown component type: sidebar-nav -💡 Ensure the component is registered via registry.register() before rendering. - Check for typos in the component type name. (OBJUI-001) -``` - -This is louder than the `app-shell` case above — nothing is silently dropped, because -nothing is parsed as props at all. The whole sidebar is replaced by the error panel. - -When the navigation tree genuinely has to come from JSON, it is application metadata: -the app document's `navigation` items (`{ "type": "app", "navigation": [...] }`, which -`objectui validate` accepts), drawn by the shell — the console's sidebar, or -`AppSchemaRenderer` (the whole shell from metadata, above). Both hand the items to the -`NavigationRenderer` component, and its items are JSON-shaped: `icon` really is a string -name there, resolved by `resolveIcon`. There is no page node for navigation: -`navigation-renderer` was retired (objectui#11441), so a node written with that type -renders the same "Unknown component type" panel. - -### Features - -- Nested menu items (2 levels) with collapsible expand/collapse -- Active state highlighting via React Router -- Icon support (Lucide icons) -- Badge/counter support with variant styling (`default`, `destructive`, `outline`) -- NavGroup support for grouped navigation sections -- Built-in search filtering (`searchEnabled`) across all items and children -- Collapse/expand animation - -## Common Layout Patterns - -### Full Application Layout - -The shell is React; the page inside it is your JSON. - - -```tsx -import { AppShell, SidebarNav, type NavItem } from '@object-ui/layout'; -import { SchemaRenderer } from '@object-ui/react'; -import { SidebarTrigger } from '@object-ui/components'; -import { Home, Package, ShoppingCart } from 'lucide-react'; - -const navItems: NavItem[] = [ - { title: 'Home', href: '/', icon: Home }, - { title: 'Products', href: '/products', icon: Package }, - { title: 'Orders', href: '/orders', icon: ShoppingCart }, -]; - - - - My App - - } - sidebar={} - defaultOpen -> - - -``` - -`AppShell` adds no controls of its own to the top bar — the sidebar toggle above is one -you render yourself, in `navbar`. There is no `sidebarCollapsible` prop: the sidebar's -collapse behaviour belongs to the sidebar node you pass (`SidebarNav`'s `collapsible`), -and `defaultOpen` is the shell's own initial-state prop. - -### Landing Page (No Sidebar) - -Omit `sidebar` and the content fills the width under the top bar. - - -```tsx -Welcome}> - - -``` - -### Settings Page with Tabs - -A settings page is a `utility` page, which gives it the narrowest content width (see -[Content Width](#content-width)): - -```json -{ - "type": "page", - "pageType": "utility", - "title": "Settings", - "children": { - "type": "tabs", - "items": [ - { - "label": "General", - "value": "general", - "content": { "type": "form", "fields": [] } - }, - { - "label": "Security", - "value": "security", - "content": { "type": "form", "fields": [] } - }, - { - "label": "Notifications", - "value": "notifications", - "content": { "type": "form", "fields": [] } - } - ] - } -} -``` - -### Detail Page with Actions - -Same rule as above, and it governs the trail too: the breadcrumb and the buttons are both -**nodes in `children`** — never a `breadcrumbs` or an `actions` key on the page. - -A detail page is a `record` page. That is the default, and it is named below so the snippet -says so. A record page draws no `title` and no `description` of its own: its heading is -the [`page:header`](#page-header-pageheader) block, so the record's name goes in that -block's `properties.title`, not on the page. - -```json -{ - "type": "page", - "pageType": "record", - "children": [ - { - "type": "breadcrumb", - "items": [ - { "label": "Home", "href": "/" }, - { "label": "Customers", "href": "/customers" }, - { "label": "Acme Corporation" } - ] - }, - { - "type": "page:header", - "properties": { "title": "Acme Corporation" } - }, - { - "type": "flex", - "properties": { - "justify": "end", - "gap": 2, - "children": [ - { - "type": "action:button", - "properties": { - "name": "edit_record", - "label": "Edit", - "variant": "default", - "icon": "pencil", - "actionType": "editRecord" - } - }, - { - "type": "action:button", - "properties": { - "name": "delete_record", - "label": "Delete", - "variant": "danger", - "icon": "trash", - "actionType": "deleteRecord" - } - } - ] - } - }, - { - "type": "card", - "children": [ - { "type": "text", "content": "Record details..." } - ] - } - ] -} -``` - -A button that RUNS something is an `action:button` node, not a `button` carrying an -`onClick`. Its props — `actionType` and the rest of the block's row — go in the node's -`properties` bag, as `@objectstack/spec` declares them; the spec's strict page component -refuses them written flat on the node, and `SchemaRenderer` hoists the bag onto the node -before the renderer reads it. `ButtonSchema.onClick` is declared as a runtime slot for a host-supplied -function and the zod mirror refuses it BY NAME — JSON has no function value, and no -handler key consumes a declarative action object. The refusal is not the whole cost: -`onClick` is on `SDUI_DOM_PASS_THROUGH_KEYS`, so an authored string or object is -forwarded to the real DOM listener slot, and React throws the moment anyone clicks. -Measured, React's own error: "Expected `onClick` listener to be a function, instead got -a value of `object` type." - -The handler name goes in `actionType`, which `action:button` forwards to the action -runner as the action's type; the runner dispatches to the handler registered under it. -Same spelling as [Record Edit Modes](./record-edit-modes.md). - -The rest of the action is authored on the same node. `action:button` and `action:icon` -evaluate `visible` and `disabled` themselves (a boolean, a CEL string, or a -`{ dialect, source }` envelope), and forward the action-definition keys to the runner: -`params`, `confirmText`, `successMessage`, `errorMessage`, `toast`, `bodyExtra`, -`bodyShape`, `operation`, `onSuccess`, `objectName` and the others each -registration's `inputs` lists, with a `description` per key. For an `api` action, write -the request URL in `target`: `endpoint` is not published, because the console's `api` -handler never reads it. - -An `operation: update` node runs the declared action its `name` names: the platform -action route writes that declaration's `patch` under the collected params. A `patch` -written on the node itself is not forwarded and writes nothing. - -`undoable: true` on an `operation: update` makes the success toast offer Undo, which -writes back the prior values of the fields the update wrote: the named declaration's -`patch` fields and the collected params. `action:button` reads those values off the -record in scope: the record page's record, or the row the host binds through `data`. A -button with no record in scope offers no Undo, because there is no row to restore; -neither does one whose `recordId` names a record other than the one in scope. - -## Responsive Behavior - -The shell has exactly **one** layout breakpoint, at **768px** — Tailwind's `md`, and -`MOBILE_BREAKPOINT` in `packages/components/src/hooks/use-mobile.tsx`. There is no -separate tablet tier: nothing in `AppShell`, `SidebarNav` or the Shadcn sidebar underneath -them reads `lg` (1024px), so 800px and 1400px get the same layout. - -### Sidebar, at or above 768px - -- Rendered inline, as a flex sibling of the content — not an overlay. -- How it collapses is the sidebar node's own `collapsible` prop: `"icon"` (the `SidebarNav` - default) leaves an icon rail, `"offcanvas"` slides it fully out, `"none"` pins it open. -- `AppShell`'s `defaultOpen` picks the initial state, and defaults to `true`. - -### Sidebar, below 768px - -- It leaves the layout entirely and becomes a `Sheet` overlay (18rem) above the content, - which is why the content is full-width there. -- **Nothing opens it for you.** `AppShell`'s header renders `{navbar}` and nothing else — - it adds no controls of its own, in particular **no sidebar toggle**. Render a - `SidebarTrigger` inside `navbar` yourself; otherwise the only way in is the - `SidebarProvider` keyboard shortcut, `Cmd/Ctrl + B`, which no touch device has. - -### Header and content - -- The header is `h-12` (3rem / 48px) at **every** breakpoint — there is no compact - variant — and spans the full viewport width at every size. The only thing about it that - responds is horizontal padding, and it turns at `sm` (640px), not 768: `px-2 sm:px-4`. -- Content padding steps three ways — `p-3`, `sm:p-4` (640px), `md:p-6` (768px) — with a - taller `pb-20` below `sm` only. - -## Styling and Customization - -### Custom Classes - -Add Tailwind classes to layout components: - - -```tsx - - My App - - } - sidebar={} -> - - -``` - -`className` is the only class hook `AppShell` itself takes, and it lands on the content -element (a `
` inside the shell's one `
` landmark) — not on the outer container. There is **no** per-slot className: -`headerClassName`, `sidebarClassName` and `contentClassName` do not exist. The top bar and -the sidebar are nodes you build, so style them where you build them, as above. - -### Page Padding - -A `page` node has no padding switch. Its wrapper always insets the content: `p-3`, then -`md:p-4`, then `lg:p-6`. The padding you control is a `container`'s. Its `padding` is a -step on the container's spacing scale: one of 0 to 8, 10, 12 or 16, and `0` means none. -Any other number is refused: - -```json -{ - "type": "page", - "children": { - "type": "container", - "maxWidth": false, - "padding": 8, - "children": [] - } -} -``` - -`"maxWidth": false` keeps the container as wide as the page. Leave it out and the container -also caps its own width at its default, `xl`. The -[Container reference](/docs/components/layout/container) lists both scales. - -> **⛔ `padding` on a `page` node is refused by name** (objectui#11318). This page used to -> teach `"padding": false` here, and it removed **nothing**: the page wrapper kept its inset, -> because `PageRenderer` has no read for the key and `BaseSchema`'s `.passthrough()` kept the -> value rather than refusing it. The refusal's message names the `container` and its `padding`. - -## Best Practices - -### 1. Consistent Structure - -Compose the shell once and let the page JSON change per route: - - -```tsx -// One shell for the whole app; `pageSchema` is whatever the route resolves to. -}> - - -``` - -### 2. Breadcrumbs for Deep Navigation - -Add a breadcrumb trail to help users navigate. It is a **node in `children`**, not a key on the -page — `breadcrumb`, singular, is the registered renderer: - -```json -{ - "type": "breadcrumb", - "items": [ - { "label": "Home", "href": "/" }, - { "label": "Products", "href": "/products" }, - { "label": "Electronics", "href": "/products/electronics" }, - { "label": "Laptops" } - ], - "separator": "/", - "maxItems": 3 -} -``` - -`separator` defaults to `/`, and `maxItems` collapses the middle of a long trail behind an -ellipsis while keeping the first crumb and the current page. See the -[Breadcrumb reference](/docs/components/data-display/breadcrumb) for the per-key face. - -⛔ Not `"breadcrumbs"` on the `page` node — that key has no reader and is refused by name -(objectui#8871), the same way `actions` is. ⛔ Nor the `page:header` block's `breadcrumb`, -a **boolean** the renderer ignores rather than a list of links. - -### 3. Action Buttons at the Top of the Body - -Place primary actions in the first `children` node, so they sit above the content. A list -of orders is a `list` page, which draws its `title` as the heading above them: - -```json -{ - "type": "page", - "pageType": "list", - "title": "Orders", - "children": [ - { - "type": "flex", - "properties": { - "justify": "end", - "gap": 2, - "children": [ - { "type": "button", "label": "New Order", "variant": "default" } - ] - } - } - ] -} -``` - -⛔ Not `"actions"` on the `page` node — that key has no reader and is refused by name -(objectui#7926). A record page has a second door: the `page:header` block, whose `actions` -are **action ids** resolved from the object's own actions metadata, not nodes -(see the [PageHeader reference](/docs/layout/page-header)). - -### 4. Constrained Width for Forms - -Give forms and reading content a narrow column: a `container` with its own `maxWidth`, -inside the page. ⛔ Not `maxWidth` on the `page` node, which is refused by name -(objectui#11318); the page's own cap comes from `pageType` (see [Content Width](#content-width)). - -```json -{ - "type": "page", - "pageType": "utility", - "children": { - "type": "container", - "maxWidth": "2xl", - "children": { - "type": "form", - "fields": [] - } - } -} -``` - -### 5. Sidebar Organization - -Group related items with `NavGroup`. Pass groups instead of a flat list and `SidebarNav` -labels each section and draws the separator between them itself — there is no `divider` -item, and a row is either a link or a group, never both: - - -```tsx -import { SidebarNav, type NavGroup } from '@object-ui/layout'; -import { DollarSign, Home, Settings } from 'lucide-react'; - -const navGroups: NavGroup[] = [ - { - label: 'Overview', - items: [{ title: 'Dashboard', href: '/', icon: Home }], - }, - { - label: 'Sales', - items: [ - { - title: 'Sales', - href: '/sales', - icon: DollarSign, - children: [ - { title: 'Orders', href: '/orders' }, - { title: 'Invoices', href: '/invoices' }, - ], - }, - ], - }, - { - label: 'System', - items: [{ title: 'Settings', href: '/settings', icon: Settings }], - }, -]; - -; -``` - -## Related Documentation - -- [Components Overview](/docs/components) - All available components -- [Schema Rendering](/docs/guide/schema-rendering) - How schemas work -- [Architecture Overview](/docs/guide/architecture) - System architecture diff --git a/ui/content/docs/guide/meta.json b/ui/content/docs/guide/meta.json deleted file mode 100644 index e2b11ad2c23..00000000000 --- a/ui/content/docs/guide/meta.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "title": "Guide", - "pages": [ - "quick-start", - "agent-skills", - "architecture", - "architecture-overview", - "schema-rendering", - "schema-overview", - "schema-playground", - "schema-catalog", - "layout", - "expressions", - "data-source", - "fields", - "component-registry", - "theming", - "plugins", - "plugin-development", - "building-crud-app", - "designing-app-navigation", - "public-forms", - "record-edit-modes", - "notifications", - "dashboard-filters", - "slotted-pages", - "react-pages", - "print-pages", - "console", - "console-architecture", - "flow-designer", - "metadata-diagnostics", - "user-state-persistence", - "objectos-integration", - "troubleshooting", - "deployment", - "release-notes" - ] -} diff --git a/ui/content/docs/guide/metadata-diagnostics.md b/ui/content/docs/guide/metadata-diagnostics.md deleted file mode 100644 index 9613eaadcc7..00000000000 --- a/ui/content/docs/guide/metadata-diagnostics.md +++ /dev/null @@ -1,322 +0,0 @@ ---- -title: Metadata Diagnostics -description: How ObjectStack surfaces load-time validation problems in Studio. ---- - -# Metadata Diagnostics - -Every metadata item shipped by a package — `object`, `view`, `report`, -`dashboard`, `flow`, `app`, … — is validated against its Zod schema when -the framework loads it. The validation result travels alongside the -item as a `_diagnostics` envelope, and Studio surfaces it at four levels -so authors and operators can fix problems without grepping logs. - -> **Backend agnostic.** The shape and the REST endpoint described below -> are part of the ObjectStack protocol. Studio is one consumer; any -> custom UI built on `@object-ui/data-objectstack` can render the same -> envelope. - -## The `_diagnostics` envelope - -```ts -interface MetadataDiagnostics { - valid: boolean; - errors?: Array<{ path: string; message: string; code?: string }>; - warnings?: Array<{ path: string; message: string; code?: string }>; -} -``` - -* `valid === false` means **at least one error** — features that depend - on the item (rendering, queries, automation) are unsafe to use. -* `warnings[]` is advisory — items remain `valid: true` but operators - should review (deprecations, performance hints, missing-but-defaultable - fields). -* `path` is dot-delimited, matching the same convention Zod uses - (`fields.email.type`, `columns.0.bind`). - -The envelope is attached to: - -| Endpoint | Where the envelope lives | -|:---------------------------------------|:----------------------------------| -| `GET /api/v1/meta/items/:type` | Each list entry (`item._diagnostics`) | -| `GET /api/v1/meta/items/:type/:name` | Top-level (`item._diagnostics`) | -| `GET /api/v1/meta/items/:type/:name?layered=true` | `effective._diagnostics` | -| `GET /api/v1/meta/diagnostics` | Sweep — see next section | - -## The diagnostics sweep endpoint - -`GET /api/v1/meta/diagnostics` runs validation across **every metadata -type and item** in one round-trip. It powers the governance overview -page and the per-type tile badges. - -```http -GET /api/v1/meta/diagnostics?severity=error -``` - -| Query | Default | Effect | -|:-------------|:----------|:------------------------------------------------| -| `severity` | `error` | `error` returns invalid items only; `warning` also returns items with only warnings. | -| `type` | — | Limit to a single metadata type. | -| `package` | — | Limit to one package id. | - -Response: - -```ts -import type { MetadataDiagnostics } from '@object-ui/data-objectstack'; - -interface MetadataDiagnosticsSummary { - entries: Array<{ - type: string; - name: string; - diagnostics: MetadataDiagnostics; - }>; - total: number; // entries.length - scannedTypes: number; // how many metadata types were checked - scannedItems: number; // how many items were checked in total - /** - * Per-type aggregate stats — count of items and the list of - * packages contributing to each type. Computed in the same sweep so - * directory tiles render counts and a package filter without - * additional round-trips. Empty `{}` on framework versions older - * than the 7.x line. - */ - stats: Record; -} -``` - -Use this as a CI gate too — `total === 0` is the green-build condition. - -## Studio UI surfaces - -### 1. Directory page badges - -`/apps//metadata` — the directory is **scoped to the active project -software package** (the sidebar `active_package` selector, published as -`?package=`). Only metadata types that the selected project package -contributes are listed — system/cloud types never appear, and there is no -in-page "All packages" dropdown. If the URL holds no valid project -package the page repairs it to the first available one. Each visible type -tile shows: - -* A neutral count badge with the total items of that type. (Note: this - total spans all packages — the per-type *list* page it links to is - strictly scoped to the active project package.) -* A red ⚠ + count when any items fail validation (errors). -* An amber ⚠ + count when items have warnings but no errors. - -Tiles deep-link into the list page carrying the active `?package=`, so the -scope survives navigation. - -The "View all issues (N)" link in the filter row jumps straight to the -governance page. - -### 2. Resource list rows - -`/apps//metadata/` — invalid rows get a red ⚠ icon next to -the name and a destructive-tinted background; warning-only rows get an -amber ⚠ and amber tint. The list header shows aggregate "Invalid N" and -"Warnings N" chips. Hover the ⚠ for the first three messages. - -The list page is **always scoped to a single project software package**. -Studio's sidebar exposes a mandatory **Package** scope selector (the app's -`active_package` context selector) whose options are the installed -*project* packages — system/cloud packages are never offered and there is -no "All" choice. The selection is published as the `?package=` URL -parameter, which every metadata list reads to filter rows by their -`_packageId`. If the URL holds no valid project package the list repairs -it to the first available one, so system metadata never leaks into the -view. (The page no longer renders its own per-type package dropdown — -scope is owned solely by the sidebar selector.) - -### 3. Resource edit banners - -`/apps//metadata//` — a destructive banner at the top -of the edit page lists the first three errors with their paths; the -same errors are also threaded into the form so the offending fields -get inline messages **without** the user having to click Save first. -Warnings, when present, render as a parallel amber banner. - -Edits clear the matching diagnostic immediately — the inline error on a -field disappears as soon as you start typing in it, then re-validates -on save. - -For **object** drafts the live validation goes beyond the Zod shape check: -every field conditional rule (`visibleWhen` / `readonlyWhen` / `requiredWhen`) -is linted as a CEL predicate with the same `@objectstack/formula` validators -the server uses. A predicate -that parses but references an unknown field, or references a field bare -instead of as `record.`, surfaces under its `fields..` -path in the banner. The field inspector's *Conditional rules* editors give -the same verdict inline as you type — with autocomplete for the object's -fields (after `record.` / `previous.`), the runtime-bound scope roots -(`record`, `previous`, `parent`), and the CEL stdlib. - -Formula fields get the same treatment for their value `expression`: the -inline editor lints it in `role: 'value'` mode and shows the **inferred -result type** (only a proven-Number formula is offered as a dataset -measure), while the draft-wide pass surfaces a broken formula on any field -under its `fields..expression` path. - -A predicate that slips past authoring is still not silent at **runtime**: -when a conditional rule (`visibleWhen` / `readonlyWhen` / `requiredWhen`, -view-level `visibleOn`, per-option `visibleWhen`, list conditional -formatting) fails to evaluate, the renderer applies the rule's safe default -(fail-open — a broken predicate never hides a field) and -logs **one `console.warn` per predicate** with the predicate source, the -engine's failure reason, and the field it was attached to. A rule that never -fires while its field stays visible is the classic symptom — open the -browser console and the broken predicate identifies itself (most often a -bare field name where `record.` was meant). - -One slot is the opposite: an object **field group**'s `visibleWhen` is -fail-CLOSED, as `@objectstack/spec` declares for -`ObjectFieldGroupSchema.visibleWhen`. A group whose predicate cannot be -evaluated is hidden whole, header and members, on the form and on the -record detail page. The same one-time warning names `safe default (false)` -and "the group is hidden". A form-view section's own `visibleWhen` keeps -failing open (objectui#13055). - -At **submit**, a broken field rule is refused rather than passed (ADR-0137 -D2). A field-level `visibleWhen` that cannot be evaluated refuses the submit -on the client — the record form, the `/forms/:name` and `/f/:slug` page, and a -wizard's final step — with a message naming the field and the rule, because no -server evaluates `visibleWhen` and its fail-open render would otherwise be a -silent grant. A broken `requiredWhen` / `readonlyWhen` is refused by the server, -and the form shows that refusal beside the input. A `visibleWhen` that reads -`previous` cannot be evaluated on a create form, so such a form is always -refused (and on an edit wizard too, whose final cross-step check binds no -`previous`). - -A **blank** field rule (`''`, whitespace, or an envelope whose `source` is -blank) is not "no rule" — it is refused, at the first place that can see it -(ADR-0137 D1 / D2). A new one is refused at authoring: the form schema's -`visibleWhen` / `readonlyWhen` / `requiredWhen` reject a blank predicate at -parse, as the protocol's field schema does. One already stored is refused at -submit, like any rule that cannot be evaluated: the client refuses a blank -`visibleWhen`, the server a blank `requiredWhen` / `readonlyWhen`. A blank -**gate** is different: at runtime it is read as "no gate" and never refused — -a blank `visible`, `hidden` or `disabled`, a page node's `visibleWhen`, or a -form view's own field `visibleWhen` — but it is not silent either: it is -reported once in the console with the `[blank]` reason (ADR-0137 D4). Where the -protocol already refuses a blank gate at authoring, the form schema does too: a -form field's view-level `visibleOn` and a select option's `visibleWhen` reject -a blank predicate at parse, as their protocol counterparts do. `visible`, -`hidden` and `disabled` have no protocol counterpart, so a blank there parses -and is only diagnosed. - -The same is now true of a **component node's own gate** — `visibleWhen` on a -page component (and its `visible` / `hidden` siblings; the `visibility`, -`visibleOn` and `hiddenOn` spellings are retired and not read), plus a `page:tabs` item's `visibleWhen`. These used to -report in a development build only, so a gate that stopped biting in -production left nothing on the console at all. They now warn in **both** -builds, with the node type, the node id, the gate key, the predicate source -and the engine's reason: - -```text -[ObjectUI] A visibility predicate could not be evaluated - node "record:alert" (id: "a1") - visibleWhen: "nosuchroot.status == 'draft'" - Reason: Failed to evaluate expression "nosuchroot.status == 'draft'": nosuchroot is not defined -... -``` - -The rest of the line says what the fault did to the node: on `visibleWhen` and -`visible` the node was **withheld**, on `hidden` it was removed (see below). - -The line is **rate limited to one per distinct predicate source**, so a broken -predicate rendered down two hundred rows of a list is one line, not two -hundred — while a second, differently-broken predicate still gets its own. - -Page visibility gates fail **closed**: a page node's `visibleWhen` (and its -`visible` / `hidden` siblings, objectui#13299) and a -`page:tabs` item's `visibleWhen` (objectui#13286). A declared predicate that -cannot be evaluated — over the CEL engine's AST bound, a type error, an unbound -root such as `record` on a surface with no row, a missing function — withholds -what it gates rather than drawing it for a viewer the predicate was written to -exclude: the node is not rendered, and a tab is not drawn, header and panel. A -related list withheld this way is not counted in its tab's badge either. The -console line says the node or tab was **withheld**. If a withheld tab was the -active one, the strip falls back to the first visible tab. A node or item with -no gate, or a blank one (`''`, whitespace, an envelope whose `source` is blank), -declares nothing and is still shown; a predicate that evaluates decides as it -always did. Form field rules (`visibleWhen` / `readonlyWhen` on a field, and -the other rules listed above) are not page visibility gates and keep their -fail-open. - -The **app shell's own `visible` gate** joins the same reporter and the same -rate limit: a `visible` predicate on a navigation item, on an area's -navigation, or on an object field rendered by the record form page. This one -was silent in **both** builds before — including the bare-string dialect, -which printed nothing at all — so a menu entry whose role gate had stopped -working rendered for everyone, silently, with nothing to grep for. It now -reports under the surface label `app-shell:visible`: - -```text -[ObjectUI] A visibility predicate could not be evaluated - node "app-shell:visible" - visible: "'org_admin' in current_user.postions" - Reason: ... -``` - -The dedupe key is the predicate **source**, not the menu entry — one broken -role gate copy-pasted across eight entries is one authoring mistake and prints -one line, while a second, differently-broken predicate still gets its own. -Fail-open is unchanged here too: the item still renders for everyone, -including the role the predicate was written to exclude. That is what the line -exists to tell you. - -### 4. Governance overview page - -`/apps//metadata/_diagnostics` — a single sortable table of every -invalid item across every type, grouped by type, with deep-links to the -offending edit page. Toggle the severity tab to include -warning-only items. This is the page to open during a release readiness -review. - -## Authoring metadata that validates cleanly - -Validation rules are defined by the Zod schemas in `@objectstack/spec`. -A few high-leverage patterns: - -* **Use `defineObject`, `defineView`, … helpers** from `@objectstack/spec` — - TypeScript catches most shape issues at compile time before they ever - reach the diagnostics path. -* **Run `os check`** locally before publishing. It calls the same - validators the server uses on load. -* **Treat warnings like errors in CI.** Pass `severity=warning` to the - sweep endpoint and assert `total === 0`. -* **Layered overlays merge first, then validate.** If only your runtime - overlay fails, the source artifact is fine — the bad value is in the - overlay. The edit banner reflects the *effective* item, so what you - see is what features will get. - -## Client SDK - -```ts -import { MetadataClient } from '@object-ui/data-objectstack'; - -const client = new MetadataClient({ baseUrl: 'https://api.example.com' }); -const summary = await client.diagnostics({ severity: 'error' }); -console.log(summary.total, 'invalid item(s)'); -``` - -The hook used by the Studio surfaces: - -```ts -import { useGlobalDiagnostics, useMetadataClient } from '@object-ui/app-shell'; - -const client = useMetadataClient(); -const { - loading, - error, - summary, - byType, // Record - warnByType, // Record (severity='warning' only) - countsByType, // Record - packagesByType, // Record - allPackages, // packageId[] — deduped union for filter dropdowns - reload, -} = useGlobalDiagnostics(client, 'warning'); -``` - -Pass `severity: 'warning'` when you need `warnByType` populated — the -server omits warning-only entries when the default `'error'` severity -is in effect. diff --git a/ui/content/docs/guide/notifications.md b/ui/content/docs/guide/notifications.md deleted file mode 100644 index f86e93895f5..00000000000 --- a/ui/content/docs/guide/notifications.md +++ /dev/null @@ -1,237 +0,0 @@ ---- -title: Notifications -description: The five spec display types and the surface that renders each ---- - -# Notifications - -ObjectUI's notification system implements the spec `NotificationSchema` -(`@objectstack/spec` → `ui/notification.zod.ts`). A notification carries two -independent axes: - -- **`severity`** — `info` / `success` / `warning` / `error`. Picks the icon and tone. -- **`displayType`** — `toast` / `snackbar` / `banner` / `alert` / `inline`. Picks the - **surface**: where and how it appears. - -`displayType` used to be stored and never read, so every type surfaced as a -toast — an author asking for a `banner` got a transient overlay. Each type now -has a presentation of its own. - -## The five presentations - -| `displayType` | Presentation | Rendered by | Persists | -|---|---|---|---| -| `toast` | Transient overlay | the host's `onToast` delegate (sonner in the console) | no — auto-dismiss | -| `snackbar` | Bottom-anchored bar, one at a time, at most one action | `` | no — auto-dismiss | -| `banner` | Page-width strip **in the content flow** | `` | yes — until dismissed | -| `alert` | Blocking acknowledgement dialog, FIFO queue | `` | yes — until acknowledged | -| `inline` | In place, at the surface that raised it | `` | yes — until dismissed | - -Auto-dismiss follows the presentation, not a single global timer: `toast` and -`snackbar` are transient (`config.defaultDuration`, 5s by default), the other -three stay until dismissed. An explicit `duration` always wins — including -`duration: 0`, which makes a toast persistent. - -## Mounting the surfaces - -`toast` is delegated to the host; the other four are React components that -subscribe to the provider. Placement is deliberately yours: a banner needs a -slot in the content area and an inline notification belongs next to the thing -that raised it, so neither can be positioned by a global overlay. - - - -```tsx -import { - NotificationAlerts, - NotificationBanners, - NotificationInline, - NotificationSnackbar, -} from '@object-ui/components'; -import { NotificationProvider } from '@object-ui/react'; -import { toast } from 'sonner'; - - toast[n.severity](n.title, { description: n.message })} -> -
- {/* top of the content area */} - -
- - -
-``` - -A provider with **no** `onToast` is a supported "notification centre" mode: items -are collected in `notifications` / `unreadCount` for a bell or list, and nothing -is overlaid. Raising one of the other four types with its surface unmounted is a -mistake, though — dev builds warn, naming the component to mount. - -### In the console - -`@object-ui/app-shell` already does this wiring — a console route can call -`useNotifications()` and every display type presents correctly with no setup: - -| Surface | Mounted by | -|---|---| -| `toast` | `ConsoleShell`, via `presentNotificationToast` → sonner (`ConsoleToaster`) | -| `snackbar`, `alert` | `ConsoleShell` — both have a single global home | -| `banner` | `ConsoleLayout`, at the top of the content area, beside the draft / unpublished bars | -| `inline` | nothing, by contract — the raising surface mounts its own `` | - -Assembling a shell by hand? Both pieces are exported: `presentNotificationToast` -for the `onToast` delegate, and `ConsoleNotificationBanners` — `NotificationBanners` -guarded by `useHasNotificationProvider()`, so a layout rendered without the -provider above it renders no banners instead of throwing. - -## Raising notifications - -```tsx -import { useNotifications } from '@object-ui/react'; - -declare const undo: () => void; - -const { notify } = useNotifications(); - -notify({ title: 'Saved', severity: 'success' }); // toast (spec default) -notify({ title: 'Row deleted', severity: 'info', displayType: 'snackbar', - actions: [{ label: 'Undo', onClick: undo }] }); // one action -notify({ title: 'Viewing a draft', severity: 'warning', displayType: 'banner' }); -notify({ title: 'Session expired', severity: 'error', displayType: 'alert' }); -``` - -### `actions` - -An action's `variant` is the spec vocabulary — `primary` (default) / `secondary` -/ `link` — describing the action's **role**. Each surface maps it onto its own -button styling; it is not the shadcn Button vocabulary, which is a look. - - - -```tsx -notify({ - title: 'Storage almost full', severity: 'warning', displayType: 'banner', - actions: [ - { label: 'Upgrade', onClick: upgrade }, // primary by default - { label: 'Learn more', onClick: openDocs, variant: 'link' }, - ], -}); -``` - -A `snackbar` and a `toast` render only the **first** action — both have one -action slot by nature. A `banner` and an `inline` render all of them; an `alert` -renders them beside its acknowledge button. - -### `inline` and `scope` - -An inline notification is rendered by the surface that raised it. `scope` is the -routing key that pairs the two, so two forms on one page don't show each other's -messages: - - - -```tsx -notify({ - title: 'Fix 2 fields', severity: 'error', - displayType: 'inline', scope: 'contact-form', -}); - - -``` - -Omit `scope` on both ends for a page-level inline outlet. `scope` is renderer-local -routing metadata, not a spec field — the spec describes what a notification *is*, -not which React subtree hosts it. - -### `icon` - -Every surface — including the console's sonner toast — resolves `icon` through -the same rule: a declared Lucide name (kebab-case or PascalCase) replaces the -severity icon; anything else falls back to it. - - - -```tsx -notify({ title: 'Deploy finished', severity: 'success', displayType: 'banner', icon: 'rocket' }); -``` - -A name Lucide doesn't have costs the author their override and nothing more — -deliberately *not* the generic `Database` glyph `getLazyIcon` returns for -data-shaped schema slots, which on an error notification would replace a -meaningful icon with a meaningless one. - -### `position` - -Honored by the **floating** presentations — `toast` and `snackbar`. `banner`, -`inline` and `alert` are anchored by what they are (content top / in place / -centred modal) and ignore it. - -Resolution is `notification.position ?? config.defaultPosition ?? nothing`, and -"nothing" is a real answer rather than a missing one: - -- **declared** → the surface pins itself there, always; -- **undeclared** → the surface keeps its own anchor (a snackbar's bottom edge) - or defers to the host's toast chrome. - -That asymmetry is deliberate. The host's toast container is shared with toasts -that are *not* spec notifications (in the console, the action runtime's own -`toast.*` calls), so it stays the fallback authority for placement — but never a -competing one. A declared position that a component prop could silently override -would be the same "validates, then does nothing" shape this whole area is about. - -## Configuring the system - -`NotificationProvider`'s `config` is `NotificationSystemConfig`, declared by -`@object-ui/react` (`packages/react/src/context/NotificationContext.tsx`) and -normalized by `resolveNotificationConfig`: - -| Key | Default | Effect | -|---|---|---| -| `defaultPosition` | — | Fallback position for the floating presentations. Deliberately unset: see above. | -| `defaultDuration` | `5000` | Auto-dismiss for the transient presentations. | -| `maxVisible` | `5` | Cap on a stacking surface (banner, inline); the newest survive. | -| `stackDirection` | `down` | Which way a stack grows — `down` puts the newest below. | -| `pauseOnHover` | `true` | Hold a transient notification's timer while it is hovered. | - -The legacy spellings `position` and `stacking` are still accepted: -`defaultPosition` wins over `position`, and `stacking: false` reads as -`maxVisible: 1` ("show only the newest") rather than being ignored. - -### `dismissible` - -Defaults to `true`. On the persistent presentations, `dismissible: false` removes -the dismiss control (a banner you must resolve rather than wave away). An `alert` -always keeps its acknowledge button — `dismissible: false` only closes the Escape -route, never the way out. - -## Notes - -- **`alert` is not the action system's modal.** `ModalHandler` resolves a page or - object, renders it, and reports an `ActionResult` back to the `ActionRunner`. A - notification alert has no schema, no target and no result — it is a message and - an acknowledgement, so it renders through the `AlertDialog` primitive instead. -- **`snackbar` is not a toast variant.** It supersedes rather than stacks, anchors - to the bottom regardless of the toast position config, and carries at most one - action. -- Adding a member to the spec `NotificationTypeSchema` fails type-check in - `NOTIFICATION_PRESENTATIONS` (`@object-ui/react`) until its presentation is - decided — new types cannot silently fall back to a toast. - -## Server-side notifications - -Server-side notifications are the ADR-0030 inbox rows: `sys_inbox_message`, -with read-state in `sys_notification_receipt`. `@object-ui/app-shell` reads them -through **one shared inbox feed** (`sharedUserFeeds`), and every surface that -shows them reads that feed. So however many of those surfaces mount, there is -one read on one cadence, and the surfaces cannot disagree about read-state: - -| Surface | How to get it | -|---|---| -| Header bell: Notifications tab and unread badge | `AppHeader` from `@object-ui/app-shell`; the console's own layouts already mount it | -| A bell placed on a page | a `global:notifications` node in the page schema | - -The feed itself is not exported. To show these rows, mount one of the surfaces -above rather than polling them yourself: a second poller beside the shell is -the duplicate-read shape the shared feed exists to remove. diff --git a/ui/content/docs/guide/objectos-integration.mdx b/ui/content/docs/guide/objectos-integration.mdx deleted file mode 100644 index aedc4c8f139..00000000000 --- a/ui/content/docs/guide/objectos-integration.mdx +++ /dev/null @@ -1,690 +0,0 @@ ---- -title: "ObjectOS Integration Guide" -description: "Complete guide for integrating ObjectUI with ObjectOS platform" ---- - -# ObjectOS Integration Guide - -ObjectUI is designed to be the official frontend renderer for the **ObjectOS** ecosystem. This guide provides comprehensive instructions for integrating ObjectUI components with ObjectOS, ObjectStack, and building enterprise applications. - -## Overview - -ObjectUI serves as the **UI Layer** in the ObjectOS architecture: - -``` -┌─────────────────────────────────────────────────┐ -│ ObjectUI (UI Layer) │ -│ React Components + Tailwind + Schema Rendering │ -└──────────────────┬──────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────┐ -│ ObjectStack (Runtime Layer) │ -│ Kernel + Plugins + ObjectQL + Data Drivers │ -└──────────────────┬──────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────┐ -│ ObjectOS (Platform Layer) │ -│ Multi-tenant + RBAC + System Objects + APIs │ -└─────────────────────────────────────────────────┘ -``` - -## Quick Start Integration - -### 1. Install Dependencies - -```bash -# Core ObjectUI packages -pnpm add @object-ui/react @object-ui/components @object-ui/fields - -# ObjectStack runtime -pnpm add @objectstack/core @objectstack/runtime @objectstack/objectql - -# Data adapter -pnpm add @object-ui/data-objectstack - -# Optional: Plugins as needed -pnpm add @object-ui/plugin-form @object-ui/plugin-grid -``` - -### 2. Set Up ObjectStack Kernel - -{/* doc-snippet: fragment — this file belongs to the ObjectStack SERVER project, not to an ObjectUI app. `@objectstack/runtime`, `@objectstack/objectql`, `@objectstack/plugin-app` and `@objectstack/plugin-hono-server` are dependencies of that project and are not declared by any package in this repository, so they do not resolve in this gate's program (measured: TS2307 x4). Correct documentation the gate cannot reach — the authoritative reference is the ObjectStack side, linked in the section below */} - -```typescript -// src/kernel.ts -import { Kernel } from '@objectstack/runtime'; -import { ObjectQLPlugin } from '@objectstack/objectql'; -import { AppPlugin } from '@objectstack/plugin-app'; -import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; - -export async function createKernel() { - const kernel = new Kernel(); - - // Register essential plugins - kernel.registerPlugin(new ObjectQLPlugin()); - kernel.registerPlugin(new AppPlugin()); - kernel.registerPlugin(new HonoServerPlugin({ - port: 3000, - cors: true - })); - - await kernel.start(); - return kernel; -} -``` - -### 3. Create ObjectStack Configuration - -`objectstack.config.ts` belongs to the **ObjectStack server project**, not to an -ObjectUI app. It is authored with `defineStack()` and compiled by the `os` CLI; -the packages it imports (`@objectstack/spec`, `@objectstack/runtime`, plugins) -are dependencies of that project, not of this one. ObjectUI is a pure consumer -of whatever the server publishes over the metadata API, so the authoritative -reference for this file lives on the ObjectStack side: - -- [Your First Project](https://objectstack.ai/docs/getting-started/your-first-project) — the generated `objectstack.config.ts` walked through key by key: `manifest`, `plugins`, `objects` -- [Command Line Interface](https://objectstack.ai/docs/deployment/cli) — how the file is discovered, validated against `ObjectStackDefinitionSchema`, and compiled to a deployable `dist/objectstack.json` -- [Data Flow Diagrams](https://objectstack.ai/docs/api/data-flow) — how `defineStack()` becomes a running application - -The rest of this guide is ObjectUI's half of the integration: rendering the -objects, views and apps that the server exposes. - -### 4. Set Up Frontend with Console - -```typescript -// src/App.tsx -import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; -import { ObjectStackAdapter } from '@object-ui/data-objectstack'; - -// Import required plugins -import '@object-ui/components'; -import '@object-ui/fields'; -import '@object-ui/plugin-form'; -import '@object-ui/plugin-grid'; - -// Initialize ObjectStack adapter -const dataSource = new ObjectStackAdapter({ - baseUrl: 'http://localhost:3000/api' -}); - -export function App() { - // The adapter is installed ONCE, by `SchemaRendererProvider` above the - // renderer: every node in the tree, the nested `object-view` included, reads - // it from that context. A `dataSource` prop on `SchemaRenderer` itself would - // only be forwarded to the root component. - return ( - -
- -
-
- ); -} -``` - -Mount `App` from your project's own React entry point, the way any React -application is bootstrapped — `createRoot` out of the DOM renderer package, -inside a `StrictMode`. That entry point is yours, not ObjectUI's: no -`@object-ui/*` package installs a DOM renderer for you, it is asked for as a -peer dependency, so the snippet above stops at the component boundary rather -than showing a line that would only resolve inside this repository. - -## ObjectOS-Specific Features - -### Multi-Tenancy Support - -The adapter's constructor takes no `headers` option. Per-request headers go -through its `fetch` hook, which it uses for every call it makes: - -```typescript -// Configure tenant isolation -import { ObjectStackAdapter } from '@object-ui/data-objectstack'; - -const adapter = new ObjectStackAdapter({ - baseUrl: 'http://localhost:3000/api', - fetch: (input, init) => { - const headers = new Headers(init?.headers); - headers.set('X-Tenant-ID', 'tenant-123'); - headers.set('X-Workspace-ID', 'workspace-456'); - return globalThis.fetch(input, { ...init, headers }); - } -}); -``` - -### Role-Based Access Control (RBAC) - -{/* doc-snippet: fragment — a SHAPE excerpt of the ObjectStack SERVER's object metadata (the `objects` map of `defineStack()`), not an expression: a bare object literal at statement position parses as a block with labels (measured: TS1005 x4, TS1109 x1, TS1128 x1). This repo declares no type for it — ObjectUI is a pure consumer of what the server publishes */} - -```typescript -// Define permissions in object schema -{ - objects: { - contact: { - name: 'contact', - label: 'Contact', - permissions: { - create: ['admin', 'sales'], - read: ['admin', 'sales', 'support'], - update: ['admin', 'sales'], - delete: ['admin'] - }, - fields: { - salary: { - name: 'salary', - label: 'Salary', - type: 'currency', - permissions: { - read: ['admin', 'hr'], - update: ['admin', 'hr'] - } - } - } - } - } -} -``` - -### System Objects Integration - -ObjectOS provides system objects like `sys_user`, `sys_organization`, `sys_role`, etc. Integrate them in your UI: - -```typescript -import { ObjectStackAdapter } from '@object-ui/data-objectstack'; -import type { ObjectViewSchema } from '@object-ui/types'; - -const objectStackAdapter = new ObjectStackAdapter({ - baseUrl: 'http://localhost:3000/api' -}); - -// The adapter is installed above the renderer by `SchemaRendererProvider` -// (`dataSource={objectStackAdapter}`, from `@object-ui/react`), never written on -// the node: a node's `dataSource` is the spec's per-element binding, not an adapter. -const sysUserView: ObjectViewSchema = { - type: 'object-view', - objectName: 'sys_user', - defaultViewType: 'grid', - // The grid's own configuration rides the `table` slot. - table: { columns: ['username', 'email', 'role', 'status', 'last_login'] } -}; -``` - -### Workflow Integration - -{/* doc-snippet: fragment — a SHAPE excerpt of the ObjectStack SERVER's object metadata, not an expression: a bare object literal at statement position parses as a block with labels (measured: TS1005 x4, TS1128 x1). `workflow` is a server-side object concern; this repo declares no type for it */} - -```typescript -// Define workflow-enabled object -{ - objects: { - opportunity: { - name: 'opportunity', - label: 'Opportunity', - workflow: { - enabled: true, - states: ['lead', 'qualified', 'proposal', 'negotiation', 'closed_won', 'closed_lost'], - transitions: [ - { from: 'lead', to: 'qualified', label: 'Qualify', role: ['sales'] }, - { from: 'qualified', to: 'proposal', label: 'Create Proposal', role: ['sales'] }, - { from: 'proposal', to: 'negotiation', label: 'Negotiate', role: ['sales', 'manager'] }, - { from: 'negotiation', to: 'closed_won', label: 'Close Won', role: ['manager'] }, - { from: 'negotiation', to: 'closed_lost', label: 'Close Lost', role: ['manager'] } - ] - }, - fields: { - // ... field definitions - } - } - } -} -``` - -## Data Layer Integration - -### Using ObjectQL for Queries - -```typescript -import { ObjectStackAdapter } from '@object-ui/data-objectstack'; -import type { ObjectGridBlockNode } from '@object-ui/types'; -import type { ObjectGridProps } from '@objectstack/spec/ui'; - -const dataSource = new ObjectStackAdapter({ - baseUrl: 'http://localhost:3000/api' -}); - -// The adapter is injected ONCE, at the renderer boundary — -// `` from `@object-ui/react` — -// never as a schema key. A node's `dataSource` is the per-element BINDING -// (`{ object, view?, filter?, sort?, limit? }`), not the adapter, and -// `SchemaRenderer` strips it from the props it spreads. The grid's own props go -// in its `properties` bag — the spec's `ComponentPropsMap['object-grid']` row. -const schema: ObjectGridBlockNode = { - type: 'object-grid', - properties: { - objectName: 'contact', - // Grid filter, JSON-rules form: an array of `{ field, operator, value }` - // entries, AND-ed together. `operator` takes the canonical view-filter - // vocabulary (`equals`, `greater_than_or_equal`, ...). - filter: [ - { field: 'status', operator: 'equals', value: 'active' }, - { field: 'created_date', operator: 'greater_than_or_equal', value: '2024-01-01' } - ], - // ObjectQL sorting - sort: [ - { field: 'created_date', order: 'desc' } - ], - } satisfies ObjectGridProps, -}; -``` - -### Custom Data Hooks - -`@object-ui/data-objectstack` ships the *adapter*, not hooks. Reads and writes -go through `useViewData` from `@object-ui/react`, which resolves the adapter -from context and hands back both the rows and the `DataSource` to write with. - -```typescript -import { SchemaRenderer, useViewData } from '@object-ui/react'; - -function ContactList() { - const { data, loading, error, dataSource, refresh } = useViewData({ - resource: 'contact', - params: { - $filter: { status: 'active' }, - $orderby: [{ field: 'name', order: 'asc' }], - $top: 20, - }, - }); - - const handleCreate = async (formData: Record) => { - await dataSource?.create('contact', formData); - await refresh(); - }; - - if (loading) return
Loading...
; - if (error) return
Error: {error.message}
; - - // The grid's props go in its `properties` bag. The rows `useViewData` - // fetched are handed over as inline `ViewData` (there is no `rowData` key). - return ( - - ); -} -``` - -## Deployment Strategies - -### Strategy 1: Monolithic Deployment - -Deploy ObjectUI and ObjectStack together in a single Node.js process: - -{/* doc-snippet: fragment — `./kernel` and `./console-plugin` are the READER's own project files (the kernel module is the one built in step 2 above), so the relative specifiers resolve nowhere in this gate's program (measured: TS2307 x2) */} - -```typescript -// server.ts -import { createKernel } from './kernel'; -import { ConsolePlugin } from './console-plugin'; - -async function start() { - const kernel = await createKernel(); - - // Register console UI plugin - kernel.registerPlugin(new ConsolePlugin()); - - console.log('🚀 Server started at http://localhost:3000'); - console.log('📊 Console UI at http://localhost:3000/console'); -} - -start(); -``` - -### Strategy 2: Microservices Deployment - -Deploy ObjectUI (frontend) and ObjectStack (backend) separately: - -**Backend (ObjectStack API):** - -{/* doc-snippet: fragment — `./kernel` is the READER's own project file (the kernel module built in step 2 above), so the relative specifier resolves nowhere in this gate's program (measured: TS2307 x1) */} - -```typescript -// backend/server.ts -import { createKernel } from './kernel'; - -async function start() { - const kernel = await createKernel(); - console.log('🚀 API Server started at http://localhost:3000'); -} - -start(); -``` - -**Frontend (ObjectUI):** - -{/* doc-snippet: fragment — two files in one block, and it is `import.meta.env` that cannot compile here: the `env` member is a Vite ambient declaration carried by the READER's `vite/client` types, which this repository's gate program does not load (measured: TS2339 x1, plus TS2304 x1 for the adapter the second file imports in the reader's own entry point) */} - -```typescript -// frontend/src/config.ts -export const config = { - apiBaseUrl: import.meta.env.VITE_API_URL || 'http://localhost:3000/api' -}; - -// frontend/src/index.tsx -const dataSource = new ObjectStackAdapter({ - baseUrl: config.apiBaseUrl -}); -``` - -### Strategy 3: Cloud-Native Deployment - -Deploy on Kubernetes with separate services: - -```yaml -# k8s/deployment.yaml -apiVersion: v1 -kind: Service -metadata: - name: objectstack-api -spec: - selector: - app: objectstack-api - ports: - - port: 3000 ---- -apiVersion: v1 -kind: Service -metadata: - name: objectui-frontend -spec: - selector: - app: objectui-frontend - ports: - - port: 80 ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: objectstack-api -spec: - replicas: 3 - template: - spec: - containers: - - name: api - image: myregistry/objectstack-api:latest - env: - - name: DATABASE_URL - value: postgresql://... ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: objectui-frontend -spec: - replicas: 2 - template: - spec: - containers: - - name: frontend - image: myregistry/objectui-frontend:latest - env: - - name: API_URL - value: http://objectstack-api:3000 -``` - -## Advanced Integration Patterns - -### Custom Component Registration - -{/* doc-snippet: fragment — `MyCustomWidget` is the READER's own component, and the block closes with a bare `{ type: 'my-custom-widget' }` SHAPE excerpt showing where the registered name is then used, which at statement position parses as a block with labels (measured: TS1005 x1) */} - -```typescript -// Register custom components with ObjectUI -import { ComponentRegistry } from '@object-ui/core'; - -ComponentRegistry.register('my-custom-widget', MyCustomWidget, { - namespace: 'custom', - lazy: true -}); - -// Use in schema -{ - type: 'my-custom-widget', - config: { - // custom props - } -} -``` - -### Event Handling & Callbacks - -A metadata document cannot carry a function, so a callback is never a schema -key: the grid's row callbacks (`onRowClick`, `onCellChange`, …) are **component -props**, passed to the renderer. The grid's own props go in its `properties` -bag, and with `editable` an edited cell saves through the adapter the -`SchemaRendererProvider` above it supplies, with no callback to wire. - -```tsx -import { SchemaRenderer } from '@object-ui/react'; - -export const ContactGrid = () => ( - { - // Navigate to detail page - window.location.href = `/contact/${row.id}`; - }} - /> -); -``` - -### Reacting to Data Changes - -⚠️ `@object-ui/data-objectstack` has **no WebSocket transport and no server-push -subscription**. What it offers is `onMutation`: a notification of the writes -*this adapter instance* performed, which is what a view needs to refresh itself -after its own create/update/delete. It returns its own unsubscribe function. - -```typescript -import { ObjectStackAdapter } from '@object-ui/data-objectstack'; - -const adapter = new ObjectStackAdapter({ - baseUrl: 'http://localhost:3000/api' -}); - -// Fires for writes this adapter performed. Filter by `resource` for one object. -const unsubscribe = adapter.onMutation((event) => { - if (event.resource !== 'contact') return; - if (event.type === 'create' || event.type === 'update') { - // Refresh the view holding this object's rows - } -}); - -// Later, when the view unmounts: -unsubscribe(); -``` - -## Migration from Other Platforms - -### From Retool - -{/* doc-snippet: fragment — a SHAPE excerpt showing the ObjectUI half of a side-by-side migration comparison; a bare object literal at statement position parses as a block with labels */} - -```typescript -// Retool table → ObjectUI Grid. The props go in the node's `properties` bag -// (the spec's `ComponentPropsMap['object-grid']` row): declaring bulk actions -// turns multi-row selection on, the export menu is `exportOptions`, and the -// adapter comes from `SchemaRendererProvider`, never from the node. -{ - type: 'object-grid', - properties: { - objectName: 'users', - editable: true, - bulkActions: ['delete', 'export'], - exportOptions: { formats: ['csv', 'xlsx'] }, - }, -} -``` - -### From Appsmith - -{/* doc-snippet: fragment — a SHAPE excerpt showing the ObjectUI half of a side-by-side migration comparison; a bare object literal at statement position parses as a block with labels */} - -```typescript -// Appsmith form → ObjectUI form. The props go in the node's `properties` -// bag (the spec's `ComponentPropsMap['object-form']` row). The form saves -// through the adapter `SchemaRendererProvider` supplies, so no submit -// handler is written here. -{ - type: 'object-form', - properties: { - objectName: 'contact', - mode: 'create', - fields: ['name', 'email', 'phone', 'company'], - }, -} -``` - -### From Mendix - -{/* doc-snippet: fragment — a SHAPE excerpt showing the ObjectUI half of a side-by-side migration comparison, with every slot body elided as a prose comment, so the object literal cannot parse as TypeScript (measured: TS1005 x2, TS1128 x2) */} - -```typescript -// Mendix page → ObjectUI page -{ - type: 'page', - template: 'header-sidebar-main', - header: { /* ... */ }, - sidebar: { /* ... */ }, - main: { - type: 'tabs', - items: [ - { label: 'Overview', content: { /* ... */ } }, - { label: 'Details', content: { /* ... */ } } - ] - } -} -``` - -## Performance Optimization - -### Bundle Optimization - -```typescript -// Lazy load plugins -const plugins = { - grid: () => import('@object-ui/plugin-grid'), - charts: () => import('@object-ui/plugin-charts'), - kanban: () => import('@object-ui/plugin-kanban') -}; - -// Load on demand -await plugins.grid(); -``` - -### Caching Strategy - -```typescript -import { ObjectStackAdapter } from '@object-ui/data-objectstack'; - -const adapter = new ObjectStackAdapter({ - baseUrl: 'http://localhost:3000/api', - cache: { - ttl: 60000, // 1 minute - maxSize: 500 // entries retained - } -}); -``` - -## Testing & Quality Assurance - -### Unit Tests - -{/* doc-snippet: fragment — a test body: `test` / `expect` are the RUNNER's globals (Vitest or Jest, injected by the reader's own test config, not imported here) and `mockDataSource` is the reader's own fixture, so none of the three resolves in this gate's program (measured: TS2593 x1, TS2304 x2) */} - -```typescript -import { render } from '@testing-library/react'; -import { SchemaRenderer } from '@object-ui/react'; - -test('renders contact grid', () => { - const { getByText } = render( - - ); - expect(getByText('Contact')).toBeInTheDocument(); -}); -``` - -### Integration Tests - -End-to-end tests drive the running app, so they need a browser runner the app itself -does not depend on. Install it in your project first — `npm install -D @playwright/test` -(then `npx playwright install` once, for the browsers) — and run these specs with -`npx playwright test`. - -{/* doc-snippet: fragment — an end-to-end spec for the READER's own Playwright installation: `@playwright/test` is a test runner the reader installs themselves (the sentence above says so), and no package documented here declares it, so it does not resolve in this gate's program (measured: TS2307 x1). It used to compile only because THIS repository happens to carry `@playwright/test` as a root devDependency, which is what objectui#7463 item 2 bounded */} - -```typescript -import { test, expect } from '@playwright/test'; - -test('create contact workflow', async ({ page }) => { - await page.goto('http://localhost:3000/console/contacts'); - await page.click('button:has-text("New Contact")'); - await page.fill('[name="name"]', 'John Doe'); - await page.fill('[name="email"]', 'john@example.com'); - await page.click('button:has-text("Save")'); - await expect(page.locator('text=John Doe')).toBeVisible(); -}); -``` - -## Resources - -- [ObjectStack Documentation](https://docs.objectstack.ai) -- [ObjectUI Components Reference](/docs/components) -- [ObjectQL Schemas](/docs/api/schema-reference#objectql-schemas) -- [Schema Catalog](/docs/guide/schema-catalog) - every schema rendered in these docs - -## Support - -- GitHub Issues: https://github.com/objectstack-ai/objectui/issues -- Discord Community: https://discord.gg/objectui -- Email: hello@objectui.org diff --git a/ui/content/docs/guide/plugin-development.md b/ui/content/docs/guide/plugin-development.md deleted file mode 100644 index e4b33382be7..00000000000 --- a/ui/content/docs/guide/plugin-development.md +++ /dev/null @@ -1,561 +0,0 @@ ---- -title: "Custom Plugin Development" ---- - -This guide walks you through creating custom ObjectUI plugins — from scaffolding to publishing. Plugins extend ObjectUI with new view types, field widgets, or complex interactive components while keeping your application bundle lean through lazy loading. - -## What Is an ObjectUI Plugin? - -A plugin is a self-contained package that registers one or more components into the [Component Registry](./component-registry.md). When a JSON schema references a plugin's component type, the renderer resolves and renders it automatically. - -Plugins differ from regular components in two ways: - -- **Lazy-loaded** — heavy dependencies are code-split and fetched on demand. -- **Self-registering** — importing the package is enough; no manual wiring required. - -Official plugins (`@object-ui/plugin-grid`, `@object-ui/plugin-kanban`, `@object-ui/plugin-charts`, etc.) all follow this pattern, and your custom plugins should too. - -## Plugin Anatomy - -Every plugin has three key parts: - -``` -packages/plugin-board/ -├── src/ -│ ├── index.tsx # Entry point: lazy wrapper + ComponentRegistry.register() -│ ├── BoardImpl.tsx # Heavy implementation (imported lazily) -│ ├── BoardImpl.test.tsx # Tests -│ └── types.ts # TypeScript interfaces & schema types -├── package.json -├── vite.config.ts -├── tsconfig.json -└── README.md -``` - -| File | Role | -|------|------| -| `index.tsx` | Lightweight entry — sets up `React.lazy()`, `Suspense` fallback, and calls `ComponentRegistry.register()`. | -| `BoardImpl.tsx` | The actual renderer. All heavy dependencies live here so they are tree-shaken from the initial bundle. | -| `types.ts` | Schema interfaces extending `BaseSchema` from `@object-ui/types`. | - -## Scaffolding With the CLI - -The fastest way to start is the `create-plugin` generator: - -```bash -npx @object-ui/create-plugin board --description "Kanban-style board view" - -# Or with pnpm / npm create aliases: -pnpm create @object-ui/plugin board -npm create @object-ui/plugin board -``` - -This produces a ready-to-build plugin under `packages/plugin-board/` with the correct `package.json`, Vite config, test file, and registry call already in place. That directory — and the anatomy shown above — is what the generator writes into **your** workspace (`/packages/plugin-`, see `packages/create-plugin/src/index.ts`); it is not a package that ships in this repository, so do not expect to find it in a fresh ObjectUI checkout. - -After scaffolding, install dependencies: - -```bash -pnpm install -``` - -## Implementing a Custom View Plugin - -Let's build a **board** view plugin that renders items in columns (similar to a Kanban but simplified). - -### 1. Define the Schema Types - -```typescript -// src/types.ts -import type { BaseSchema } from '@object-ui/types'; - -export interface BoardColumn { - id: string; - title: string; -} - -export interface BoardItem { - id: string; - columnId: string; - title: string; - description?: string; -} - -export interface BoardSchema extends BaseSchema { - type: 'board'; - columns: BoardColumn[]; - items: BoardItem[]; - onItemMove?: (itemId: string, toColumnId: string) => void; -} - -export interface BoardProps { - schema: BoardSchema; - className?: string; -} -``` - -### 2. Build the Implementation - - -```tsx -// src/BoardImpl.tsx -import React from 'react'; -import { Card, CardHeader, CardTitle, CardContent } from '@object-ui/components'; -import { cn } from '@object-ui/components'; -import type { BoardProps } from './types'; - -export default function BoardImpl({ schema, className }: BoardProps) { - const { columns, items } = schema; - - return ( -
- {columns.map((col) => ( -
-

{col.title}

- {items - .filter((item) => item.columnId === col.id) - .map((item) => ( - - - {item.title} - - {item.description && ( - - {item.description} - - )} - - ))} -
- ))} -
- ); -} -``` - -### 3. Create the Entry Point - - -```tsx -// src/index.tsx -import React, { Suspense } from 'react'; -import { ComponentRegistry } from '@object-ui/core'; -import { Skeleton } from '@object-ui/components'; - -const LazyBoard = React.lazy(() => import('./BoardImpl')); - -export const BoardRenderer: React.FC<{ schema: any; [key: string]: any }> = ({ - schema, - ...props -}) => ( - }> - - -); - -// Auto-register on import -ComponentRegistry.register('board', BoardRenderer, { - namespace: 'plugin-board', - label: 'Board View', - category: 'plugin', - inputs: [ - { name: 'columns', type: 'array', required: true }, - { name: 'items', type: 'array', required: true }, - ], - defaultProps: { - columns: [ - { id: 'todo', title: 'To Do' }, - { id: 'done', title: 'Done' }, - ], - items: [], - }, -}); - -export { default as BoardImpl } from './BoardImpl'; -export type { BoardSchema, BoardProps, BoardColumn, BoardItem } from './types'; -``` - -Now any schema with `"type": "board"` will resolve to your component. - -## What Reaches the DOM - -`SchemaRenderer` hands a registered component far more than the keys it -declares: the node itself as `schema`, every other key the author wrote on the -node, the contents of its `props` bag, the runtime props it injects (`bind`, -`events`, the camelCase `ariaLabel` the author wrote, the data-source adapter) -and whatever its own host passed. React writes unknown lowercase props straight -onto an element and stringifies objects, so a component that ends in a bare -`{...props}` spread onto an element puts all of it on the page — -`schema="[object Object]"`, `datasource="[object Object]"`, a `name` on a `div`, -and every authored key the widget never declared. Nothing warns: React stays -silent for lowercase names. - -**The contract for a registered widget's host element** (objectui#4425, -phase-2 ruling, comment 5270759246): it receives only what `toDomProps` from `@object-ui/core` -passes — the SDUI contract's declared DOM keys (`id`, `className`, `role`, -`tabIndex` and the rest of `SDUI_DOM_PASS_THROUGH_KEYS`) and the open `aria-*` -and `data-*` families. Everything else the renderer hands a widget is either -**consumed** — read by name and turned into behaviour — or dropped. It is a -whitelist rather than a list of keys to strip, because the set of keys an author -may write is unbounded and a strip list can only name the ones that exist today. - -```tsx -import type { HTMLAttributes, ReactNode } from 'react'; -import { toDomProps, type SduiDomPassThroughKey } from '@object-ui/core'; - -// The DOM pass-through this widget declares is exactly what the whitelist -// forwards — named from the whitelist's own key type, so the two cannot drift. -interface BoardFrameProps extends Pick, SduiDomPassThroughKey> { - children?: ReactNode; -} - -export function BoardFrame({ className, children, ...props }: BoardFrameProps) { - return ( - // Spread FIRST, so the attributes the widget computes stay authoritative. -
- {children} -
- ); -} -``` - -A widget that really means to forward something beyond that set **declares** -it on its props type and forwards it **by name** — the way `style` reaches the -layout containers. Never reopen the spread to get it. A component that is both -registered and exported for direct React use may keep a wider declared -`HTMLAttributes` pass-through on its direct door, as `@object-ui/plugin-dashboard`'s -metric cards do; what the renderer hands it still goes through the whitelist. - -Field widgets follow the same rule with their own declared set: `@object-ui/fields` -exports a `toDomProps` that adds the form-control keys `name` and `disabled` -(see [Field Widget Props](/docs/fields/widget-props)). - -**The gate.** `packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx` -renders every registered widget of the packages it covers through the real -`SchemaRenderer`, with planted canary keys from every family above, and fails -on any attribute HTML does not define. A known leak is a ledger row in that -file naming the issue that owns its fix, and the ledger is a two-way ratchet: a -new leak fails the gate, and so does a fixed one until its row is deleted. - -## Implementing a Custom Field Widget - -Field widgets follow the `FieldWidgetComponentProps` interface from `@object-ui/fields`. - -```typescript -// FieldWidgetComponentProps shape (from packages/fields/src/widgets/types.ts) -import type { FieldMetadata } from '@object-ui/types'; - -type FieldWidgetComponentProps = { - value: T; - onChange: (val: T) => void; - field: FieldMetadata; - readonly?: boolean; - disabled?: boolean; - className?: string; - error?: string; -}; -``` - -The validation slot is named `error`, matching `FieldWidgetPropsSchema` in -`@objectstack/spec/ui` — the published contract a widget is written against. -The form renderer supplies it from the active validation message. - -### `field` is the only metadata carrier (v17, breaking) - -Before v17 a widget could receive its metadata under **either** `field` or -`schema`, depending on which host rendered it, so widgets written in that era -resolve their config as `field || schema`. `schema` has been removed from -`FieldWidgetComponentProps` in v17: **read `props.field`, full stop.** Reading -`props.schema` now yields `undefined`. - -`schema` itself is not going anywhere — it is the universal SDUI node -`SchemaRenderer` hands to *every* registered component (`element:*`, `page:*`, -grids, reports). That is precisely why a **field widget** needs an adapter when -it is rendered from a schema node instead of from a form. Wrap it once, at -registration, and the widget only ever implements one contract: - - -```tsx -import { ComponentRegistry } from '@object-ui/core'; -import { withFieldCarrier } from '@object-ui/fields'; - -ComponentRegistry.register('color', withFieldCarrier(ColorPickerField), { - namespace: 'field', -}); -``` - -`withFieldCarrier` forwards the node by reference (nothing is copied or -dropped) and consumes `schema` so it never reaches the DOM. Every built-in -field widget is registered through it. - -### Who renders what - -The widget and the form renderer split validation display, and the split is -not optional: - -| Concern | Owner | -|---|---| -| `aria-invalid` on the input | **the widget** — only it renders the input element | -| the required marker (`*`) | **the form renderer** (``) | -| the message TEXT | **the form renderer** (``) | - -So consume `error` as a **boolean signal** — `aria-invalid={!!error}` — and do -not render the message yourself. The form already prints it below the control; -a widget that prints it too shows the user the same sentence twice. For the -same reason `required` is not in the props: the marker has one author. - -### Example: Color Picker Field - -```tsx -// src/ColorPickerField.tsx -import React from 'react'; -import { Input } from '@object-ui/components'; -import type { FieldWidgetComponentProps } from '@object-ui/fields'; - -export function ColorPickerField({ - value, - onChange, - field, - readonly, - disabled, - error, -}: FieldWidgetComponentProps) { - if (readonly) { - return ( -
- - {value || '—'} -
- ); - } - - return ( -
- onChange(e.target.value)} - disabled={disabled} - className="h-8 w-8 cursor-pointer rounded border-0 p-0" - /> - onChange(e.target.value)} - placeholder={field?.placeholder || '#000000'} - disabled={disabled} - className="font-mono text-sm" - // The whole job of `error` here: tell assistive tech the field failed. - // The message text is rendered by the form, not by this widget. - aria-invalid={!!error} - /> -
- ); -} -``` - -Register it as a field widget: - - -```tsx -// src/index.tsx -import { ComponentRegistry } from '@object-ui/core'; -import { withFieldCarrier } from '@object-ui/fields'; -import { ColorPickerField } from './ColorPickerField'; - -ComponentRegistry.register('field-color', withFieldCarrier(ColorPickerField), { - namespace: 'plugin-board', - label: 'Color Picker', - category: 'field', - inputs: [ - { name: 'value', type: 'string', label: 'Value' }, - { name: 'placeholder', type: 'string', label: 'Placeholder' }, - ], -}); - -export { ColorPickerField }; -``` - -## Using the ComponentRegistry - -### Namespaced Registration - -Namespaces prevent type collisions between plugins: - - -```tsx -import { ComponentRegistry } from '@object-ui/core'; - -// Register with a namespace — accessible as 'plugin-board:board' AND 'board' -ComponentRegistry.register('board', BoardRenderer, { - namespace: 'plugin-board', -}); - -// Explicit lookup by namespace -ComponentRegistry.get('board', 'plugin-board'); - -// Fallback lookup (works when the type is unambiguous) -ComponentRegistry.get('board'); -``` - -Use `skipFallback: true` in the metadata if you do **not** want the component to be available without a namespace prefix. - -### Querying Registered Components - -```tsx -import { ComponentRegistry } from '@object-ui/core'; - -ComponentRegistry.has('board'); // boolean -ComponentRegistry.getAllTypes(); // string[] -ComponentRegistry.getNamespaceComponents('plugin-board'); // RegistryComponentConfig[] -``` - -## Plugin Configuration & Schema Types - -Define your schema interface in `types.ts` and extend `BaseSchema`: - - -```typescript -import type { BaseSchema } from '@object-ui/types'; - -export interface BoardSchema extends BaseSchema { - type: 'board'; - columns: BoardColumn[]; - items: BoardItem[]; -} -``` - -Declare `ComponentInput` entries when registering: they are what the published manifest (`sdui.manifest.json`) and the JSX-page compiler's diagnostics read. Each entry carries the seven keys the manifest forwards — `name`, `type`, `of`, `required`, `enum`, `binding`, `description`; a default belongs in the renderer's own fallback read and, for the author, in `description` (`label`, `defaultValue` and `advanced` are retired keys — nothing ever read them). At the component level the manifest carries one marker of its own: `tier: 'html'` on the intrinsic HTML elements a `kind:'html'` page may author (`HTML_TIER_INTRINSICS` in `@object-ui/core`). An entry without it is a curated block; a reader that means the curated vocabulary filters on it, a reader that whitelists tags takes every key. - -`of` is the coarse kind of an `array`'s elements, or of the values of an `object` used as a map. Declare it whenever the contract admits exactly one kind there: `type: 'array'` alone says a value is a list and stops, so a member of the wrong shape reaches the renderer with nothing anywhere reporting it. With `of` declared, the parser reports a member no declared kind accepts, and the generated `sdui-intrinsics.d.ts` types the elements (`string[]` rather than `unknown[]`). Like `type`, it names a KIND and never a domain: `of: 'object'` says the elements are objects, not which keys they carry — spell that out in `description`, and let `os validate` judge the values. A member contract that genuinely accepts several kinds takes the array form (`of: ['string', 'object']`), or no `of` at all: - - -```tsx -ComponentRegistry.register('board', BoardRenderer, { - inputs: [ - { name: 'columns', type: 'array', of: 'object', required: true }, - { name: 'items', type: 'array', of: 'object', required: true }, - { - name: 'layout', - type: 'enum', - enum: ['horizontal', 'vertical'], - description: 'Defaults to "horizontal" — the renderer\'s own fallback', - }, - ], -}); -``` - -## Testing Plugins - -ObjectUI uses **Vitest + React Testing Library**. Place tests next to the implementation. - - -```tsx -// src/BoardImpl.test.tsx -import { describe, it, expect } from 'vitest'; -import { render, screen } from '@testing-library/react'; -// `toBeInTheDocument` is a jest-dom matcher, not a Vitest one — without this -// import the assertions below do not type-check and do not run. -import '@testing-library/jest-dom'; -import BoardImpl from './BoardImpl'; - -const schema = { - type: 'board' as const, - columns: [ - { id: 'todo', title: 'To Do' }, - { id: 'done', title: 'Done' }, - ], - items: [ - { id: '1', columnId: 'todo', title: 'Write tests' }, - { id: '2', columnId: 'done', title: 'Ship plugin' }, - ], -}; - -describe('BoardImpl', () => { - it('renders all columns', () => { - render(); - expect(screen.getByText('To Do')).toBeInTheDocument(); - expect(screen.getByText('Done')).toBeInTheDocument(); - }); - - it('renders items in correct columns', () => { - render(); - expect(screen.getByText('Write tests')).toBeInTheDocument(); - expect(screen.getByText('Ship plugin')).toBeInTheDocument(); - }); - - it('handles empty items gracefully', () => { - render(); - expect(screen.getByText('To Do')).toBeInTheDocument(); - }); -}); -``` - -Run tests: - -```bash -pnpm vitest run packages/plugin-board -``` - -## Publishing Guidelines - -### Package Checklist - -Before publishing, verify: - -- [ ] `package.json` has correct `name`, `version`, `exports`, and `peerDependencies`. -- [ ] `react` and `react-dom` are **peer** dependencies, not direct dependencies. -- [ ] `@object-ui/core` and `@object-ui/components` are in `devDependencies` (or `peerDependencies`). -- [ ] `vite.config.ts` marks React and ObjectUI packages as **external**. -- [ ] Types are exported via `"types"` field in `package.json`. -- [ ] All tests pass (`pnpm vitest run`). -- [ ] The entry point is lightweight — heavy code lives in `*Impl.tsx` files. - -### Build & Verify - -```bash -pnpm build --filter @object-ui/plugin-board -ls -lh packages/plugin-board/dist/ -``` - -The entry chunk should be under 1 KB; the lazy chunk carries the bulk. - -### Publish - -```bash -cd packages/plugin-board -npm publish --access public -``` - -### Consumers Install & Use - -```bash -pnpm add @object-ui/plugin-board -``` - - -```tsx -// app/main.tsx — import once, auto-registers -import '@object-ui/plugin-board'; -``` - -```json -{ - "type": "board", - "columns": [ - { "id": "todo", "title": "To Do" }, - { "id": "done", "title": "Done" } - ], - "items": [ - { "id": "1", "columnId": "todo", "title": "Write docs" } - ] -} -``` - -## Related Documentation - -- [Component Registry](./component-registry.md) — registry internals and advanced usage -- [Plugins Overview](./plugins.md) — official plugin catalog -- [Schema Rendering](./schema-rendering.md) — how schemas become UI -- [Fields Guide](./fields.md) — built-in field widgets and `FieldWidgetComponentProps` diff --git a/ui/content/docs/guide/plugins.md b/ui/content/docs/guide/plugins.md deleted file mode 100644 index 879b343a0a8..00000000000 --- a/ui/content/docs/guide/plugins.md +++ /dev/null @@ -1,553 +0,0 @@ ---- -title: "Plugins" ---- - -Object UI supports a powerful plugin system that allows you to extend the framework with additional components. Plugins are separate packages that load on-demand, keeping your main application bundle small while providing rich functionality. - -## Overview - -Plugins are lazy-loaded component packages that: - -- **Auto-register** components when imported -- **Lazy-load** heavy dependencies on-demand -- **Keep bundles small** - only load when needed -- **Are type-safe** with full TypeScript support -- **Follow best practices** with built-in loading states - -## Official Plugins - -Object UI provides 14+ official plugins for common use cases: - -### Data Visualization & Dashboards - -#### [@object-ui/plugin-charts](../plugins/plugin-charts.mdx) - -Data visualization components powered by Recharts. - -- Bar, line, area, and pie charts -- Responsive design -- Customizable colors -- Lazy-loaded (~80 KB) - -[Read full documentation →](../plugins/plugin-charts.mdx) - ---- - -#### [@object-ui/plugin-dashboard](../plugins/plugin-dashboard.mdx) - -Dashboard layouts with metric cards and widgets. - -- Dashboard grid layouts -- Metric/KPI cards with trends -- Widget system -- Lazy-loaded (~22 KB) - -[Read full documentation →](../plugins/plugin-dashboard.mdx) - ---- - -#### [@object-ui/plugin-timeline](../plugins/plugin-timeline.mdx) - -Timeline component with multiple layout variants. - -- Vertical, horizontal layouts -- Customizable markers -- Date formatting -- Lazy-loaded (~20 KB) - -[Read full documentation →](../plugins/plugin-timeline.mdx) - ---- - -#### [@object-ui/plugin-gantt](../plugins/plugin-gantt.mdx) - -Gantt chart for project visualization. - -- Task dependencies -- Progress tracking -- ObjectQL integration -- Lazy-loaded (~40 KB) - -[Read full documentation →](../plugins/plugin-gantt.mdx) - ---- - -#### [@object-ui/plugin-calendar](../plugins/plugin-calendar.mdx) - -Calendar visualization for events. - -- Month/week/day views -- Event management -- ObjectQL integration -- Lazy-loaded (~25 KB) - -[Read full documentation →](../plugins/plugin-calendar.mdx) - ---- - -#### [@object-ui/plugin-map](../plugins/plugin-map.mdx) - -Map visualization with markers. - -- Interactive maps -- Location markers -- ObjectQL integration -- Lazy-loaded (~60 KB) - -[Read full documentation →](../plugins/plugin-map.mdx) - ---- - -### Data Management - -#### [@object-ui/plugin-grid](../plugins/plugin-grid.mdx) - -Advanced data grid with sorting, filtering, and pagination. - -- Column sorting and filtering -- Pagination controls -- Row selection -- Lazy-loaded (~45 KB) - -[Read full documentation →](../plugins/plugin-grid.mdx) - ---- - -#### [@object-ui/plugin-form](../plugins/plugin-form.mdx) - -Advanced form builder with validation. - -- Multi-step forms -- Field validation -- Custom field types -- Lazy-loaded (~28 KB) - -[Read full documentation →](../plugins/plugin-form.mdx) - ---- - -#### [@object-ui/plugin-view](../plugins/plugin-view.mdx) - -ObjectQL-integrated views for automatic CRUD. - -- Auto-generated forms and grids -- CRUD operations -- Field mapping -- Lazy-loaded (~35 KB) - -[Read full documentation →](../plugins/plugin-view.mdx) - ---- - -### Content & Editing - -#### [@object-ui/plugin-editor](../plugins/plugin-editor.mdx) - -Code editor component powered by Monaco Editor. - -- Syntax highlighting for 100+ languages -- IntelliSense and code completion -- Multiple themes -- Lazy-loaded (~120 KB) - -[Read full documentation →](../plugins/plugin-editor.mdx) - ---- - -#### [@object-ui/plugin-markdown](../plugins/plugin-markdown.mdx) - -Markdown renderer with GitHub Flavored Markdown support. - -- GitHub Flavored Markdown -- XSS protection -- Code syntax highlighting -- Lazy-loaded (~30 KB) - -[Read full documentation →](../plugins/plugin-markdown.mdx) - ---- - -#### [@object-ui/plugin-chatbot](../plugins/plugin-chatbot.mdx) - -Chat interface component. - -- Message history -- User and assistant roles -- Timestamps and avatars -- Responsive floating panel for console assistants -- Inline responding, stop, and retry states -- Lazy-loaded (~35 KB) - -[Read full documentation →](../plugins/plugin-chatbot.mdx) - ---- - -### Workflows & Tasks - -#### [@object-ui/plugin-kanban](../plugins/plugin-kanban.mdx) - -Kanban board component with drag-and-drop powered by @dnd-kit. - -- Drag and drop cards between columns -- Column limits (WIP limits) -- Card badges for status/priority -- Lazy-loaded (~100 KB) - -[Read full documentation →](../plugins/plugin-kanban.mdx) - ---- - -## How Plugins Work - -### Stylesheets - -A plugin's JavaScript is only half of what it renders with. `@object-ui/plugin-grid` and `@object-ui/plugin-kanban` publish a `style.css` of their own, and an app that installs one must import it after the base sheets: - -```css -/* src/index.css */ -@import "tailwindcss"; -@import "@object-ui/components/style.css"; -@import "@object-ui/fields/style.css"; -@import "@object-ui/plugin-grid/style.css"; -@import "@object-ui/plugin-kanban/style.css"; -``` - -Each plugin sheet is compiled against the components theme and then has every rule that sheet already ships subtracted from it, so it carries only what the plugin adds. That includes the themed utilities (`bg-muted/10`, `bg-card/60`, `ring-primary/40`) which **no consumer-side configuration can produce** — the `@theme` block declaring their tokens lives in package source that is not published, so scanning `node_modules` cannot reach it ([#4929](https://github.com/objectstack-ai/objectui/issues/4929)). Skip the import and the view renders unstyled. - -Add a line only for the plugins you install. The other `@object-ui/plugin-*` packages do not publish a stylesheet yet; the build step above is the pattern each of them will adopt when it needs one. - -### Lazy Loading Architecture - -Plugins use React's `lazy()` and `Suspense` to load heavy dependencies on-demand: - - -```typescript -// The plugin structure -import React, { Suspense } from 'react' -import { Skeleton } from '@object-ui/components' - -// Lazy load the heavy implementation -const LazyEditor = React.lazy(() => import('./MonacoImpl')) - -export const CodeEditorRenderer = (props) => ( - }> - - -) -``` - -**Benefits:** -- **Smaller initial bundle**: Main app loads faster -- **Progressive loading**: Components load when needed -- **Better UX**: Loading skeletons while chunks download -- **Automatic code splitting**: Vite handles chunking - -### Bundle Impact - -| Plugin | Initial Load | Lazy Load | Description | -|--------|-------------|-----------|-------------| -| plugin-editor | ~0.2 KB | ~120 KB | Monaco editor | -| plugin-charts | ~0.2 KB | ~80 KB | Recharts visualization | -| plugin-kanban | ~0.2 KB | ~100 KB | Drag-and-drop board | -| plugin-markdown | ~0.2 KB | ~30 KB | Markdown rendering | -| plugin-dashboard | ~0.2 KB | ~22 KB | Dashboard layouts | -| plugin-form | ~0.2 KB | ~28 KB | Form builder | -| plugin-grid | ~0.2 KB | ~45 KB | Data grid | -| plugin-view | ~0.2 KB | ~35 KB | ObjectQL views | -| plugin-timeline | ~0.2 KB | ~20 KB | Timeline layouts | -| plugin-chatbot | ~0.2 KB | ~35 KB | Chat interface | -| plugin-calendar | ~0.2 KB | ~25 KB | Calendar views | -| plugin-gantt | ~0.2 KB | ~40 KB | Gantt charts | -| plugin-map | ~0.2 KB | ~60 KB | Map visualization | - -Without lazy loading, all this code would be in your main bundle! - -### Auto-Registration - -Plugins automatically register their components when imported: - - -```typescript -// In the plugin's index.tsx -import { ComponentRegistry } from '@object-ui/core' - -ComponentRegistry.register('code-editor', CodeEditorRenderer) -``` - -You just need to import the plugin once: - -```typescript -// In your App.tsx or main.tsx -import '@object-ui/plugin-editor' -import '@object-ui/plugin-charts' -import '@object-ui/plugin-kanban' -import '@object-ui/plugin-markdown' -import '@object-ui/plugin-dashboard' -import '@object-ui/plugin-form' -import '@object-ui/plugin-grid' -// ... import other plugins as needed -``` - -Now all plugin components are available in your schemas! - -## Creating Custom Plugins - -You can create your own plugins following the same pattern: - -### 1. Create Package Structure - -```bash -mkdir -p packages/plugin-myfeature/src -cd packages/plugin-myfeature -``` - -### 2. Create Heavy Implementation - - -```typescript -// src/MyFeatureImpl.tsx -import HeavyLibrary from 'heavy-library' - -export default function MyFeatureImpl(props) { - return -} -``` - -### 3. Create Lazy Wrapper - - -```typescript -// src/index.tsx -import React, { Suspense } from 'react' -import { ComponentRegistry } from '@object-ui/core' -import { Skeleton } from '@object-ui/components' - -// Lazy load implementation -const LazyFeature = React.lazy(() => import('./MyFeatureImpl')) - -// Create renderer with Suspense -export const MyFeatureRenderer = (props) => ( - }> - - -) - -// Auto-register -ComponentRegistry.register('my-feature', MyFeatureRenderer) - -// Export for manual use -export const myFeatureComponents = { - 'my-feature': MyFeatureRenderer -} -``` - -### 4. Add TypeScript Types - -```typescript -// src/types.ts -import type { BaseSchema } from '@object-ui/types' - -export interface MyFeatureSchema extends BaseSchema { - type: 'my-feature' - customProp?: string -} -``` - -### 5. Configure Build - - -```typescript -// vite.config.ts -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' -import { resolve } from 'path' - -export default defineConfig({ - plugins: [react()], - build: { - lib: { - entry: resolve(__dirname, 'src/index.tsx'), - name: 'ObjectUIPluginMyFeature', - fileName: (format) => `index.${format}.js` - }, - rollupOptions: { - external: [ - 'react', - 'react-dom', - '@object-ui/components', - '@object-ui/core' - ], - output: { - globals: { - react: 'React', - 'react-dom': 'ReactDOM' - } - } - } - } -}) -``` - -### 6. Add Package.json - -```json -{ - "name": "@object-ui/plugin-myfeature", - "version": "1.0.0", - "type": "module", - "main": "./dist/index.umd.js", - "module": "./dist/index.es.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "import": "./dist/index.es.js", - "require": "./dist/index.umd.js", - "types": "./dist/index.d.ts" - } - }, - "files": ["dist"], - "scripts": { - "build": "vite build && tsc --emitDeclarationOnly" - }, - "peerDependencies": { - "react": "^19.0.0", - "react-dom": "^19.0.0" - }, - "dependencies": { - "heavy-library": "^1.0.0" - }, - "devDependencies": { - "@object-ui/components": "workspace:*", - "@object-ui/core": "workspace:*", - "@object-ui/types": "workspace:*", - "@vitejs/plugin-react": "^6.0.5", - "typescript": "^6.0.3", - "vite": "^8.2.1" - } -} -``` - -## Best Practices - -### 1. Keep Entry Point Light - -The main index file should only contain: -- Lazy loading wrapper -- Component registration -- Type exports - -Heavy imports go in the `*Impl.tsx` file. - -### 2. Provide Good Loading States - -Always show a meaningful skeleton while loading: - - -```typescript - -}> - - -``` - -### 3. Export Types - -Make your plugin type-safe: - - -```typescript -export type { MyFeatureSchema } from './types' -``` - -### 4. Document Your Plugin - -Include a README with: -- Installation instructions -- Usage examples -- Schema API reference -- Bundle size information - -### 5. Test Lazy Loading - -Verify that: -- The main bundle is small (~200 bytes) -- The lazy chunk is separate -- Components load correctly when rendered - -```bash -pnpm build -ls -lh dist/ -``` - -## Plugin vs Component Package - -**Use a Plugin when:** -- The component depends on large libraries (>50 KB) -- Not all apps will use this component -- You want on-demand loading - -**Use regular Components when:** -- The component is lightweight -- Most apps will use it -- It's part of core functionality - -## Troubleshooting - -### Plugin not loading - -Check that you imported it in your app: - - -```typescript -import '@object-ui/plugin-myfeature' -``` - -### TypeScript errors - -Make sure types are exported: - - -```typescript -export type { MyFeatureSchema } from '@object-ui/plugin-myfeature' -``` - -### Bundle size too large - -Check that the implementation is in a separate file: - -``` -✅ src/index.tsx (light, uses React.lazy) -✅ src/MyFeatureImpl.tsx (heavy, imported lazily) -``` - -### Component not registering - -Check that ComponentRegistry.register() is called at the module level: - - -```typescript -// ✅ Good - runs on import -ComponentRegistry.register('my-feature', MyFeatureRenderer) - -// ❌ Bad - never runs -export function registerComponents() { - ComponentRegistry.register('my-feature', MyFeatureRenderer) -} -``` - -## Related Documentation - -- [Component Registry](./component-registry.md) - Understanding the registry -- [Schema Rendering](./schema-rendering.md) - How schemas become UI -- [Custom Plugin Development](/docs/guide/plugin-development) - Component development -- **[Create Plugin Utility](/docs/utilities/create-plugin)** - Scaffold new plugins quickly -- **[CLI Tool](/docs/utilities/cli)** - Test plugins with the CLI -- **[All Utilities](/docs/utilities)** - Complete toolkit for development - -## Next Steps - -1. Install official plugins you need -2. Try creating a custom plugin -3. Share your plugins with the community -4. Contribute new plugins to Object UI diff --git a/ui/content/docs/guide/print-pages.md b/ui/content/docs/guide/print-pages.md deleted file mode 100644 index f771bad9070..00000000000 --- a/ui/content/docs/guide/print-pages.md +++ /dev/null @@ -1,105 +0,0 @@ ---- -title: Print Pages -description: A page that declares `print` is a printable document — an invoice, a delivery order, a letter — printed through the browser's print preview. ---- - -# Print Pages - -A printable document is an ordinary page with one more key, `print`. There is -no separate template type: the page's own blocks are the document body, and its -own `header` and `footer` regions are the letterhead and the footer -(`@objectstack/spec`, ruling B′ on objectstack#8346). - -```json -{ - "name": "invoice_document", - "label": "Invoice", - "type": "record", - "object": "invoice", - "print": { - "paperSize": "A4", - "orientation": "portrait", - "margins": { "top": 18, "bottom": 18, "left": 15, "right": 15 }, - "repeatHeader": true, - "pageNumbers": true, - "avoidBreakInside": true - }, - "regions": [ - { "name": "header", "components": [{ "type": "element:text", "properties": { "content": "ACME Ltd" } }] }, - { - "name": "main", - "components": [ - { "type": "record:details" }, - { - "type": "record:line_items", - "properties": { "childObject": "invoice_line", "relationshipField": "invoice", "columns": [{ "name": "qty" }], "readonly": true } - } - ] - }, - { "name": "footer", "components": [{ "type": "element:text", "properties": { "content": "Thank you for your business" } }] } - ] -} -``` - -`print` is accepted on a `kind: 'full'` page of `type: 'record'`, `'home'` or -`'app'` with its blocks in `regions`. Every block in it must come from the -spec's printable block subset (`PRINTABLE_PAGE_COMPONENT_TYPES` in -`@objectstack/spec/ui`); `os validate`, `os build` and the metadata save door -refuse any other block inside a print page. - -## What each key does on paper - -`PageRenderer` mounts a print stylesheet for a page that declares `print`, and -only for it. Every rule sits inside `@media print`, so the page on screen is -drawn exactly as before — the console's page is the preview. - -| Key | Print CSS | -| --- | --- | -| `paperSize`, `orientation` | `@page { size }` — A4 and portrait when omitted | -| `margins` | `@page` margin, one side per key, in millimetres; a side left out keeps the console default | -| `pageNumbers` | "page / pages" in the bottom-right margin box | -| `repeatHeader`, `repeatFooter` | the page's `header` / `footer` region repeated on every sheet | -| `repeatTableHeaders` | a table's column headings repeated on every sheet it runs onto — on when omitted | -| `avoidBreakInside` | `break-inside: avoid` on each block of a region | - -Only the print page reaches paper: the console chrome and the record view's own -panels around the page are hidden, and the page runs onto as many sheets as it -needs. - -## Blocks print whole - -- `record:line_items` loads at most its row cap (500 unless `limit` is set). On - a print page, when the child object has more lines than that, the panel draws - the cap's lines and a note under them naming the cap and, when the server - reports one, the total — a printed copy never drops lines in silence. -- A collapsible `record:details` section draws open on a print page. -- A `record:details` section prints every declared field, empty ones included, - with no "Show N empty fields" row. A section `hideEmpty` hides still prints - nothing. - -A block reads whether it is on a print page with `usePrintPage()` from -`@object-ui/react`, which returns the page's `print` declaration or `null`. - -## The record page's Print action - -When the record page bound to a record declares `print`, the record view shows -a **Print** button that opens the browser's print preview of that page. Saving -a PDF is the browser's own "Save as PDF" destination. - -## Printing a page that is not the default - -The record route, `/apps/APP/OBJECT/record/ID`, shows a record on its object's -default record page. Any other record page of the object renders for the same -record at: - -``` -/apps/APP/OBJECT/record/ID/page/PAGE -``` - -So an invoice keeps its record view as the default page, and its print page -(`invoice_document` above) opens at `…/invoice/record/ID/page/invoice_document`, -with the same print stylesheet, print root and Print action. PAGE must be a -record page bound to OBJECT (`type: 'record'`, `object: OBJECT`). Any other -name, a page that is not a record page or a page of another object, shows -"Page not found": the console never falls back to the default page, so a print -of that URL never puts a different document on paper. diff --git a/ui/content/docs/guide/public-forms.md b/ui/content/docs/guide/public-forms.md deleted file mode 100644 index 5cfdce67172..00000000000 --- a/ui/content/docs/guide/public-forms.md +++ /dev/null @@ -1,289 +0,0 @@ ---- -title: "Public Forms" -description: "Embed hardened public-facing forms — contact, lead capture, signup — with built-in anti-spam, GDPR consent, prefill whitelist, and open-redirect protection." ---- - -# Public Forms - -`@object-ui/plugin-form` ships an `EmbeddableForm` component that renders a -public-facing form (contact us, lead capture, signup, RSVP) with the security -defaults you'd expect from Airtable Forms, Typeform or HubSpot Forms — without -asking app authors to bolt them on themselves. - -The console exposes a turnkey route at `/f/:slug` (`FormPage`) that loads -a `FormView` spec from the server's `GET /api/v1/forms/:slug` resolver and -renders the merged form. The same component also serves authed internal -forms at `/forms/:name` (`mode="internal"`), reading the FormView spec -from `/api/v1/meta/view/:name` and posting to `/api/v1/data/:object`. - -## Quick start - -```tsx -import { EmbeddableForm } from '@object-ui/plugin-form'; -import { createObjectStackAdapter } from '@object-ui/data-objectstack'; - -const dataSource = createObjectStackAdapter({ baseUrl: 'https://api.example.com' }); - - -``` - -## Security defaults (at a glance) - -| Defence | Default | Config key | -| --- | --- | --- | -| **Honeypot field** (silent fake-success on bots) | On | `honeypot` (string to rename, `false` to disable) | -| **Min-fill-time guard** | 1500 ms | `minFillTime` (ms, `0` to disable) | -| **URL prefill whitelist** | _none_ — prefill is fully off | `allowedPrefillFields: string[]` | -| **Open-redirect guard** | Same-origin only | `allowedRedirectHosts: string[]` (supports `*.example.com`) | -| **Default `maxLength`** | text 200 · email 254 · url 2048 · phone 32 · textarea/markdown/html 5000 | Per-field `maxLength` overrides | -| **GDPR consent gate** | Off (opt-in) | `consent: { required, label }` + `privacyPolicyUrl` | -| **CAPTCHA token** | Off (opt-in) | `captchaToken` (string sent as `_captcha`) | -| **Demo mode (`?demo=1`)** | DEV only | gated by `import.meta.env.DEV` in `EmbeddableForm` consumers | - -All gates run **before** the network call. If the consent gate or min-fill -timer trips, the backend is never contacted. The honeypot silently shows the -thank-you page so bots can't tell they failed. - -## Configuration reference - -```ts -import type { FormField } from '@object-ui/types'; -import type { EmbeddableFormTexts } from '@object-ui/plugin-form'; - -interface EmbeddableFormConfig { - formId: string; - objectName: string; - title?: string; - description?: string; - - // Fields — either from a registered schema or inline - fields?: string[]; - customFields?: FormField[]; - - // Anti-spam - honeypot?: string | false; // field name (default '_company_website_2'), false disables - minFillTime?: number; // ms before submit allowed (default 1500) - captchaToken?: string; // forwarded as payload._captcha - - // Prefill & redirect hardening - allowedPrefillFields?: string[]; // empty/undefined → no URL prefill - allowedRedirectHosts?: string[]; // supports '*.example.com' - - // GDPR - consent?: { required?: boolean; label?: string }; - privacyPolicyUrl?: string; - - // UI - branding?: { logo?: string; primaryColor?: string; coverImage?: string }; - thankYouPage?: { title?: string; message?: string; redirectUrl?: string; redirectDelay?: number }; - texts?: EmbeddableFormTexts; // i18n-friendly string overrides -} -``` - -### URL prefill (safe-by-default) - -Public form URLs are user-controlled, so prefill is **off** unless you -explicitly opt fields in: - - - -```tsx - -``` - -With the snippet above, visiting `/f/contact?email=alice@x.com&secret=foo` -fills the `email` field and silently ignores `secret`. The -`prefillParams` prop (used by trusted hosts such as the console) -bypasses this whitelist. - -### Open-redirect guard - -`thankYouPage.redirectUrl` is validated against the current origin **plus** -`allowedRedirectHosts` before navigation. Wildcards like `*.example.com` -match subdomains; the apex itself must be listed explicitly. Dangerous -schemes (`javascript:`, `data:`) are always rejected. - -The thank-you panel follows that verdict rather than the declaration: the -`Redirecting in N seconds…` line appears only for a destination that was -**accepted**, and reads its countdown from the delay that destination was -accepted with. When the destination is refused the line is omitted, and -`texts.redirectBlocked` — if you declared it — is shown in the panel instead; -leave it undeclared and the panel stays silent. Either way the refusal is -logged with `console.warn`, which is where to look if a redirect you expected -never happens. - -### Who performs the redirect (mounted hosts) - -The guard decides **whether** a destination is followed; who travels to it -depends on the shape of the destination: - -| Destination | Travelled by | -|---|---| -| App-relative — `/thanks`, `thanks`, `?ok=1`, `#done` | the host's navigate, when one is supplied; otherwise a browser navigation | -| External — a host you listed in `allowedRedirectHosts` | a browser navigation, always | -| Same-origin but **absolute** — `https://your.app/thanks` | a browser navigation, always | - -This matters when your application is mounted at a sub-path (the console runs -at basename `/_console`). A browser navigation resolves `/thanks` against the -**origin root**, which leaves the application — so an in-app thank-you page -needs the host to place the path. Supply one with `HostNavigationProvider` -from `@object-ui/react` and the form hands app-relative destinations to it: - -```tsx -import { HostNavigationProvider } from '@object-ui/react'; -import { EmbeddableForm, type EmbeddableFormConfig } from '@object-ui/plugin-form'; -import type { DataSource } from '@object-ui/types'; - -export function MountedPublicForm(props: { - /** Your own router's navigate — it already knows the basename. */ - navigate: (to: string) => void; - config: EmbeddableFormConfig; - dataSource: DataSource; -}) { - return ( - - - - ); -} -``` - -Supplying it is optional and changes nothing for an unmounted host: with no -provider — or with no router at all, where there is no basename to miss — -every destination keeps the browser navigation it always had. - -An external destination is never handed to your navigate. A host navigate is a -client-side router transition, so it takes an application-relative path, and a -form holding a cross-origin address must not route it through your router. A -same-origin **absolute** URL is treated the same way for a different reason: -you spelled out the whole address, so that is the address the submitter gets — -write the destination relatively if you want it placed inside your mount. - -### GDPR consent - - - -```tsx -consent: { required: true, label: 'I agree to the privacy policy.' } -privacyPolicyUrl: '/legal/privacy' -``` - -When `required: true`, submitting before the box is ticked shows -`texts.consentRequired` and the network call is suppressed. - -### Honeypot - -The hidden input is rendered off-screen with `tabIndex={-1}` and -`autocomplete="off"`. Bots that blindly fill every field trigger a -**silent fake-success** — the visitor sees the thank-you screen, but -`dataSource.create()` is never called. Honeypot data is also stripped -from the payload defensively. - -### Min-fill-time - -Genuine users take more than ~1.5 s to read and fill a form. Faster -submissions are soft-rejected with `texts.rateLimited` rather than a -hard error, so legit speed-typers can simply retry. - -### CAPTCHA hook - -`EmbeddableForm` doesn't bundle a specific provider. Mount your -preferred widget (hCaptcha, Turnstile, reCAPTCHA) and feed the token in: - - - -```tsx -const [token, setToken] = useState(); - -``` - -The token is forwarded as `payload._captcha` for server-side validation. - -## i18n - -`EmbeddableForm` is i18n-agnostic — every user-visible string is -overridable via `config.texts: EmbeddableFormTexts`. The console wires -this up through `@object-ui/i18n`: - - - -```tsx -const { t } = useObjectTranslation(); - -``` - -## Console route — `/f/:slug` and `/forms/:name` - -The console's `FormPage` component renders both modes from the same -spec-merging code path: - -- **`/f/:slug`** (public, anonymous) — loads `GET /api/v1/forms/:slug` which - resolves the `FormView` whose `sharing.publicLink` matches the slug, then - submits to `POST /api/v1/forms/:slug/submit`. -- **`/forms/:name`** (internal, authed) — loads `GET /api/v1/meta/view/:name` - for the FormView spec plus `GET /api/v1/meta/object/:object` for field - metadata, then submits to `POST /api/v1/data/:object` with the - authenticated session cookie. -- **`/apps/:appName/forms/:name`** (internal, authed) — the same form inside - the app the URL names, with that app's sidebar and branding. A - `type: 'form'` action opens here when it is fired from an app route (a - record page, an object list or a page). The app-less `/forms/:name` serves - every other link, cold deep links included. - -URL parameters of the form `?prefill_=` populate the matching -fields on mount; the rest of the chrome (label, section columns, post-submit -behaviour) comes from the `FormView` spec itself. The heading is the view's -label or the form's `title`; a form with neither is headed by its object's -label, and the object's API name is used only when no label is available at -all. The internal form also has a Cancel, which goes back to the page the form -was opened from, or, on a cold link, to the record being edited, else to the -app's home (Home on the app-less route). - -For richer public forms with anti-spam, GDPR consent, prefill whitelisting -and open-redirect protection, host `EmbeddableForm` directly inside your own -route — see the Quick start above. - -## Testing - -Pure helpers (`isRedirectUrlSafe`, `applyDefaultMaxLengths`) are exported -from `@object-ui/plugin-form` for unit testing. See -`packages/plugin-form/src/__tests__/EmbeddableForm.test.tsx` for the -reference test suite covering all gates. diff --git a/ui/content/docs/guide/quick-start.md b/ui/content/docs/guide/quick-start.md deleted file mode 100644 index 1d843026887..00000000000 --- a/ui/content/docs/guide/quick-start.md +++ /dev/null @@ -1,333 +0,0 @@ ---- -title: "Quick Start" -description: "Get up and running with ObjectUI in 5 minutes - install, configure, and render your first server-driven UI" ---- - -# Quick Start - -Get up and running with ObjectUI in a small Vite app. This guide installs the core renderer, registers the built-in component packages, and renders a first JSON schema. - -## Prerequisites - -- **Node.js** and **pnpm** (or npm/yarn) — ObjectUI is tested on Node 22.x with pnpm 10.x. -- Basic knowledge of **React** and **TypeScript** - -## Step 1: Create a React Project - -If you don't have an existing React project, create one with Vite: - -```bash -pnpm create vite my-app --template react-ts -cd my-app -``` - -## Step 2: Install ObjectUI - -Install the core ObjectUI packages: - -```bash -pnpm add @object-ui/react @object-ui/core @object-ui/types @object-ui/components @object-ui/fields -``` - -Install Tailwind CSS for styling: - -```bash -pnpm add -D tailwindcss @tailwindcss/vite -``` - -## Step 3: Configure Tailwind CSS - -Add Tailwind to your `vite.config.ts`: - - -```ts -import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react'; -import tailwindcss from '@tailwindcss/vite'; - -export default defineConfig({ - plugins: [react(), tailwindcss()], -}); -``` - -Add to your `src/index.css`: - -```css -@import "tailwindcss"; -@import "@object-ui/components/style.css"; -@import "@object-ui/fields/style.css"; -``` - -Each `style.css` is a stylesheet the package compiles from its own sources at build time, and between them they carry every utility ObjectUI renders with — the themed ones (`bg-primary`, `border-input`) included. - -**Import them in that order.** `@object-ui/components/style.css` is the complete sheet: Tailwind's base layer, the `@theme` tokens and the utilities its components use. `@object-ui/fields/style.css` is a small supplement on top of it — only the ~155 utilities the field widgets add and the components sheet does not already carry, which is why it is a few kB rather than another 170. It is not a standalone stylesheet, and on its own it will not style anything. - -**Plugin packages that publish a stylesheet need one line each.** `@object-ui/plugin-grid` and `@object-ui/plugin-kanban` ship the same kind of supplement, built the same way, so add whichever of them you install: - -```css -@import "@object-ui/plugin-grid/style.css"; -@import "@object-ui/plugin-kanban/style.css"; -``` - -Without that line the plugin renders with no themed styling at all — its `bg-muted/10`, `bg-card/60` and `text-muted-foreground/60` have no other source in a published app, because the `@theme` block they resolve lives in package source that is never published ([#4929](https://github.com/objectstack-ai/objectui/issues/4929)). The remaining `@object-ui/plugin-*` packages ship no stylesheet yet; importing one that does not exist breaks the build, so add only the lines above. - -That is the whole styling setup: you do not add `@source` lines for the ObjectUI packages, and pointing Tailwind at them inside `node_modules` only regenerates utilities these imports already gave you. - -## Step 4: Render Your First Schema - -Replace `src/App.tsx` with: - -```tsx -import '@object-ui/components'; -import '@object-ui/fields'; -import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; -import type { CardSchema } from '@object-ui/types'; - -const schema: CardSchema = { - type: 'card', - title: 'Team Directory', - description: 'Rendered from JSON metadata', - className: 'mx-auto max-w-3xl', - children: { - type: 'data-table', - caption: 'Users', - columns: [ - { header: 'Name', accessorKey: 'name', sortable: true }, - { header: 'Email', accessorKey: 'email' }, - { header: 'Role', accessorKey: 'role' }, - ], - data: [ - { name: 'Ada Lovelace', email: 'ada@example.com', role: 'Admin' }, - { name: 'Grace Hopper', email: 'grace@example.com', role: 'Editor' }, - { name: 'Katherine Johnson', email: 'katherine@example.com', role: 'Viewer' }, - ], - pagination: false, - searchable: false, - }, -} as const; - -function App() { - return ( -
- - - -
- ); -} - -export default App; -``` - -Importing `@object-ui/components` and `@object-ui/fields` registers their renderers with the shared `ComponentRegistry`. `SchemaRendererProvider` injects the host's data adapter, and everything below it — expressions, smart fields, data-aware plugins — reads it back from there. - -This app renders inline `data`, so it has no adapter to inject and says so with `undefined`. That is a real state of the contract, not a placeholder: `dataSource` is typed `DataSource | null | undefined` (`@object-ui/types`), so a host either hands over an adapter or states that it has none. It used to be typed `any` and this example passed an empty object, which no renderer can do anything with (objectui#7912). - -## Step 5: Run the App - -```bash -pnpm dev -``` - -Open [http://localhost:5173](http://localhost:5173). You should see a card and data table rendered from JSON. - -## What Just Happened? - -1. **Schema** - the UI was described as JSON with `type`, visual props, and nested `children`. -2. **Registry** - importing the component packages registered renderers for `card` and `data-table`. -3. **Renderer** - `SchemaRenderer` resolved each `type` and rendered React components. -4. **Provider** - `SchemaRendererProvider` is where a host injects its `DataSource`; this app has none, so it passes `undefined`. - -## Expression Scope - -A `${…}` expression in a schema reads a root the host publishes. Publish those roots with -`PredicateScopeProvider`: - -### Basic Usage - -```tsx -import React from 'react' -import { PredicateScopeProvider, SchemaRenderer } from '@object-ui/react' -import type { DeclaredNode } from '@object-ui/types' -// Importing the package registers every default renderer as a side effect — -// there is no separate registration call. -import '@object-ui/components' - -const schema: DeclaredNode = { - type: "page", - // An app page draws `title` as its heading; a page with no `pageType` is a - // record page, which leaves the heading to a `page:header` block. - pageType: "app", - title: "Dashboard", - children: { - type: "grid", - columns: 3, - children: [ - { type: "statistic", label: "Total Users", value: "${stats.users}" }, - { type: "statistic", label: "Revenue", value: "${stats.revenue}" }, - { type: "statistic", label: "Orders", value: "${stats.orders}" } - ] - } -} - -function App() { - // 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 ( - - - - ) -} - -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. - -⛔ `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: publish the values your -`${…}` expressions read with `PredicateScopeProvider`, and inject the adapter your data -components query with `SchemaRendererProvider`. - -## Copy-Paste Schemas - -A `${name.…}` in any of these reads the root `name` off the scope the host published — see -[Expression Scope](#expression-scope) 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 -{ - "type": "form", - "fields": [ - { "name": "name", "type": "text", "label": "Full Name", "required": true }, - { "name": "email", "type": "email", "label": "Email", "required": true }, - { "name": "subject", "type": "select", "label": "Subject", "options": [ - { "label": "General Inquiry", "value": "general" }, - { "label": "Bug Report", "value": "bug" }, - { "label": "Feature Request", "value": "feature" } - ]}, - { "name": "message", "type": "textarea", "label": "Message", "required": true } - ], - "submitLabel": "Send Message" -} -``` - -### Data Grid - -```json -{ - "type": "object-grid", - "properties": { - "objectName": "user", - "title": "Users", - "columns": [ - { "field": "name", "label": "Name", "sortable": true }, - { "field": "email", "label": "Email" }, - { "field": "role", "label": "Role" }, - { "field": "status", "label": "Status" }, - { "field": "created_at", "label": "Joined" } - ], - "showSearch": true, - "operations": { "create": true, "update": true, "delete": true, "export": true } - } -} -``` - -### Dashboard - -A row of live KPI tiles. A `dashboard` node's `widgets[]` takes `metric-card` entries and -dataset-bound charts instead; see [Dashboard Plugin](/docs/plugins/plugin-dashboard). - -```json -{ - "type": "grid", - "columns": 4, - "children": [ - { "type": "statistic", "label": "Revenue", "value": "${stats.revenue}", "trend": "up", "description": "+12%" }, - { "type": "statistic", "label": "Orders", "value": "${stats.orders}", "trend": "up", "description": "+8%" }, - { "type": "statistic", "label": "Customers", "value": "${stats.customers}", "trend": "up", "description": "+5%" }, - { "type": "statistic", "label": "Conversion", "value": "${stats.conversion}", "trend": "down", "description": "-2%" } - ] -} -``` - -### Kanban Board - -```json -{ - "type": "object-kanban", - "objectName": "tasks", - "groupBy": "status", - "cardTitle": "title", - "cardFields": ["assignee", "priority", "due_date"], - "columns": [ - { "id": "todo", "title": "To Do", "className": "border-t-2 border-indigo-500" }, - { "id": "in_progress", "title": "In Progress", "className": "border-t-2 border-amber-500" }, - { "id": "review", "title": "In Review", "className": "border-t-2 border-blue-500" }, - { "id": "done", "title": "Done", "className": "border-t-2 border-green-500" } - ] -} -``` - -More schemas: [`ui/examples/schema-catalog`](https://github.com/objectstack-ai/objectstack/tree/main/ui/examples/schema-catalog) -is the canonical catalog — the JSON schemas the docs render, a smoke test mounts, and AI agents -use as a few-shot corpus. - -## Next Steps - -### Add Actions - -Actions are data, not inline functions. Declare one as an `action:button` -node whose `properties` bag carries the block's props, as `@objectstack/spec` -declares them: `actionType` names the built-in executor the action runner -dispatches to, and the bag's other keys carry that executor's arguments — -`target` is the location a `url` action navigates to. Written flat on the node, -`actionType` and `target` are refused by the spec's strict page component: - -```json -{ - "type": "action:button", - "properties": { - "label": "Open details", - "actionType": "url", - "target": "/users/ada" - } -} -``` - -Learn the full action model in [Enhanced Actions](/docs/core/enhanced-actions). - -### Connect a Data Source - -```bash -pnpm add @object-ui/data-objectstack -``` - -```tsx -import { createObjectStackAdapter } from '@object-ui/data-objectstack'; - -const dataSource = createObjectStackAdapter({ - baseUrl: 'https://api.example.com' -}); -``` - -Pass the adapter to `SchemaRendererProvider` and let data-aware renderers call the `DataSource` interface. See [Data Connectivity](/docs/guide/data-source). - -### Learn More - -- [Architecture Overview](/docs/guide/architecture) — Understand how ObjectUI works -- [Schema Rendering](/docs/guide/schema-rendering) — Deep dive into schema rendering -- [Component Registry](/docs/guide/component-registry) — Customize and extend components -- [Plugins](/docs/guide/plugins) — Add views like Grid, Kanban, Charts -- [Fields Guide](/docs/guide/fields) — Field widgets and cell renderers diff --git a/ui/content/docs/guide/react-pages.md b/ui/content/docs/guide/react-pages.md deleted file mode 100644 index c6de06996f9..00000000000 --- a/ui/content/docs/guide/react-pages.md +++ /dev/null @@ -1,413 +0,0 @@ ---- -title: React Pages -description: Author a page body as real React (kind:'react') or as constrained JSX that is parsed and never executed (kind:'html') — and how to choose between them. ---- - -# React Pages - -Most pages in ObjectUI are a **schema tree** — `regions[].components[]` of JSON -nodes. Two page kinds let you write the body as **source** instead, for layouts -that are awkward to express as nested JSON: - -| `kind` | Source is | Executed? | Author trust | -|---|---|---|---| -| `"html"` | Constrained JSX/HTML | **No** — parsed into a schema tree | Untrusted OK | -| `"react"` | Real React (hooks, handlers, arbitrary JS) | **Yes** — in the main React tree | Trusted only | - -Both set `source` and leave `regions` unused. The former alias `"jsx"` was retired -in `@objectstack/spec` 18 and is refused; author `"html"`. - -> Page `kind` also carries the record-page override values `"full"` (default) -> and `"slotted"` — a different axis, covered in [Slotted Pages](./slotted-pages.md). - -## Choosing between them - -Reach for **`kind:'html'`** by default. It is parsed, whitelisted against the -public block manifest, and never executed, so it is safe for AI-generated and -customer-authored pages. It covers layout, blocks, and styling — styling through -the blocks' own structured props plus a JSON `style` object, **not** Tailwind -(see *Styling*, below; the rule holds on both tiers). - -Reach for **`kind:'react'`** only when you need real behaviour the schema tree -cannot express — local state, computed lists, event handlers wiring one block to -another, custom data fetching. It runs **without a sandbox**. - -## `kind:'react'` - -```json -{ - "type": "home", - "name": "project_console", - "kind": "react", - "source": "function Page() {\n const [selected, setSelected] = React.useState(null);\n return (\n
\n setSelected(r._id)} />\n {selected && }\n
\n );\n}" -} -``` - -Written out, that `source` is: - -```jsx -function Page() { - const [selected, setSelected] = React.useState(null); - return ( -
- setSelected(r._id)} - /> - {selected && } -
- ); -} -``` - -### The security gate - -A react page's source is transpiled and evaluated directly in the application — -no isolation, full access to the page's React tree. So the host capability -`react-pages` defaults **OFF**, and a deployment turns it on only when its page -authors are trusted and reviewed. - -On ObjectStack the operator turns it on server-side with -`OS_PAGE_REACT_ENABLED=true`; the server then writes the enable global -(`window.__OBJECTUI_CAPABILITIES__`) into the Console document, and with the -tier off it writes the disable global instead. A host that embeds the renderer -itself calls `enableCapability('react-pages')` before rendering. While the -capability is off, a react page renders a notice that names the switch instead -of executing. `kind:'html'` pages are unaffected either way. - -### What is in scope - -Nothing is imported. These identifiers are injected as closure variables: - -| In scope | What it is | -|---|---| -| `React` | The host's React — call hooks with it (`React.useState`). | -| The public data blocks | Every public non-container block, as a PascalCase tag *on this tier* — but *what resolves* and *what you author against* are two different sets, below. | -| `Block` | Escape hatch for anything not injected. | -| `useAdapter` | The live data source — query/create/update. | -| `useDataInvalidation` | The data-invalidation bus reader: `useDataInvalidation('object')` returns a number that moves when a write to that object is reported. Name it in the dependencies of an effect that reads through `useAdapter` — see *Live data*, below. | -| `data`, `variables`, `page` | The page's own data, local variables, and schema. | - -#### Two tiers: what resolves, and what you author against - -**The runtime scope** is every block in the curated public contract -(`PUBLIC_BLOCKS`) that is not a layout container. **On this tier** tags are -derived by splitting the registry type on `-`, `_` and `:` and PascalCasing each -part: `object-grid` → ``, `record:details` → ``. A -`kind:'html'` page writes the registry type itself instead — ``, -``. Blocks registered lazily are in scope too — you never wait -on a plugin chunk to reference one. The intrinsic HTML elements the `html` tier -declares (`h1`, `p`, `a`, the sectioning tags, … — `HTML_TIER_INTRINSICS`, carried -in `sdui.manifest.json` as `tier: 'html'`) are **not** injected here: on this tier -a lowercase `

` is React's own element, and no `

` wrapper exists. - -**The authored contract** is the much smaller set that has *published props* — -checked by `os validate` and generated into the reference an author, human or AI, -writes against: **``, ``, ``, ``**. -That set is `REACT_BLOCKS` in `@objectstack/spec`, and the generated per-prop -table is `skills/objectstack-ui/references/react-blocks.md` in the framework -repo. **Treat that table as the prop authority, not this page.** - -Everything in the runtime scope but outside the contract still resolves and -renders — its props simply are not part of the react-tier contract. Reach those -through the contract instead: a kanban / calendar / gantt / timeline / map of an -object is ``, or ``. - -#### The `record:*` family is excluded from this tier - -The tag derivation above is real — `` and `` -*are* defined in the scope — but every `record:*` block reads its record from the -record context a **record page** mounts, and a `kind:'react'` page never mounts -one. The block renders empty however you bind it: its `objectName`/`recordId` are -not read by the renderer. `os validate` rejects them at publish time: - -``` - ✗ Author-time rules failed (1 issue) - • page "showcase_renewals_pipeline" › RecordHighlights: RecordHighlights renders - "record:highlights", which reads its record from the record context a record page - mounts — a kind:'react' page never mounts one, so the block renders empty no matter - how it is bound (its objectName/recordId are not read by the renderer). - rule: react-block-needs-record-context -``` - -The rule matches by **type**, so `` is rejected -the same way. On a react page, bind the record yourself: - -| Instead of | Write | -|---|---| -| `` | `` — it binds by its own props. | -| `` | ``, or read the record with `useAdapter().findOne` and lay the strip out in JSX. | -| `` | ``, ``, -`` and friends have no injected wrapper. In react mode you compose layout -with real HTML, which React is better at than a schema-children renderer — styled -inline, not with Tailwind: `

`. - -`isContainer` means exactly this: *layout containment*, the reason a block is -kept out of the JSX scope. It does not mean "accepts children". Whether a block -renders an authored child list is declared on its registration as -`{ name: 'children', type: 'slot' }` in `inputs`, and that declaration is what -the html tier's `not-a-container` diagnostic reads — so `