Skip to content

feat(app-shell): CEL lint + field autocomplete for condition predicates (#1582)#2567

Merged
os-zhuang merged 1 commit into
mainfrom
claude/field-when-cel-editor
Jul 16, 2026
Merged

feat(app-shell): CEL lint + field autocomplete for condition predicates (#1582)#2567
os-zhuang merged 1 commit into
mainfrom
claude/field-when-cel-editor

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Summary

Closes #1582. That issue asks for a proper CEL editor — live validation + field-name autocomplete — on the three field-level *When inputs in Studio's field config form. This delivers it at the right altitude: ConditionBuilder's raw-expression escape hatch (a bare <textarea>) is replaced with the existing CelPredicateField, so every surface that authors a condition through the builder gets the assists, not just the field *When trio:

  • field-level visibleWhen / readonlyWhen / requiredWhen — SchemaForm's detectConditionWidget auto-maps /When$/ properties to the condition widget;
  • action visible / disabled (ActionDefaultInspector);
  • every other condition-widget property (visibleOn, predicate, …).

What changed

  • ConditionBuilder raw mode now renders CelPredicateField (inline lint + as-you-type autocomplete, lazily backed by @objectstack/formula's validateExpression / introspectScope — the same validators the server uses, per the issue's "reuse, don't reimplement" note). The no-code [subject][op][value] builder path is untouched.
  • Sibling-field catalog reaches the editor through the existing plumbing: ResourceEditPage already fetches objectFields for the record's bound object into widgetContextConditionWidgetConditionBuilder.fieldsfieldNames. No new fetches.
  • i18n: EN + ZH labels for the raw-mode strings (previously hardcoded English).

Acceptance mapping (#1582)

Tests

ConditionBuilder.test.tsx (4): raw mode renders the CEL editor and commits edits; a mocked engine lint error surfaces inline; field-name autocomplete offers the object's fields; the no-code builder path is unchanged for simple conditions. Full metadata-admin suite: 89 files / 696 tests green. turbo type-check @object-ui/app-shell green. No new lint errors.

Notes

🤖 Generated with Claude Code


Generated by Claude Code

…es (#1582)

ConditionBuilder's raw-expression escape hatch — a bare <textarea> — is
replaced by CelPredicateField, so every surface that authors a condition
through it gains inline syntax/semantic validation and field-name autocomplete
on the canonical @objectstack/formula engine:

- field-level visibleWhen / readonlyWhen / requiredWhen (SchemaForm's
  condition widget auto-maps /When$/ properties),
- action visible / disabled (ActionDefaultInspector),
- every other condition-widget property (visibleOn, predicate, ...).

The no-code [subject][op][value] builder path is unchanged; only the
"Expression" mode is upgraded. An invalid predicate now surfaces a readable
inline error instead of failing silently at runtime. The sibling-field catalog
reaches the editor via the existing widgetContext.objectFields plumbing
(ResourceEditPage fetches it for the record's bound object). EN + ZH labels.

Completes the objectui side of #1582.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LwrWThBJgvcfPtqNHpstB
@vercel

vercel Bot commented Jul 16, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Jul 16, 2026 5:10am

Request Review

@github-actions github-actions Bot added the tests label Jul 16, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 27.6 KB 350 KB
Entry file index-CfUy3pgY.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 8.17KB 2.95KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 7.57KB 2.97KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 21.70KB 4.21KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.12KB 3.41KB
auth (LoginForm.js) 17.12KB 5.00KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.63KB 2.15KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 30.17KB 7.28KB
auth (createAuthenticatedFetch.js) 3.93KB 1.55KB
auth (index.js) 1.83KB 0.79KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 4.86KB 0.85KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 18.38KB 4.49KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 3.65KB 1.42KB
collaboration (PresenceProvider.js) 2.42KB 0.96KB
collaboration (index.js) 1.25KB 0.53KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 444.81KB 96.19KB
core (index.js) 1.69KB 0.60KB
create-plugin (index.js) 9.28KB 2.98KB
data-objectstack (index.js) 113.70KB 28.15KB
fields (index.js) 203.90KB 49.85KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 2.46KB 0.96KB
i18n (pickLocalized.js) 1.70KB 0.83KB
i18n (provider.js) 5.37KB 1.72KB
i18n (useObjectLabel.js) 21.22KB 4.69KB
i18n (useSafeTranslation.js) 2.68KB 0.98KB
layout (index.js) 38.45KB 10.67KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.74KB
mobile (index.js) 1.50KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 4.42KB 1.27KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 1.77KB 0.77KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 5.90KB 2.15KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.46KB 1.03KB
permissions (evaluator.js) 4.00KB 1.23KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.50KB 0.70KB
plugin-ai (index.js) 15.71KB 3.79KB
plugin-calendar (index.js) 45.12KB 12.34KB
plugin-charts (index.js) 46.31KB 13.06KB
plugin-chatbot (index.js) 176.76KB 42.28KB
plugin-dashboard (index.js) 108.35KB 26.87KB
plugin-designer (index.js) 213.89KB 43.03KB
plugin-detail (index.js) 210.40KB 51.19KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 104.55KB 25.30KB
plugin-gantt (index.js) 149.17KB 37.17KB
plugin-grid (index.js) 172.00KB 45.43KB
plugin-kanban (index.js) 47.17KB 12.85KB
plugin-list (index.js) 97.92KB 23.14KB
plugin-map (index.js) 16.80KB 5.24KB
plugin-markdown (index.js) 13.65KB 4.67KB
plugin-report (index.js) 37.82KB 9.96KB
plugin-timeline (index.js) 25.37KB 7.20KB
plugin-tree (index.js) 8.36KB 2.81KB
plugin-view (index.js) 85.72KB 20.86KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.55KB 0.67KB
providers (UploadProvider.js) 11.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 3.19KB 1.38KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 18.70KB 6.09KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 0.76KB 0.42KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 2.16KB 0.94KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
tenant (TenantContext.js) 0.31KB 0.25KB
tenant (TenantGuard.js) 1.04KB 0.43KB
tenant (TenantProvider.js) 2.76KB 0.98KB
tenant (TenantScopedQuery.js) 0.77KB 0.44KB
tenant (index.js) 0.75KB 0.38KB
tenant (resolver.js) 2.64KB 0.76KB
tenant (useTenant.js) 0.50KB 0.32KB
tenant (useTenantBranding.js) 0.62KB 0.39KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 0.77KB 0.41KB
types (disclosure.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (index.js) 1.54KB 0.68KB
types (layout.js) 0.20KB 0.18KB
types (mobile.js) 0.20KB 0.18KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.26KB 1.96KB
types (tenant.js) 0.20KB 0.18KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 0.75KB 0.46KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-zhuang
os-zhuang marked this pull request as ready for review July 16, 2026 05:32
@os-zhuang
os-zhuang merged commit eeef906 into main Jul 16, 2026
10 checks passed
@os-zhuang
os-zhuang deleted the claude/field-when-cel-editor branch July 16, 2026 05:32
os-zhuang added a commit that referenced this pull request Jul 16, 2026
…e-2358-3b099c) (#2608)

* remove(react): #2545 drop unused FormRenderer/FieldFactory duplicate form path (#2560)

Phase 4 of #2545. FormRenderer (and its captive FieldFactory) was an
exported-but-dead second form render path with zero runtime consumers — the
only import in the whole repo was its own test file. It duplicated
plugin-form's ObjectForm (the path every app actually uses via the component
registry) as a degraded variant: raw-HTML/Tailwind instead of shared UI
primitives, a hard-coded Submit button, no submitBehavior/aria/groups support.

- delete packages/react/src/components/form/ entirely (FormRenderer,
  FieldFactory, their test, README, index) and the now-empty re-export
  `export * from './components/form'` in packages/react/src/index.ts
- remove the public exports FormRenderer/FormRendererProps/FieldFactory/
  FieldFactoryProps/ExtendedFormField from @object-ui/react (changeset: minor)
- docs: delete content/docs/core/form-renderer.mdx, drop it from core meta.json
  nav, repoint the schema-renderer and enhanced-actions cross-links (the latter
  to the live /docs/components/form page), delete the stale example
  packages/types/examples/form-renderer-example.ts
- fix stale guidance that listed FormRenderer as an app-shell export
  (app-shell never exported it): app-shell/README.md, three skills guides; the
  plugin-development guide's registration example now shows the real
  ObjectFormRenderer symbol

Verified: pnpm react build clean; vitest react 23 files / 295 tests pass; no
FormRenderer/FieldFactory/ExtendedFormField references remain outside CHANGELOGs.

Refs #2545.


Claude-Session: https://claude.ai/code/session_01Ln4gqtxXuymoQmyy4iRM2z

Co-authored-by: Claude <noreply@anthropic.com>

* test(e2e-live): B3 cascading-options live e2e — client re-filter + server rejection (#2559 / #1583) (#2562)

Adds e2e/live/cascading-options.spec.ts, the live-backend counterpart to the
no-backend docs e2e (e2e/cascading-options.spec.ts). Against the real console +
backend it drives the showcase `showcase_cascade` create form and asserts the
dual-side B3 contract:

- CLIENT: the child `province`'s offered option set re-filters as the parent
  `country` changes, and a now-invalid child value clears (cascade-clear).
- SERVER: POST /api/v1/data/showcase_cascade rejects an out-of-set option value
  (400 VALIDATION_FAILED / invalid_option) and accepts the in-set one — client
  hiding is UX, the server is the boundary.

Excluded from the default/CI run (testIgnore '**/live/**'); opt-in via
`pnpm test:e2e:live` with the stack up. Requires the framework showcase_cascade
fixture (framework #2559).


Claude-Session: https://claude.ai/code/session_01S91NyYJURiQTKmF9q3AXxg

Co-authored-by: Claude <noreply@anthropic.com>

* feat(flow-designer): connector picker lists dispatchable connectors + marks declarative instances (#2563)

Point the connector_action connector picker at the runtime registry (GET /api/v1/automation/connectors) so it lists exactly the dispatchable connectors — plugin connectors plus materialized declarative instances — and annotates declarative ones via the descriptor's origin field. Studio-side follow-up to framework ADR-0096.

* fix(detail): gate related lists on the current user's child-object read permission (#2359) (#2565)

Related-list sections were derived purely from the relationship graph:
every master_detail/lookup FK produced a section (header + grid + New
button) even when the current user has no read access to the child
object — the grid then showed 'no records' (fetch 403'd) and New 403'd
on save. Data access was always enforced server-side; this closes the
UI/DX gap.

- deriveRelatedLists gains an optional canRead predicate; children the
  user cannot read are dropped before sections/tabs/reference-rail are
  synthesized.
- RecordDetailView wires the predicate from usePermissions().can once
  permissions are loaded (fail-open while loading / with no provider —
  standalone embeds and Studio keep rendering).
- record:related_list renderer additionally self-gates on
  can(objectName, 'read') so hand-authored pages get the same baseline
  without an explicit requiredPermissions opt-in.

* remove(tenant): drop zero-consumer @object-ui/tenant package + types/tenant.ts mirror (#2564) (#2566)

Implements the #2564 disposition (owner-approved, enforce-or-remove — same doctrine as framework#2763/framework#2962): the client-side tenancy layer never had a consumer, and its TenantConfig.isolation strategy enum was the UI mirror of the spec's removed tenancy.strategy. Real tenant scoping is server-enforced (X-Tenant-ID via createAuthenticatedFetch + row-level isolation); per-tenant branding is a ThemeSchema concern.

- delete packages/tenant and packages/types/src/tenant.ts; drop the tenant type family from the @object-ui/types barrel
- retire the advertising: skills guides, eval item 3, content architecture-overview, ROADMAP row
- unwire config: changesets fixed group, root vitest alias, console vite alias, lockfile
- changeset: @object-ui/types minor (fixed group bumps together)

* feat(app-shell): CEL lint + field autocomplete for condition predicates (#1582) (#2567)

ConditionBuilder's raw-expression escape hatch — a bare <textarea> — is
replaced by CelPredicateField, so every surface that authors a condition
through it gains inline syntax/semantic validation and field-name autocomplete
on the canonical @objectstack/formula engine:

- field-level visibleWhen / readonlyWhen / requiredWhen (SchemaForm's
  condition widget auto-maps /When$/ properties),
- action visible / disabled (ActionDefaultInspector),
- every other condition-widget property (visibleOn, predicate, ...).

The no-code [subject][op][value] builder path is unchanged; only the
"Expression" mode is upgraded. An invalid predicate now surfaces a readable
inline error instead of failing silently at runtime. The sibling-field catalog
reaches the editor via the existing widgetContext.objectFields plumbing
(ResourceEditPage fetches it for the record's bound object). EN + ZH labels.

Completes the objectui side of #1582.


Claude-Session: https://claude.ai/code/session_019LwrWThBJgvcfPtqNHpstB

Co-authored-by: Claude <noreply@anthropic.com>

* fix(app-shell): bind current_user.positions into the client predicate scope; align role-gating examples (#1583 / ADR-0058) (#2573)

* test(e2e-live): B3 cascading-options live e2e — client re-filter + server rejection (#2559 / #1583)

Adds e2e/live/cascading-options.spec.ts, the live-backend counterpart to the
no-backend docs e2e (e2e/cascading-options.spec.ts). Against the real console +
backend it drives the showcase `showcase_cascade` create form and asserts the
dual-side B3 contract:

- CLIENT: the child `province`'s offered option set re-filters as the parent
  `country` changes, and a now-invalid child value clears (cascade-clear).
- SERVER: POST /api/v1/data/showcase_cascade rejects an out-of-set option value
  (400 VALIDATION_FAILED / invalid_option) and accepts the in-set one — client
  hiding is UX, the server is the boundary.

Excluded from the default/CI run (testIgnore '**/live/**'); opt-in via
`pnpm test:e2e:live` with the stack up. Requires the framework showcase_cascade
fixture (framework #2559).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S91NyYJURiQTKmF9q3AXxg

* fix(app-shell): bind current_user.positions into the client predicate scope; align role-gating examples (#1583 / ADR-0058)

The console forwarded only id/name/email/role/roles/isPlatformAdmin into the
expression scope, so a position-gated option predicate
(`'admin' in current_user.positions` — the shape the framework rule-validator
binds and enforces, EvalUser) faulted client-side and FAILED OPEN: the gated
option stayed visible to everyone and was only rejected by the server on
submit. Forward `positions` (auth payload already carries it) in AppContent and
RecordFormPage so client hiding and the server verdict agree.

Also switch every role-gating example from `current_user.roles` — a key the
server never binds, so a predicate written that way is never enforced — to
`current_user.positions`: schema-catalog role-gated-options.json, fields/select
docs, the skills guide, ADR-0058 example spellings, TSDoc on
SelectOption.visibleWhen, evaluator header comments, and the three unit tests'
mock scopes. Matches @objectstack/spec's own SelectOption.visibleWhen example.

Verified: optionRules/optionLint/SelectField.cascade/useExpression 51/51,
schema-catalog 836/836 (incl. the option-predicate guard), eslint 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S91NyYJURiQTKmF9q3AXxg

---------

Co-authored-by: Claude <noreply@anthropic.com>

* feat(page-header): metadata-driven multi-button record header (#2361) (#2574)

The record detail page header hardcoded INLINE_MAX=1 — only one action
could ever render as an inline button; everything else collapsed into
the ⋯ overflow menu, so multi-action objects (Lead: Convert / Assign /
Return) could not surface their common operations side by side.

The header now renders up to maxVisible inline buttons (default 3
desktop / 1 mobile, overridable via maxVisible / mobileMaxVisible on
the page:header schema) and decides which actions claim the inline
slots from metadata, mirroring action:bar's #2339 contract:

- order ascending (unset = 0; lower = more prominent), stable sort
- variant: 'primary' as a tie-break within equal order (and mapped to
  the Shadcn default Button variant instead of leaking through)
- component: 'action:menu' pins an action inside the ⋯ menu regardless
  of the action count

The synthesized system actions declare their placement accordingly:
sys_edit gets order: 100 (behind every authored business action, still
inline when slots remain); sys_share / sys_delete are pinned into the
⋯ menu via component: 'action:menu' so Delete never surfaces as an
inline red button just because an object has few actions.

Closes #2361


Claude-Session: https://claude.ai/code/session_01AvDRpozQS6m3UaWpGBtdG2

Co-authored-by: Claude <noreply@anthropic.com>

* feat(dashboard): dashboard-level filters driving multiple charts (framework#2501) (#2576)

Wire the (previously declared-but-inert) DashboardSchema.globalFilters +
dateRange into the runtime: filter values live as dashboard-level variables
(PageVariablesProvider, readable as page.<name> in widget expressions), a
DashboardFilterBar renders above the widgets, and DashboardRenderer
broadcasts the active values into every bound widget's inline query —
AND-merged with the widget's own filter (dataset widgets via runtimeFilter).

Each widget maps a filter to ITS OWN field via the new
DashboardWidgetSchema.filterBindings (string override / false opt-out),
defaulting to the filter's own field; legacy targetWidgets allow-list kept.
globalFilters[].name added as the stable variable key. Both pending paired
@objectstack/spec alignment (framework#2501).

- core: pure dashboard-filters module (defs resolution, condition build,
  per-widget binding resolution); mergeFilters lifted from plugin-report.
- Date presets emit date-macro tokens resolved at query time.
- Example: schema-catalog plugin-dashboard/filtered-dashboard (2 charts over
  different objects + opt-out metric). Docs: README + plugin-dashboard.mdx.

Tests: 16 core unit tests (binding/condition/merge), 5 renderer tests
(broadcast, per-widget fields, opt-out, live re-scope, dataset runtimeFilter).


Claude-Session: https://claude.ai/code/session_01U25wX5Ja3oS4dMyVHMnRsK

Co-authored-by: Claude <noreply@anthropic.com>

* fix(detail+fields+app-shell): ADR-0085 #2548 follow-ups — strip title dedupe, group icon/description, currency channel, approvals Bearer (#2577)

UX follow-ups from the framework#2548 real-backend browser pass, re-verified live (14/14): highlight strip drops the record title (new resolveTitleField, both synth + RecordDetailView paths); fieldGroups icon/description reach detail sections (LazyIcon rendering); currencyConfig passes through field enrichment so spec-authored currency fields render their symbol; RecordMetaFooter actor-less fallback labels (+ zh mistranslation fix); select badges ellipsize with hover title; all approvals fetches (app-shell badge/inbox/record panel + console approvalsApi) send the Bearer token for split-origin deployments.

* feat(dashboard-filters): #2578 follow-ups — catalog examples, guide tutorial, i18n entries, spec-alignment cleanup (#2581)

Follow-ups to the dashboard-level filters feature (#2576, framework#2501):

- schema-catalog: five new plugin-dashboard examples — optionsFrom dynamic
  options, text/number/lookup filter types, dataset + inline widget mix,
  legacy targetWidgets allow-list, and date presets + custom range with a
  dateRange opt-out.
- docs: new 'Dashboard-Level Filters' guide page (full tutorial from zero:
  dateRange + globalFilters + filterBindings, page.* expression usage,
  filter-type condition table, known limitations with workarounds); cross-
  linked from plugin-dashboard.mdx.
- i18n: dashboard.filters.* entries for en and zh (bar label, All time,
  Custom…, All, Reset, and the 13 date-range preset labels) — the filter
  bar previously always rendered the useSafeTranslate English fallbacks.
- types: GlobalFilterSchema.name + DashboardWidgetSchema.filterBindings are
  now landed in @objectstack/spec, so the 'Pending alignment' JSDoc flips
  to 'Aligned' (no shape changes).


Claude-Session: https://claude.ai/code/session_01HG5LQnPbQbjAAyofghzz3Z

Co-authored-by: Claude <noreply@anthropic.com>

* fix(app-shell): guard Studio Access pillar against silently discarding unsaved matrix edits (#2588)

The Access pillar keys PermissionMatrixEditPage by the selected set
(key={current}) and swaps it out entirely for the OWD overview, so any
rail click REMOUNTED the matrix and silently dropped unsaved checkbox
edits — no confirm, no beforeunload.

- PermissionMatrixEditPage now tracks dirty state (JSON snapshot
  baseline re-anchored on load/save, same approach as ResourceEditPage),
  reports transitions via a new onDirtyChange prop (reset to false on
  unmount), and installs the standard beforeunload guard while dirty.
- AccessPillar holds the reported dirty flag and gates every
  rail-driven surface swap (set switch, OWD overview button, the
  matrix's OWD badge deep-link, and the new-set creator) on the same
  native confirm the metadata editor's leave guard uses
  (engine.edit.unsavedLeaveConfirm). Re-clicking the open set stays a
  no-op and never prompts.
- Tests: onDirtyChange contract (clean on load, dirty on edit, clean
  after save, reset on unmount) + AccessPillar guard integration
  (cancel keeps edits in place, confirm discards, clean switches and
  no-op re-clicks never prompt, discard clears the guard).

Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix(detail): highlight strip lookup editor honors ObjectStack `reference` key (#2407) (#2587)

The record-highlights strip's inline-edit lookup editor (#2407 P2, PR #2549)
only copied `reference_to` from the object schema into the enriched field,
but backend object schemas use the ObjectStack-convention `reference` key
(e.g. showcase_project.account has "reference": "showcase_account").
Result: the LookupField got no target object, so it showed the placeholder
instead of the current value and the picker found no options. The details
body already normalizes both keys in DetailSection — mirror that here, and
copy `reference_field` for parity. Also affects user-type highlights
(e.g. team_members → sys_user).

Adds regression tests: a lookup highlight whose schema field carries the
`reference` key (and `reference_field`) hands the target through to the
LookupField editor, alongside the existing `reference_to` spelling.

Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* feat(app-shell): visual filterBindings editor in the dashboard widget inspector (#2578) (#2586)

When the dashboard declares filters (dateRange / globalFilters), the Studio
widget inspector now shows a 'Dashboard filter bindings' section — one row
per filter with an Apply toggle (unticked writes filterBindings[name]=false)
and a field picker re-targeting the filter to this widget's own field
(empty = default: the filter's own field). Rows come from the same
resolveDashboardFilterDefs normalization the runtime broadcasts from.
Previously bindings were only configurable through raw JSON metadata.

en + zh inspector strings; 4 new tests (section gating, row rendering,
opt-out round-trip, override reset).


Claude-Session: https://claude.ai/code/session_01HG5LQnPbQbjAAyofghzz3Z

Co-authored-by: Claude <noreply@anthropic.com>

* feat(fields+form+detail): file/image upload cells in inline line-item grids (#2360) (#2585)

Field.file in a master-detail inline grid degraded to a text input (no
input[type=file] anywhere on the page, so no way to upload from the grid),
and auto-derived subform / related-list columns silently dropped file fields.

- fields: new FileCell — compact upload control for grid cells (picker
  button + removable chips, image thumbnails), sharing the UploadProvider
  pipeline with FileField via an extracted useFileUploads hook. GridField
  gains a 'file' column type (accept/multiple carried through) and renders
  file names in list/readonly modes instead of String(value).
- plugin-form: deriveColumns/hydrateColumns no longer exclude
  file/image/avatar fields — they map to 'file' columns and carry the
  field's multiple + accept (image fields default to ['image/*']).
- plugin-detail: auto-derived related-list columns no longer skip
  file/image fields — they render via the existing FileCellRenderer /
  ImageCellRenderer.

Closes #2360


Claude-Session: https://claude.ai/code/session_01H5tsBSEhsDZmdcHBvfymYC

Co-authored-by: Claude <noreply@anthropic.com>

* feat(dashboard-filters): #2578 item-5 enhancements — nested variable merging, metadata-aware default bindings, server-side optionsFrom distinct (#2590)

- react: nested PageVariablesProviders merge instead of shadowing wholesale —
  outer page variables stay readable inside an embedded filtered dashboard,
  same-named inner definitions shadow deliberately, and writes route to the
  defining scope (resetVariables stays local). 6 new tests.
- core: buildWidgetScopedFilter takes an optional knownFields set; a DEFAULT
  binding whose field is missing from the widget's object is skipped with a
  console warning instead of emitting an empty-matching query. Explicit
  filterBindings strings are always honoured. 3 new tests.
- plugin-dashboard: DashboardRenderer feeds knownFields from
  dataSource.getObjectSchema for inline object widgets (best-effort);
  optionsFrom resolves distinct option values server-side via a dataset
  GROUP BY (queryDataset inline draft) with the previous client-side top-200
  dedupe as fallback. 3 new tests.
- docs: guide page known-limitations section rewritten (two of three lifted);
  README notes updated.


Claude-Session: https://claude.ai/code/session_01HG5LQnPbQbjAAyofghzz3Z

Co-authored-by: Claude <noreply@anthropic.com>

* fix(data-objectstack): emit MutationEvents from batchTransaction and bulk so master-detail saves refresh bound views (#2584)

Fixes #2582. A ModalForm over an object WITH subforms delegates to
MasterDetailForm, which persists the parent + child rows through ONE
transactional POST /api/v1/batch (the canBatch path — in the console
dataSource.batchTransaction always exists). ObjectStackAdapter.batchTransaction
never called emitMutation, so the #2269 invalidation bus (onMutation →
notifyDataChanged) heard nothing: the related list and the related-tab count
badge kept stale values after a successful create/edit until a full page
reload.

- batchTransaction: after the transaction commits, emit one MutationEvent per
  operation (creates take id/record from the index-aligned server echo). A
  failed batch rolled back entirely and emits nothing.
- bulk(): each of the create/delete/update branches now emits one
  resource-level event, matching the existing bulkUpdate/bulkDelete contract
  (this covers applyDetail's fallback child-row creation path).
- batchTransaction now goes through this.fetchImpl instead of the global
  fetch, consistent with every other raw HTTP call in the adapter (and
  injectable in tests).

Regression tests: batch create/edit emit per-op events, failed batch emits
nothing, op without action defaults to create, bulk create/delete/update emit
one resource-level event, zero-row and failed bulk emit nothing.


Claude-Session: https://claude.ai/code/session_01CHhyf56Z69KNL6aCAsQfKQ

Co-authored-by: Claude <noreply@anthropic.com>

* fix(fields+detail): resolve pre-existing rules-of-hooks violations in cell renderers (#2595)

Currency/Email/Phone cell renderers called hooks after their empty-value early return (latent hook-count crash when a value flips between null and set); useFieldLabel wrapped useObjectTranslation in try/catch (hook-order desync risk; the hook is provider-safe); ReferenceCellRenderer no longer constructs JSX inside try/catch; RecordMetaFooter UserRef renders the registry cell renderer via React.createElement. No behavior change; react-hooks errors 9 → 0; vitest fields+plugin-detail 556 ✓.

* feat(kanban): default lane field honours the ADR-0085 stageField role (#2596)

Kanban views without an explicit lane field resolved to a hard-coded 'status' (ObjectView options + ListView fallback); the default now goes explicit config → stageField role → strict-false suppression (no default lanes) → shared name/type heuristic, via detectStatusField moved to @object-ui/types (plugin-detail re-exports unchanged). ListView's schema memo gains the async objectDef dep, and its useListFieldLabel drops the try/catch-around-hook (same class as #2595). Out-of-scope findings recorded: report bucketing has no default-grouping seam; list badges already served by option-color select badges.

* feat(studio): CEL editor with validate + autocomplete for field conditional rules (#1582) (#2571)

The object designer's field inspector now edits visibleWhen / readonlyWhen /
requiredWhen with the CelPredicateField CEL editor instead of a raw string
(only conditionalRequired had even that). The editor runs in the new
scope='record' mode matching how these rules evaluate at runtime
(@object-ui/core evalFieldPredicate binds record/previous/parent only):

- lint via @objectstack/formula validateExpression(scope:'record') — a bare
  field ref is an error with the exact record.<field> fix; record./previous.
  members are checked against the object's fields with did-you-mean;
- autocomplete offers the runtime-bound roots + stdlib bare, and the object's
  own field catalog as MEMBER completion after record. / previous. (new
  memberTokenAt helper; empty segment right after the dot surfaces the full
  catalog);
- values round-trip both wire shapes (bare string | {dialect,source} envelope,
  envelope extras like meta.rationale preserved); the deprecated
  conditionalRequired alias reads into Required-when and migrates to
  requiredWhen on first edit;
- validateMetadataDraft('object') now lints every field's rules draft-wide, so
  an invalid predicate on ANY field surfaces under fields.<field>.<rule> in
  the edit banner before save.

Verified in the preview gallery (?only=object): inline errors, record.
member completion, Valid CEL affordance. 715 metadata-admin tests green.

Closes #1582

Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix(studio): stop force-opening the new-object dialog on empty packages (#2569)

Visiting an empty writable package's Data pillar auto-opened the "new object"
creator dialog on EVERY mount — an unrequested modal that duplicated the
guidance the empty-state panel already gives, and re-appeared each time you
navigated back (dogfood #2555 item 1).

The dialog now stays closed; the empty state carries an explicit "New object"
CTA instead (hidden on read-only packages), and the firstObjectHint copy points
at the button rather than the rail inputs. DataPillar is exported for the new
behavioral test.

Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix(fields): PeoplePicker cursor resets only on real result changes (de-flakes keyboard test) (#2594)

The keyboard-cursor reset effect keyed on the records array identity, so a
background re-emission of identical results (StrictMode double-effect,
refetch-on-focus) reset the active row to none mid-navigation. On slow CI
that landed between ArrowDown and Enter, dropping the commit — the flaky
"ArrowDown then Enter" failure. Key the reset on the record-id signature
instead, and make the test wait for the row to be visibly active before
pressing Enter.


Claude-Session: https://claude.ai/code/session_01H5tsBSEhsDZmdcHBvfymYC

Co-authored-by: Claude <noreply@anthropic.com>

* fix(dashboard-filters): spec-form filter options crashed the dashboard; add guide screenshots (#2578) (#2597)

Browser dogfood of the showcase Revenue Pulse dashboard caught a crash:
GlobalFilterSchema.options in the @objectstack/spec object form
({ value, label } — what the spec validates and framework dashboards ship)
was rendered un-normalized as a React child ('Objects are not valid as a
React child'), taking down the whole dashboard. The filter bar only
supported the objectui bare-string shorthand.

- core: resolveDashboardFilterDefs normalizes both shapes to {value,label}
  pairs (DashboardFilterDef.options typed accordingly); new test covers
  both forms.
- plugin-dashboard: the select consumes normalized pairs and the trigger
  now shows the selected option's LABEL, not its raw value.
- types: GlobalFilterSchema.options aligned with the spec union
  (string | {value,label}).
- docs: guide gains three real screenshots from the live walkthrough
  (default view, EMEA re-scope with opted-out KPI holding, Studio
  widget-inspector bindings section) under
  apps/site/public/img/guide/dashboard-filters/.

Verified end-to-end in the browser after the fix: filter bar renders,
region/date changes live re-scope invoice AND account widgets against
their own fields, Reset appears when dirty, and the Studio inspector
edits bindings visually. 1323 tests green across core/plugin-dashboard/
types.


Claude-Session: https://claude.ai/code/session_01HG5LQnPbQbjAAyofghzz3Z

Co-authored-by: Claude <noreply@anthropic.com>

* fix(core+data-objectstack+app-shell): canonicalize reference/reference_to at the schema chokepoints (#2407) (#2598)

Backend object schemas key a relational field's target as `reference`
(ObjectStack convention: showcase_project.account →
{ type: 'lookup', reference: 'showcase_account' }), while ObjectUI types
and most consumers read `reference_to`. PR #2587 fixed HeaderHighlight
case-by-case; this closes the class of bug at the chokepoints instead.

- @object-ui/core: new normalizeFieldReferenceKeys /
  normalizeSchemaReferenceKeys — stamp BOTH snake_case keys whenever
  either (or legacy camelCase referenceTo) is present; in-place,
  idempotent, never overwrites existing keys.
- ObjectStackAdapter.getObjectSchema: run the pass on the cached schema
  (same seam as applyFieldWidgetOverrides) — covers every
  dataSource.getObjectSchema consumer.
- app-shell MetadataProvider: run the pass on `object` items at
  ingestion — covers every useMetadata().objects consumer.

Remaining single-key readers confirmed against the live showcase schema
and fixed with the dual-key fallback (they can receive schemas that
bypass the chokepoints — mock/api data sources, spec-authored configs):

- ObjectGantt quick-filters: lookup/master_detail option domain never
  loaded for reference-keyed schemas (refObject was undefined).
- RecordDetailDrawer: lookup cells lost reference_to, so id→name never
  resolved in the quick-look drawer.
- ReportView column hydration: read referenceTo/reference?.to/target —
  none of which exist on store object defs; lookup report columns never
  got their target.
- attachInlineSubforms (mirror direction): parent resolved only from
  `reference`, missing reference_to-keyed defs.
- sectionFields spec override (mirror direction): author-supplied
  reference_to on a form field spec was silently dropped.
- metadataConverters.toFieldDefinition: referenceTo now falls back to
  the served `reference` key.

Unit tests cover the utility, the adapter pass, gantt quick-filter
options, inline subforms, and the section-field override.

Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* feat(flow-designer): pick the target node per branch in the Decision Branches editor (#1942) (#2568)

The decision Branches editor gains a Target column — a node picker scoped to
this flow — wiring each branch to its downstream node in the same table as its
label and CEL expression (Salesforce Flow Decision Outcomes style). Completes
the per-edge Branch picker (#1930) from the node side.

The column is virtual: derived from the decision's out-edges (routing truth)
and never stored on config.conditions, so it round-trips with FlowEdgeInspector
and canvas rewiring. Picking a target creates/updates/retargets the branch's
edge carrying its condition/label/default; clearing detaches the edge, never
the node. Commits with no targets anywhere (engine-published configSchema
forms) keep the legacy #1927 by-order mirror unchanged. Custom edge guards and
fault/back edges are never touched.

New pure module flow-decision-edges.ts + unit tests for the reconciliation.

Closes #1942

Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix(app-shell,core): keep error-envelope objects out of toast.error — React #31 page crash (#2579) (#2580)

A failed api/flow/server action returning the ObjectStack envelope
`{ error: { code, message } }` rode the error OBJECT through
`result.error` into ActionRunner's post-execution toast, and
`toast.error(object)` rendered it as a React child — Minified React
error #31 inside flushSync took down the whole page (reported from
Setup → Create User's 400, objectstack-ai/framework#3031).

Two layers so no single handler bug can crash the page again:
- sources: shared errorDetail() in useConsoleActionRuntime flattens the
  envelope to a string (error string → error.message → message → HTTP
  fallback) across apiHandler / flowHandler / serverActionHandler;
- sink: ActionRunner coerces a non-string result.error to its .message
  (or a generic string) before calling the toast handler.

* fix(components): pin sticky leading cells at measured header widths (#2592)

The checkbox / row-number / frozen data cells stuck at hardcoded 40px
offsets, but the table's auto layout doesn't guarantee those widths (the
checkbox column collapses to its ~28px min-content), leaving an uncovered
strip between pinned cells where horizontally scrolled content showed
through (steedos-labs/os-project-titanwind-ehr#418).

Measure the real header-cell widths (ResizeObserver keeps them fresh
across column resize / density / content changes) and pin each leading
sticky cell at the cumulative measured width of the cells before it,
falling back to the previous estimates until the first measurement.

* fix(fields): localize relative-date humanize via Intl.RelativeTimeFormat (framework#3040) (#2593)

The near-today (±7d) window of date fields rendered hardcoded English
(Today/Tomorrow/Yesterday/In N days/Nd ago/Overdue Nd) regardless of the
workspace locale. Now:

- formatRelativeDate resolves the generic phrases through
  Intl.RelativeTimeFormat(locale, { numeric: 'auto' }) (sentence-cased),
  falling back to English literals on an invalid locale tag.
- The due-like "Overdue Nd" wording (no Intl equivalent) resolves through
  the i18n bundle key fields.relativeDate.overdue with an English fallback;
  the key is added to all 10 built-in locales.
- DateCellRenderer feeds the ADR-0053 tenant locale (useLocalization) and
  the i18n translate fn into formatDate/formatRelativeDate, covering list
  columns, detail header chips, and detail body alike.
- formatDate's default absolute path also honors options.locale.

* fix(app-shell): lock the Access pillar permission matrix in read-only packages (#2570)

A read-only package's Studio top bar showed the "Read-only" chip while the
Access pillar's embedded PermissionMatrixEditPage stayed fully editable with
a live Save button: AccessPillar only used its readOnly prop to hide the
"New permission set" button and never passed it down, and the editor's
internal writable gate (allowOrgOverride) is a TYPE-level concept with no
host-side input.

- PermissionMatrixEditPage grows a readOnly prop, ANDed into `writable`, so
  the one existing switch locks everything: object/field checkboxes, bulk
  buttons, name/label inputs, capability picker, advanced facets, and Save.
- The read-only badge now words itself for the gate that tripped: package
  gate mirrors the top-bar chip (engine.studio.pkg.readonly + hint) so the
  screen no longer contradicts itself; the type gate keeps the
  OS_METADATA_WRITABLE wording.
- AccessPillar passes readOnly through.
- New RTL suite drives the real editor through both gates plus the editable
  regression path.

Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix(plugin-list): spec bare-string sort form crashed ListView (#2578 shape-mismatch audit) (#2601)

* fix(dashboard-filters): spec-form filter options crashed the dashboard; add guide screenshots (#2578)

Browser dogfood of the showcase Revenue Pulse dashboard caught a crash:
GlobalFilterSchema.options in the @objectstack/spec object form
({ value, label } — what the spec validates and framework dashboards ship)
was rendered un-normalized as a React child ('Objects are not valid as a
React child'), taking down the whole dashboard. The filter bar only
supported the objectui bare-string shorthand.

- core: resolveDashboardFilterDefs normalizes both shapes to {value,label}
  pairs (DashboardFilterDef.options typed accordingly); new test covers
  both forms.
- plugin-dashboard: the select consumes normalized pairs and the trigger
  now shows the selected option's LABEL, not its raw value.
- types: GlobalFilterSchema.options aligned with the spec union
  (string | {value,label}).
- docs: guide gains three real screenshots from the live walkthrough
  (default view, EMEA re-scope with opted-out KPI holding, Studio
  widget-inspector bindings section) under
  apps/site/public/img/guide/dashboard-filters/.

Verified end-to-end in the browser after the fix: filter bar renders,
region/date changes live re-scope invoice AND account widgets against
their own fields, Reset appears when dirty, and the Studio inspector
edits bindings visually. 1323 tests green across core/plugin-dashboard/
types.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HG5LQnPbQbjAAyofghzz3Z

* fix(plugin-list): spec bare-string sort form crashed ListView (#2578 audit)

@objectstack/spec ListViewSchema.sort is string | Array<{field,order}> —
the bare-string top-level form ('name desc') hit schema.sort.map and threw
'schema.sort.map is not a function', crashing the list. Found by the
spec/renderer shape-mismatch audit following the dashboard filter-options
crash.

Sort parsing is now one normalized parseSortConfig (exported, unit-tested):
bare string, legacy 'field desc' array entries, and {field,order} objects
all parse; malformed entries drop instead of throwing. The @object-ui/types
declaration already carried the union — only the implementation missed the
string branch. Also deduplicates the previously copy-pasted parser between
the state initializer and the schema-sync effect.

Audit sweep otherwise clean: every other string|object union consumer
(gantt quickFilters/tooltipFields, lookupColumns, dependsOn,
record-highlights fields, list columns) already normalizes with typeof
guards, and spec I18nLabelSchema is plain z.string() so label-as-object
crash paths do not exist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HG5LQnPbQbjAAyofghzz3Z

---------

Co-authored-by: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: 包周涛 <baozhoutao@hotoa.com>
Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Studio: CEL editor (validate + autocomplete) for field conditional rules (visibleWhen/readonlyWhen/requiredWhen)

2 participants