@object-ui/react 17.6.0 → 18.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/CHANGELOG.md +4263 -0
  2. package/README.md +383 -42
  3. package/dist/SchemaRenderer.d.ts +64 -7
  4. package/dist/SchemaRenderer.js +1326 -106
  5. package/dist/context/ActionContext.js +40 -1
  6. package/dist/context/AppShellContext.d.ts +27 -0
  7. package/dist/context/DiscussionContext.d.ts +27 -0
  8. package/dist/context/DiscussionContext.js +4 -0
  9. package/dist/context/DrillNavigationContext.d.ts +13 -0
  10. package/dist/context/InlineEditContext.d.ts +151 -1
  11. package/dist/context/InlineEditContext.js +36 -2
  12. package/dist/context/PrintPageContext.d.ts +60 -0
  13. package/dist/context/PrintPageContext.js +32 -0
  14. package/dist/context/RecordContext.d.ts +48 -5
  15. package/dist/context/RecordContext.js +10 -1
  16. package/dist/context/RelatedRecordActionsContext.d.ts +72 -2
  17. package/dist/context/RelatedRecordActionsContext.js +4 -0
  18. package/dist/context/SchemaRendererContext.d.ts +61 -6
  19. package/dist/context/SchemaRendererContext.js +39 -6
  20. package/dist/context/ThemeContext.js +1 -1
  21. package/dist/context/actionRunnerTranslator.d.ts +27 -0
  22. package/dist/context/actionRunnerTranslator.js +37 -0
  23. package/dist/context/index.d.ts +1 -0
  24. package/dist/context/index.js +1 -0
  25. package/dist/element-data-source/ElementApiDataSourcePanel.d.ts +28 -0
  26. package/dist/element-data-source/ElementApiDataSourcePanel.js +10 -0
  27. package/dist/element-data-source/ElementDataSourceGate.d.ts +118 -3
  28. package/dist/element-data-source/ElementDataSourceGate.js +401 -19
  29. package/dist/element-data-source/useElementApiDataSource.d.ts +119 -0
  30. package/dist/element-data-source/useElementApiDataSource.js +198 -0
  31. package/dist/hooks/index.d.ts +3 -1
  32. package/dist/hooks/index.js +10 -1
  33. package/dist/hooks/useActionEngine.js +46 -3
  34. package/dist/hooks/useActionRunner.js +4 -0
  35. package/dist/hooks/useActionTextLocalizer.d.ts +35 -9
  36. package/dist/hooks/useActionTextLocalizer.js +70 -14
  37. package/dist/hooks/useConfigBagEvaluator.d.ts +31 -0
  38. package/dist/hooks/useConfigBagEvaluator.js +40 -0
  39. package/dist/hooks/useDatasetDimensionLabels.js +25 -10
  40. package/dist/hooks/useDensityMode.d.ts +8 -2
  41. package/dist/hooks/useDensityMode.js +8 -2
  42. package/dist/hooks/useDiscovery.d.ts +1 -10
  43. package/dist/hooks/useETagCache.js +25 -4
  44. package/dist/hooks/useElementDataSource.d.ts +15 -2
  45. package/dist/hooks/useElementDataSource.js +24 -7
  46. package/dist/hooks/useExpression.d.ts +52 -59
  47. package/dist/hooks/useExpression.js +58 -90
  48. package/dist/hooks/useFilterScope.d.ts +22 -2
  49. package/dist/hooks/useFilterScope.js +29 -4
  50. package/dist/hooks/useGlobalUndo.d.ts +10 -0
  51. package/dist/hooks/useGlobalUndo.js +112 -46
  52. package/dist/hooks/useNavigationOverlay.d.ts +76 -11
  53. package/dist/hooks/useNavigationOverlay.js +119 -9
  54. package/dist/hooks/useOffline.js +65 -11
  55. package/dist/hooks/usePageAssignment.d.ts +33 -7
  56. package/dist/hooks/usePageAssignment.js +85 -21
  57. package/dist/hooks/useRecordSearch.d.ts +34 -1
  58. package/dist/hooks/useRecordSearch.js +42 -7
  59. package/dist/hooks/useResolvedFilter.d.ts +46 -0
  60. package/dist/hooks/useResolvedFilter.js +130 -0
  61. package/dist/hooks/useSchemaPersistence.d.ts +7 -0
  62. package/dist/hooks/useSchemaPersistence.js +144 -5
  63. package/dist/hooks/useSettledSchema.d.ts +123 -0
  64. package/dist/hooks/useSettledSchema.js +186 -0
  65. package/dist/hooks/useViewData.d.ts +10 -3
  66. package/dist/hooks/useViewData.js +5 -14
  67. package/dist/index.d.ts +9 -1
  68. package/dist/index.js +48 -1
  69. package/dist/schema-input.d.ts +38 -8
  70. package/dist/schema-input.js +39 -7
  71. package/dist/utils/configBag.d.ts +68 -0
  72. package/dist/utils/configBag.js +70 -0
  73. package/dist/utils/error-message.d.ts +1 -1
  74. package/dist/utils/error-message.js +1 -1
  75. package/dist/utils/inlineAria.d.ts +61 -0
  76. package/dist/utils/inlineAria.js +70 -0
  77. package/dist/utils/localeMap.d.ts +27 -0
  78. package/dist/utils/localeMap.js +106 -0
  79. package/dist/utils/nonGridRowCeiling.d.ts +44 -0
  80. package/dist/utils/nonGridRowCeiling.js +78 -0
  81. package/dist/utils/paramsBag.d.ts +28 -0
  82. package/dist/utils/paramsBag.js +146 -0
  83. package/dist/utils/propsBagDiagnostic.d.ts +196 -0
  84. package/dist/utils/propsBagDiagnostic.js +422 -0
  85. package/dist/utils/refusedDataPropDiagnostic.d.ts +44 -0
  86. package/dist/utils/refusedDataPropDiagnostic.js +94 -0
  87. package/dist/utils/unevaluatedExpression.d.ts +44 -1
  88. package/dist/utils/unevaluatedExpression.js +92 -11
  89. package/dist/utils/visibilityDiagnostic.d.ts +282 -21
  90. package/dist/utils/visibilityDiagnostic.js +321 -46
  91. package/package.json +10 -10
  92. package/dist/hooks/useClientNotifications.d.ts +0 -47
  93. package/dist/hooks/useClientNotifications.js +0 -162
  94. package/dist/spec-bridge/SpecBridge.d.ts +0 -28
  95. package/dist/spec-bridge/SpecBridge.js +0 -56
  96. package/dist/spec-bridge/bridges/form-view.d.ts +0 -158
  97. package/dist/spec-bridge/bridges/form-view.js +0 -170
  98. package/dist/spec-bridge/bridges/list-view.d.ts +0 -20
  99. package/dist/spec-bridge/bridges/list-view.js +0 -268
  100. package/dist/spec-bridge/index.d.ts +0 -11
  101. package/dist/spec-bridge/index.js +0 -10
  102. package/dist/spec-bridge/types.d.ts +0 -55
  103. package/dist/spec-bridge/types.js +0 -8
@@ -77,7 +77,12 @@ import { SchemaRendererContext } from '../context/SchemaRendererContext.js';
77
77
  export function useDatasetDimensionMeta(object, dimensionFields, dimensions, options) {
78
78
  // The host's AUTHENTICATED fetch (objectui#4121) — see property 1 in the
79
79
  // file header for why it is read off the context directly.
80
- const apiFetch = useContext(SchemaRendererContext)?.apiFetch;
80
+ const context = useContext(SchemaRendererContext);
81
+ const apiFetch = context?.apiFetch;
82
+ // The host data source, when it can answer `getObjectSchema` (objectui#13039):
83
+ // the data layer's shared request-key cache, so N dataset widgets over one
84
+ // object make ONE `meta/object/NAME` request instead of one each.
85
+ const dataSource = context?.dataSource;
81
86
  const enabled = options?.enabled ?? true;
82
87
  // LOCALE-FREE state (objectui#4030) — see property 2 in the file header.
83
88
  const [meta, setMeta] = useState(null);
@@ -103,14 +108,22 @@ export function useDatasetDimensionMeta(object, dimensionFields, dimensions, opt
103
108
  (async () => {
104
109
  try {
105
110
  const doFetch = apiFetch ?? fetch;
106
- const loadObjectSchema = async (name) => {
107
- const r = await doFetch(`/api/v1/meta/object/${encodeURIComponent(name)}`, {
108
- headers: { accept: 'application/json' },
109
- credentials: 'include',
110
- });
111
- const doc = await r.json().catch(() => null);
112
- return doc?.item ?? doc?.data ?? doc;
113
- };
111
+ // objectui#13039 — through the data source's `getObjectSchema` when one
112
+ // that answers it is in scope: the read the dashboard's chart, metric
113
+ // and table widgets make, which the adapter's metadata cache joins while
114
+ // it is in flight. It answers the bare object document, which is what
115
+ // the raw leg's unwrap yields, so core gets one shape from either leg.
116
+ // The raw read stays as the fallback for a surface with no such source.
117
+ const loadObjectSchema = typeof dataSource?.getObjectSchema === 'function'
118
+ ? (name) => dataSource.getObjectSchema(name)
119
+ : async (name) => {
120
+ const r = await doFetch(`/api/v1/meta/object/${encodeURIComponent(name)}`, {
121
+ headers: { accept: 'application/json' },
122
+ credentials: 'include',
123
+ });
124
+ const doc = await r.json().catch(() => null);
125
+ return doc?.item ?? doc?.data ?? doc;
126
+ };
114
127
  // ONE walk for every dimension, memoized per call inside core — sibling
115
128
  // dimensions sharing a relationship prefix read that object once, and
116
129
  // the base object is seeded so it is never re-read.
@@ -133,8 +146,10 @@ export function useDatasetDimensionMeta(object, dimensionFields, dimensions, opt
133
146
  // (objectui#4030 / PR #4324). `apiFetch` comes from the provider's
134
147
  // memoized context value, and this hook's own `setState` cannot re-render
135
148
  // the provider, so its identity is stable across the effect's own updates.
149
+ // `dataSource` joins for the same reason and from the same value
150
+ // (objectui#13039): it decides which leg the read takes.
136
151
  // eslint-disable-next-line react-hooks/exhaustive-deps
137
- }, [signature, apiFetch]);
152
+ }, [signature, apiFetch, dataSource]);
138
153
  return meta;
139
154
  }
140
155
  /**
@@ -50,8 +50,14 @@ export interface DensityResult {
50
50
  *
51
51
  * @example
52
52
  * ```tsx
53
- * const density = useDensityMode(activeView?.densityMode ?? 'comfortable', {
54
- * onChange: (m) => dataSource.updateViewConfig(obj, vid, { densityMode: m }),
53
+ * import { DENSITY_MODE_TO_ROW_HEIGHT, rowHeightToDensityMode } from '@object-ui/core';
54
+ *
55
+ * // A view stores the protocol's `rowHeight`; the legacy `densityMode` key is
56
+ * // retired on `NamedListView` (objectui#7924), so persist the spec value —
57
+ * // here into the host's own per-user store (`saveUserRowHeight` is the
58
+ * // host's; the console keeps the choice on the user's own list state).
59
+ * const density = useDensityMode(rowHeightToDensityMode(activeView?.rowHeight) ?? 'comfortable', {
60
+ * onChange: (m) => saveUserRowHeight(vid, DENSITY_MODE_TO_ROW_HEIGHT[m]),
55
61
  * });
56
62
  * ```
57
63
  */
@@ -35,8 +35,14 @@ const DENSITY_ORDER = ['compact', 'comfortable', 'spacious'];
35
35
  *
36
36
  * @example
37
37
  * ```tsx
38
- * const density = useDensityMode(activeView?.densityMode ?? 'comfortable', {
39
- * onChange: (m) => dataSource.updateViewConfig(obj, vid, { densityMode: m }),
38
+ * import { DENSITY_MODE_TO_ROW_HEIGHT, rowHeightToDensityMode } from '@object-ui/core';
39
+ *
40
+ * // A view stores the protocol's `rowHeight`; the legacy `densityMode` key is
41
+ * // retired on `NamedListView` (objectui#7924), so persist the spec value —
42
+ * // here into the host's own per-user store (`saveUserRowHeight` is the
43
+ * // host's; the console keeps the choice on the user's own list state).
44
+ * const density = useDensityMode(rowHeightToDensityMode(activeView?.rowHeight) ?? 'comfortable', {
45
+ * onChange: (m) => saveUserRowHeight(vid, DENSITY_MODE_TO_ROW_HEIGHT[m]),
40
46
  * });
41
47
  * ```
42
48
  */
@@ -40,17 +40,8 @@ export interface DiscoveryInfo {
40
40
  /** Server name and version */
41
41
  name?: string;
42
42
  version?: string;
43
- /** Runtime mode (e.g., 'development', 'production', 'preview') */
43
+ /** Runtime mode the server reports (`RuntimeMode` in `@objectstack/spec`) */
44
44
  mode?: string;
45
- /** Preview mode configuration from the kernel (present when mode is 'preview') */
46
- previewMode?: {
47
- autoLogin?: boolean;
48
- simulatedRole?: 'admin' | 'user' | 'viewer';
49
- simulatedUserName?: string;
50
- readOnly?: boolean;
51
- expiresInSeconds?: number;
52
- bannerMessage?: string;
53
- };
54
45
  /** Service availability status */
55
46
  services?: {
56
47
  /** Authentication service status */
@@ -5,7 +5,7 @@
5
5
  * This source code is licensed under the MIT license found in the
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
8
+ import { useCallback, useEffect, useInsertionEffect, useMemo, useRef, useState } from 'react';
9
9
  // ---------------------------------------------------------------------------
10
10
  // Defaults
11
11
  // ---------------------------------------------------------------------------
@@ -141,9 +141,30 @@ export function useETagCache(userConfig = {}) {
141
141
  const swUrl = userConfig.serviceWorkerUrl ?? DEFAULTS.serviceWorkerUrl;
142
142
  const [cacheSize, setCacheSize] = useState(0);
143
143
  const [serviceWorkerActive, setServiceWorkerActive] = useState(false);
144
- // Keep config in a ref so callbacks always see latest values
145
- const configRef = useRef({ enabled, storage, storagePrefix, maxEntries, ttl });
146
- configRef.current = { enabled, storage, storagePrefix, maxEntries, ttl };
144
+ // Keep config in a ref so callbacks always see latest values. Every reader
145
+ // below is a `useCallback` with `[]` deps whose identity consumers may key
146
+ // effects on, so the config has to reach them without changing it — that is
147
+ // the ref's job. Only the WRITE moves: done in the render body it also ran
148
+ // for renders React discards or replays, publishing config from a tree that
149
+ // never committed. `useInsertionEffect` runs in the mutation phase, ahead of
150
+ // every layout effect, ref attachment and paint, so the sole window deferred
151
+ // is the render phase — where `fetchWithETag` / `clearCache` and the other
152
+ // side effects are not callable anyway.
153
+ //
154
+ // The object itself is built by `useMemo` rather than inline at the two
155
+ // places that consume it. `useRef({ ... })` evaluates its argument on EVERY
156
+ // render and keeps only the first result, so every later render allocated a
157
+ // five-key object that was thrown away (objectui#6817) — the same shape PR
158
+ // objectui#6796 repaired in `useSchemaPersistence`. Its identity is
159
+ // unobservable from outside the hook: the ref is private and every reader
160
+ // only reads fields off `.current`, so a memo React chooses to discard is
161
+ // harmless — it rebuilds an equal object, which is what every render used
162
+ // to do unconditionally.
163
+ const config = useMemo(() => ({ enabled, storage, storagePrefix, maxEntries, ttl }), [enabled, storage, storagePrefix, maxEntries, ttl]);
164
+ const configRef = useRef(config);
165
+ useInsertionEffect(() => {
166
+ configRef.current = config;
167
+ });
147
168
  // Hydrate memory cache from localStorage on mount
148
169
  useEffect(() => {
149
170
  if (storage === 'localStorage') {
@@ -19,7 +19,7 @@
19
19
  * still ignores `view`), and the remaining object-bound blocks will need the same
20
20
  * resolution. One resolver in the runtime layer, not one per plugin.
21
21
  */
22
- import { type ComposedElementDataSource, type ElementDataSourceConfig, type ElementSavedView } from '@object-ui/core';
22
+ import { FilterOperatorError, type ComposedElementDataSource, type ElementDataSourceConfig, type ElementSavedView } from '@object-ui/core';
23
23
  /**
24
24
  * Where a resolution attempt stands. Distinguishing `loading` from `missing`
25
25
  * matters: a component that treated "not resolved yet" as "does not exist" would
@@ -34,7 +34,12 @@ export type ElementDataSourceStatus =
34
34
  | 'loading'
35
35
  /** The named `view` was found and composed in. */
36
36
  | 'resolved'
37
- /** The named `view` does not exist on the object (or could not be read). */
37
+ /**
38
+ * The binding cannot be applied: the named `view` does not exist on the
39
+ * object (or could not be read), or a filter it combines is REFUSED by the
40
+ * filter converter — then `filterRefusal` carries the refusal (objectui#10789).
41
+ * Either way `composed` is withheld, so no query runs without the filter.
42
+ */
38
43
  | 'missing';
39
44
  export interface UseElementDataSourceResult {
40
45
  status: ElementDataSourceStatus;
@@ -50,6 +55,14 @@ export interface UseElementDataSourceResult {
50
55
  view?: ElementSavedView;
51
56
  /** Author-facing explanation, set only for `missing`. */
52
57
  error?: string;
58
+ /**
59
+ * Why the binding could not be composed, when the reason is a refused
60
+ * filter — set only with `missing`, and `error` then carries its message
61
+ * (objectui#10789). A caller that draws the malformed-filter notice reads the
62
+ * refused operator or field off it (`filterRefusalSubject`) rather than
63
+ * scraping `error`.
64
+ */
65
+ filterRefusal?: FilterOperatorError;
53
66
  }
54
67
  /**
55
68
  * Resolve a schema node's `dataSource` binding, fetching the named saved view.
@@ -20,7 +20,7 @@
20
20
  * resolution. One resolver in the runtime layer, not one per plugin.
21
21
  */
22
22
  import { useContext, useEffect, useMemo, useState } from 'react';
23
- import { collectSavedViews, composeElementDataSource, elementDataSourceViewNotFoundMessage, isElementDataSourceConfig, resolveSavedView, } from '@object-ui/core';
23
+ import { collectSavedViews, composeElementDataSource, elementDataSourceViewNotFoundMessage, FilterOperatorError, isElementDataSourceConfig, resolveSavedView, } from '@object-ui/core';
24
24
  import { SchemaRendererContext } from '../context/SchemaRendererContext.js';
25
25
  const isRecord = (v) => !!v && typeof v === 'object';
26
26
  /**
@@ -71,6 +71,9 @@ async function fetchSavedViews(dataSource, object) {
71
71
  * ```
72
72
  */
73
73
  export function useElementDataSource(schema, dataSource) {
74
+ // Read AS DECLARED (objectui#7209) — no cast between the hook and the value,
75
+ // so a member the context does not declare is a compile error here. Pinned by
76
+ // `useElementDataSource.schemaRendererContextRead-7209.test.ts`.
74
77
  const context = useContext(SchemaRendererContext);
75
78
  const adapter = (dataSource ?? context?.dataSource ?? null);
76
79
  const config = useMemo(() => {
@@ -131,11 +134,25 @@ export function useElementDataSource(schema, dataSource) {
131
134
  return { status: 'loading', config };
132
135
  if (resolved.view === null)
133
136
  return { status: 'missing', config, error: resolved.error };
134
- return {
135
- status: 'resolved',
136
- config,
137
- view: resolved.view,
138
- composed: composeElementDataSource(config, resolved.view),
139
- };
137
+ // objectui#10789 — composing the view's filter with the binding's own is a
138
+ // MERGE, and the merge lowers both through the throwing converter form.
139
+ // This is a render-time `useMemo`, so an uncaught refusal was a render
140
+ // error: the block's error boundary instead of a sentence naming the
141
+ // operator. The refusal is kept as a VALUE and reported as `missing`,
142
+ // which every caller already answers by withholding the query — never as
143
+ // `undefined`, which would read as "no filter" and widen the read. Only a
144
+ // `FilterOperatorError` is caught, the rule `toFilterNodeSafely` states:
145
+ // anything else is a defect, not a statement about the author's filter.
146
+ // (The `ready` branch above composes no view, so nothing is merged there.)
147
+ let composed;
148
+ try {
149
+ composed = composeElementDataSource(config, resolved.view);
150
+ }
151
+ catch (error) {
152
+ if (!(error instanceof FilterOperatorError))
153
+ throw error;
154
+ return { status: 'missing', config, error: error.message, filterRefusal: error };
155
+ }
156
+ return { status: 'resolved', config, view: resolved.view, composed };
140
157
  }, [config, resolved, requestKey]);
141
158
  }
@@ -60,56 +60,49 @@ export { toPredicateInput } from '@object-ui/core';
60
60
  * Build the predicate context for a **row record** — the bag to hand
61
61
  * {@link useCondition} when the thing being gated is scoped to one record.
62
62
  *
63
- * The row is bound the THREE ways the platform's row surfaces bind it:
64
- * `record.status` (spec/canonical), bare `status` (row-action shorthand), and
65
- * `data.status` (legacy). This is not three dialects; it is one rule, and it is
66
- * `evalRowPredicate`'s rule (`core/evaluator/listConditional.ts` — the record
67
- * header, list rows, the row kebab and conditional formatting all evaluate
68
- * through it), restated here for the `useCondition` tier so both tiers answer
69
- * an author's `visible:` the same way.
70
- *
71
- * ## Why a helper and not "just spread the row" (objectui#4075)
72
- *
73
- * `useCondition` evaluates on `new ExpressionEvaluator({ ...scope, ...context })`,
74
- * so a caller that passes the row spread flat resolves the shorthand spelling
75
- * and NOTHING else. The canonical spelling is the `record.` root — it is what
76
- * `ExpressionEvaluator`'s CEL path binds (`bag.record` as the record
77
- * namespace), what `evalRowPredicate` binds, and what the server enforces
78
- * with. Under a root-only bag `record.viewer.can_act` does not read as `false`:
79
- * the evaluator throws `record is not defined`, and a fail-closed `visible`
80
- * turns that throw into "hidden". A correctly-authored predicate then deletes
81
- * its own button, indistinguishably from the gate having said no.
82
- *
83
- * That was live, not theoretical: every declared action on framework's
84
- * `sys_approval_request` gates on `record.viewer.*` (framework#3310 / #3424),
85
- * so the whole server-declared approval decision set was invisible wherever
86
- * `DeclaredActionsBar` rendered until objectui#4077 bound the row this way —
87
- * and the four generic action renderers carried the same root-only binding
88
- * until objectui#4075.
89
- *
90
- * ## The canon is `record.*` (objectui#5330, ruled 2026-08-20)
91
- *
92
- * All three spellings are bound here, and that is unchanged — but they are NOT
93
- * peers. `record.*` is the CONTRACT; the bare shorthand and `data.*` are
94
- * tolerances in a deprecation window, kept because stored metadata carries
95
- * them, reported once by `warnNonCanonicalRowSpelling` from `useCondition`
96
- * below, and removable only after a stored-metadata survey sizes the window
97
- * (⛔ no removal before the survey — part of the same ruling).
98
- *
99
- * The canon states the SERVER's accept set, which is narrower than this bag:
100
- * measured on `@objectstack/formula@17.1.0`, `buildScope({ record })` mounts
101
- * exactly `['record']`, so of the three spellings this helper binds, only
102
- * `record.*` evaluates server-side — a bare field faults `Unknown variable:
103
- * status` and `data.*` faults `Unknown variable: data`. This three-way bag has
104
- * no server counterpart at all, which is exactly why the warning belongs on
105
- * this side. `@object-ui/core`'s `evaluator/rowPredicateCanon.ts` carries the
106
- * full measurement and the layer scoping (`data` stays canonical one layer
107
- * over, in a metadata-editing form — ADR-0089 D3).
108
- *
109
- * `record` and `data` are written AFTER the spread deliberately: a row that
110
- * happens to carry a field literally named `record` or `data` must not shadow
111
- * the root every predicate is written against. Same precedence as
112
- * `evalRowPredicate`.
63
+ * The row is bound ONE way: `record.status` — the canon (objectui#5330, ruled
64
+ * 2026-08-20; Phase 2 executed by `83fe6e741`). This is not a dialect choice;
65
+ * it is one rule, and it is `evalRowPredicate`'s rule
66
+ * (`core/evaluator/listConditional.ts` — the record header, list rows, the row
67
+ * kebab and conditional formatting all evaluate through it), restated here for
68
+ * the `useCondition` tier so both tiers answer an author's `visible:` the same
69
+ * way.
70
+ *
71
+ * ## The two retired spellings (`83fe6e741`)
72
+ *
73
+ * Until Phase 2 this bag also carried the row spread flat (bare `status`, the
74
+ * row-action shorthand) and as `data` (legacy), and `useCondition` below warned
75
+ * once per non-canonical spelling (Phase 1, `d1ab06f0f`). Both bindings and the
76
+ * warning are gone. A bare-field or `data.*` predicate against this bag now
77
+ * faults exactly as it always did on the server (`buildScope({ record })`
78
+ * mounts exactly `['record']`: `Unknown variable: status` / `Unknown variable:
79
+ * data`), and each `useCondition` leg applies its EXISTING fault policy — the
80
+ * throwing legs (`throwOnError`: every action `visible` — `action:button`,
81
+ * `action:menu`, `action:bar`, `DeclaredActionsBar`, and since objectui#11212
82
+ * `action:group`, `action:icon` and the related-list toolbar) hide and report
83
+ * `was hidden/disabled: its predicate threw`, naming the variable; the
84
+ * non-throwing legs fail soft to `true`. The same holds for a legacy `${data.x}` / `${x}` string: one bag
85
+ * shape, both dialects. Nothing detects a retired spelling here; it is simply
86
+ * unbound. `@object-ui/core`'s `evaluator/rowPredicateCanon.ts` carries the
87
+ * canon statement, the server measurement, the layer scoping (`data` stays
88
+ * canonical one layer over, in a metadata-editing form — ADR-0089 D3) and the
89
+ * offline detector.
90
+ *
91
+ * ## Why a helper and not "just `{ record }`" (objectui#4075 / #4080)
92
+ *
93
+ * Because the rule has two halves and every predicate face has to get both:
94
+ * the SHAPE — `record` is the row's one name, so `record.viewer.can_act`, what
95
+ * every declared action on framework's `sys_approval_request` gates on
96
+ * (framework#3310 / objectstack-ai/objectstack#3424), reaches the row — and the NO-ROW case below.
97
+ * `DeclaredActionsBar` and the four generic action renderers once carried
98
+ * inline copies of this bag and drifted (objectui#4077 / #4079); one named
99
+ * helper is what keeps a fifth copy from drifting again.
100
+ *
101
+ * Note the direction of the merge: `useCondition` evaluates on
102
+ * `new ExpressionEvaluator({ ...scope, ...context })`, so this bag's `record`
103
+ * shadows an ambient `record` a host put in the predicate scope — the row is
104
+ * the subject — while every OTHER ambient key (`features`, `user`, a host's own
105
+ * `data`) survives to the predicate as the host's own.
113
106
  *
114
107
  * This is the BINDING rule only. It deliberately does not touch the evaluation
115
108
  * entry — `useCondition` / `toPredicateInput` / `hasDeclaredVisibilityGate`
@@ -121,10 +114,10 @@ export { toPredicateInput } from '@object-ui/core';
121
114
  * ## No row → bind NOTHING, do not bind an empty one
122
115
  *
123
116
  * `null` / `undefined` / a non-object returns an EMPTY bag, not
124
- * `{ record: {}, data: {} }`. The difference is load-bearing: `useCondition`
125
- * merges this context OVER the ambient predicate scope, so binding an empty
126
- * record would blank out a `record` that a host had put in the scope itself —
127
- * which is exactly how `action:group`'s dropdown leaf is driven in
117
+ * `{ record: {} }`. The difference is load-bearing: `useCondition` merges this
118
+ * context OVER the ambient predicate scope, so binding an empty record would
119
+ * blank out a `record` that a host had put in the scope itself — which is
120
+ * exactly how `action:group`'s dropdown leaf is driven in
128
121
  * `action-group-dropdown-visible.test.tsx`, and it is a legitimate way for a
129
122
  * host to supply the row. "This surface has no row of its own" must stay
130
123
  * distinct from "this surface's row is empty"; only the latter is entitled to
@@ -159,11 +152,11 @@ export declare function useCondition(condition: string | boolean | undefined | {
159
152
  * routes to `@object-ui/core`'s `evalRowPredicate`: a bare string is CEL (the
160
153
  * spec contract for `ActionSchema.visible`), a `{ dialect: 'cel', source }`
161
154
  * envelope is always CEL, and only a legacy-dialect string falls back to the
162
- * old engine (with a deprecation warning). The row is bound as `record.*` — the
163
- * CANON (objectui#5330) — and, for stored metadata only, as bare fields and
164
- * `data.*`, both of which now warn once; the ambient predicate scope
165
- * (`features` / `user` / …) is merged alongside so deployment-level gates keep
166
- * resolving.
155
+ * old engine (with a deprecation warning). The row is bound as `record.*` only
156
+ * — the canon (objectui#5330; the bare-field and `data.*` spellings retired in
157
+ * `83fe6e741` and fault here like any unknown variable); the ambient
158
+ * predicate scope (`features` / `user` / …) is merged alongside so
159
+ * deployment-level gates keep resolving.
167
160
  *
168
161
  * @param pred The raw predicate: `boolean` (returned as-is), a CEL string,
169
162
  * an `{ dialect, source }` envelope, or `null`/`undefined`/`''`.
@@ -6,12 +6,14 @@
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
8
  import { createContext, createElement, useContext, useMemo } from 'react';
9
- import { ExpressionEvaluator, evalRowPredicate, isLegacyDialectSource, warnNonCanonicalRowSpelling, } from '@object-ui/core';
9
+ import { ExpressionEvaluator, evalRowPredicate } from '@object-ui/core';
10
10
  /**
11
11
  * Global predicate scope — populated by host shells (e.g. app-shell's
12
12
  * `ExpressionProvider`) so renderers like action buttons can resolve
13
- * deployment-level flags (`features`, `user`, `app`, …) without needing
14
- * to import the host shell directly.
13
+ * deployment-level flags (`features`, `user`, …) without needing
14
+ * to import the host shell directly. (⛔ Not `app`: objectui#8155 removed that
15
+ * root from `buildExpressionScope`, so no host shell in this repo publishes
16
+ * one.)
15
17
  *
16
18
  * `useCondition` / `useExpression` merge this scope under the
17
19
  * locally-passed `context`, so per-row `record` overrides still win.
@@ -74,56 +76,49 @@ export { toPredicateInput } from '@object-ui/core';
74
76
  * Build the predicate context for a **row record** — the bag to hand
75
77
  * {@link useCondition} when the thing being gated is scoped to one record.
76
78
  *
77
- * The row is bound the THREE ways the platform's row surfaces bind it:
78
- * `record.status` (spec/canonical), bare `status` (row-action shorthand), and
79
- * `data.status` (legacy). This is not three dialects; it is one rule, and it is
80
- * `evalRowPredicate`'s rule (`core/evaluator/listConditional.ts` — the record
81
- * header, list rows, the row kebab and conditional formatting all evaluate
82
- * through it), restated here for the `useCondition` tier so both tiers answer
83
- * an author's `visible:` the same way.
79
+ * The row is bound ONE way: `record.status` — the canon (objectui#5330, ruled
80
+ * 2026-08-20; Phase 2 executed by `83fe6e741`). This is not a dialect choice;
81
+ * it is one rule, and it is `evalRowPredicate`'s rule
82
+ * (`core/evaluator/listConditional.ts` — the record header, list rows, the row
83
+ * kebab and conditional formatting all evaluate through it), restated here for
84
+ * the `useCondition` tier so both tiers answer an author's `visible:` the same
85
+ * way.
84
86
  *
85
- * ## Why a helper and not "just spread the row" (objectui#4075)
87
+ * ## The two retired spellings (`83fe6e741`)
86
88
  *
87
- * `useCondition` evaluates on `new ExpressionEvaluator({ ...scope, ...context })`,
88
- * so a caller that passes the row spread flat resolves the shorthand spelling
89
- * and NOTHING else. The canonical spelling is the `record.` root — it is what
90
- * `ExpressionEvaluator`'s CEL path binds (`bag.record` as the record
91
- * namespace), what `evalRowPredicate` binds, and what the server enforces
92
- * with. Under a root-only bag `record.viewer.can_act` does not read as `false`:
93
- * the evaluator throws `record is not defined`, and a fail-closed `visible`
94
- * turns that throw into "hidden". A correctly-authored predicate then deletes
95
- * its own button, indistinguishably from the gate having said no.
89
+ * Until Phase 2 this bag also carried the row spread flat (bare `status`, the
90
+ * row-action shorthand) and as `data` (legacy), and `useCondition` below warned
91
+ * once per non-canonical spelling (Phase 1, `d1ab06f0f`). Both bindings and the
92
+ * warning are gone. A bare-field or `data.*` predicate against this bag now
93
+ * faults exactly as it always did on the server (`buildScope({ record })`
94
+ * mounts exactly `['record']`: `Unknown variable: status` / `Unknown variable:
95
+ * data`), and each `useCondition` leg applies its EXISTING fault policy — the
96
+ * throwing legs (`throwOnError`: every action `visible` — `action:button`,
97
+ * `action:menu`, `action:bar`, `DeclaredActionsBar`, and since objectui#11212
98
+ * `action:group`, `action:icon` and the related-list toolbar) hide and report
99
+ * `was hidden/disabled: its predicate threw`, naming the variable; the
100
+ * non-throwing legs fail soft to `true`. The same holds for a legacy `${data.x}` / `${x}` string: one bag
101
+ * shape, both dialects. Nothing detects a retired spelling here; it is simply
102
+ * unbound. `@object-ui/core`'s `evaluator/rowPredicateCanon.ts` carries the
103
+ * canon statement, the server measurement, the layer scoping (`data` stays
104
+ * canonical one layer over, in a metadata-editing form — ADR-0089 D3) and the
105
+ * offline detector.
96
106
  *
97
- * That was live, not theoretical: every declared action on framework's
98
- * `sys_approval_request` gates on `record.viewer.*` (framework#3310 / #3424),
99
- * so the whole server-declared approval decision set was invisible wherever
100
- * `DeclaredActionsBar` rendered until objectui#4077 bound the row this way —
101
- * and the four generic action renderers carried the same root-only binding
102
- * until objectui#4075.
107
+ * ## Why a helper and not "just `{ record }`" (objectui#4075 / #4080)
103
108
  *
104
- * ## The canon is `record.*` (objectui#5330, ruled 2026-08-20)
109
+ * Because the rule has two halves and every predicate face has to get both:
110
+ * the SHAPE — `record` is the row's one name, so `record.viewer.can_act`, what
111
+ * every declared action on framework's `sys_approval_request` gates on
112
+ * (framework#3310 / objectstack-ai/objectstack#3424), reaches the row — and the NO-ROW case below.
113
+ * `DeclaredActionsBar` and the four generic action renderers once carried
114
+ * inline copies of this bag and drifted (objectui#4077 / #4079); one named
115
+ * helper is what keeps a fifth copy from drifting again.
105
116
  *
106
- * All three spellings are bound here, and that is unchanged — but they are NOT
107
- * peers. `record.*` is the CONTRACT; the bare shorthand and `data.*` are
108
- * tolerances in a deprecation window, kept because stored metadata carries
109
- * them, reported once by `warnNonCanonicalRowSpelling` from `useCondition`
110
- * below, and removable only after a stored-metadata survey sizes the window
111
- * (⛔ no removal before the survey — part of the same ruling).
112
- *
113
- * The canon states the SERVER's accept set, which is narrower than this bag:
114
- * measured on `@objectstack/formula@17.1.0`, `buildScope({ record })` mounts
115
- * exactly `['record']`, so of the three spellings this helper binds, only
116
- * `record.*` evaluates server-side — a bare field faults `Unknown variable:
117
- * status` and `data.*` faults `Unknown variable: data`. This three-way bag has
118
- * no server counterpart at all, which is exactly why the warning belongs on
119
- * this side. `@object-ui/core`'s `evaluator/rowPredicateCanon.ts` carries the
120
- * full measurement and the layer scoping (`data` stays canonical one layer
121
- * over, in a metadata-editing form — ADR-0089 D3).
122
- *
123
- * `record` and `data` are written AFTER the spread deliberately: a row that
124
- * happens to carry a field literally named `record` or `data` must not shadow
125
- * the root every predicate is written against. Same precedence as
126
- * `evalRowPredicate`.
117
+ * Note the direction of the merge: `useCondition` evaluates on
118
+ * `new ExpressionEvaluator({ ...scope, ...context })`, so this bag's `record`
119
+ * shadows an ambient `record` a host put in the predicate scope — the row is
120
+ * the subject — while every OTHER ambient key (`features`, `user`, a host's own
121
+ * `data`) survives to the predicate as the host's own.
127
122
  *
128
123
  * This is the BINDING rule only. It deliberately does not touch the evaluation
129
124
  * entry — `useCondition` / `toPredicateInput` / `hasDeclaredVisibilityGate`
@@ -135,10 +130,10 @@ export { toPredicateInput } from '@object-ui/core';
135
130
  * ## No row → bind NOTHING, do not bind an empty one
136
131
  *
137
132
  * `null` / `undefined` / a non-object returns an EMPTY bag, not
138
- * `{ record: {}, data: {} }`. The difference is load-bearing: `useCondition`
139
- * merges this context OVER the ambient predicate scope, so binding an empty
140
- * record would blank out a `record` that a host had put in the scope itself —
141
- * which is exactly how `action:group`'s dropdown leaf is driven in
133
+ * `{ record: {} }`. The difference is load-bearing: `useCondition` merges this
134
+ * context OVER the ambient predicate scope, so binding an empty record would
135
+ * blank out a `record` that a host had put in the scope itself — which is
136
+ * exactly how `action:group`'s dropdown leaf is driven in
142
137
  * `action-group-dropdown-visible.test.tsx`, and it is a legitimate way for a
143
138
  * host to supply the row. "This surface has no row of its own" must stay
144
139
  * distinct from "this surface's row is empty"; only the latter is entitled to
@@ -150,8 +145,7 @@ export function usePredicateRecordContext(record) {
150
145
  return useMemo(() => {
151
146
  if (record == null || typeof record !== 'object' || Array.isArray(record))
152
147
  return {};
153
- const row = record;
154
- return { ...row, record: row, data: row };
148
+ return { record: record };
155
149
  }, [record]);
156
150
  }
157
151
  /**
@@ -188,37 +182,11 @@ export function useCondition(condition, context = {}, options) {
188
182
  const scope = usePredicateScope();
189
183
  // We evaluate directly without caching the evaluator to avoid issues with context changes
190
184
  return useMemo(() => {
191
- // objectui#5330 Phase 1 — report a deprecated row-predicate spelling on
192
- // the `useCondition` tier. This is the BINDING half's evaluation entry:
193
- // `usePredicateRecordContext` sees the row but never the predicate text,
194
- // so it structurally cannot detect a spelling, and this is the first
195
- // point where the two meet.
196
- //
197
- // Two guards, both of which can only remove a report:
198
- // - the bag must carry the `usePredicateRecordContext` signature, `data`
199
- // and `record` being the SAME object by identity. A host scope that
200
- // merely happens to carry a `data` key cannot satisfy that, so a
201
- // non-row `useCondition` call is never judged as a row predicate.
202
- // - the source must be CEL. In this tier's legacy `${…}` dialect
203
- // (`'${data.status === "active"}'` — this hook's own doc example)
204
- // `data.*` is the NORMAL spelling, and reporting it would be a false
205
- // positive on every legacy predicate.
206
- const bag = context;
207
- const boundRow = bag.record != null && typeof bag.record === 'object' && bag.record === bag.data
208
- ? bag.record
209
- : undefined;
210
- if (boundRow !== undefined) {
211
- const celSource = typeof condition === 'string'
212
- ? isLegacyDialectSource(condition)
213
- ? undefined
214
- : condition
215
- : condition && typeof condition === 'object' && condition.dialect === 'cel'
216
- ? condition.source
217
- : undefined;
218
- if (typeof celSource === 'string') {
219
- warnNonCanonicalRowSpelling(celSource, boundRow, true, options?.label);
220
- }
221
- }
185
+ // No row-spelling detector on this tier (`83fe6e741` removed the
186
+ // Phase-1 warning with the bindings): a retired bare-field / `data.*`
187
+ // spelling against a `usePredicateRecordContext` bag is simply unbound
188
+ // and faults like any other unknown variable, and each leg's existing
189
+ // fault report below is what names it.
222
190
  const evaluator = new ExpressionEvaluator({ ...scope, ...context });
223
191
  if (options?.throwOnError) {
224
192
  // Fail-closed: a predicate that can't be evaluated hides/disables
@@ -256,11 +224,11 @@ export function useCondition(condition, context = {}, options) {
256
224
  * routes to `@object-ui/core`'s `evalRowPredicate`: a bare string is CEL (the
257
225
  * spec contract for `ActionSchema.visible`), a `{ dialect: 'cel', source }`
258
226
  * envelope is always CEL, and only a legacy-dialect string falls back to the
259
- * old engine (with a deprecation warning). The row is bound as `record.*` — the
260
- * CANON (objectui#5330) — and, for stored metadata only, as bare fields and
261
- * `data.*`, both of which now warn once; the ambient predicate scope
262
- * (`features` / `user` / …) is merged alongside so deployment-level gates keep
263
- * resolving.
227
+ * old engine (with a deprecation warning). The row is bound as `record.*` only
228
+ * — the canon (objectui#5330; the bare-field and `data.*` spellings retired in
229
+ * `83fe6e741` and fault here like any unknown variable); the ambient
230
+ * predicate scope (`features` / `user` / …) is merged alongside so
231
+ * deployment-level gates keep resolving.
264
232
  *
265
233
  * @param pred The raw predicate: `boolean` (returned as-is), a CEL string,
266
234
  * an `{ dialect, source }` envelope, or `null`/`undefined`/`''`.