@object-ui/react 17.6.0 → 17.7.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 (89) hide show
  1. package/CHANGELOG.md +3766 -0
  2. package/README.md +294 -38
  3. package/dist/SchemaRenderer.d.ts +38 -7
  4. package/dist/SchemaRenderer.js +1218 -87
  5. package/dist/context/ActionContext.js +40 -1
  6. package/dist/context/DiscussionContext.d.ts +12 -0
  7. package/dist/context/DiscussionContext.js +2 -0
  8. package/dist/context/DrillNavigationContext.d.ts +13 -0
  9. package/dist/context/InlineEditContext.d.ts +81 -1
  10. package/dist/context/InlineEditContext.js +8 -2
  11. package/dist/context/RecordContext.d.ts +48 -5
  12. package/dist/context/RecordContext.js +10 -1
  13. package/dist/context/RelatedRecordActionsContext.d.ts +5 -1
  14. package/dist/context/SchemaRendererContext.d.ts +54 -2
  15. package/dist/context/SchemaRendererContext.js +38 -5
  16. package/dist/context/ThemeContext.js +1 -1
  17. package/dist/context/actionRunnerTranslator.d.ts +27 -0
  18. package/dist/context/actionRunnerTranslator.js +37 -0
  19. package/dist/element-data-source/ElementDataSourceGate.d.ts +88 -1
  20. package/dist/element-data-source/ElementDataSourceGate.js +326 -13
  21. package/dist/hooks/index.d.ts +3 -1
  22. package/dist/hooks/index.js +10 -1
  23. package/dist/hooks/useActionEngine.js +46 -3
  24. package/dist/hooks/useActionRunner.js +4 -0
  25. package/dist/hooks/useActionTextLocalizer.d.ts +21 -8
  26. package/dist/hooks/useActionTextLocalizer.js +33 -8
  27. package/dist/hooks/useConfigBagEvaluator.d.ts +30 -0
  28. package/dist/hooks/useConfigBagEvaluator.js +39 -0
  29. package/dist/hooks/useDensityMode.d.ts +6 -2
  30. package/dist/hooks/useDensityMode.js +6 -2
  31. package/dist/hooks/useDiscovery.d.ts +1 -10
  32. package/dist/hooks/useETagCache.js +25 -4
  33. package/dist/hooks/useElementDataSource.d.ts +15 -2
  34. package/dist/hooks/useElementDataSource.js +24 -7
  35. package/dist/hooks/useExpression.d.ts +52 -59
  36. package/dist/hooks/useExpression.js +58 -90
  37. package/dist/hooks/useFilterScope.d.ts +22 -2
  38. package/dist/hooks/useFilterScope.js +29 -4
  39. package/dist/hooks/useGlobalUndo.d.ts +10 -0
  40. package/dist/hooks/useGlobalUndo.js +112 -46
  41. package/dist/hooks/useNavigationOverlay.d.ts +76 -11
  42. package/dist/hooks/useNavigationOverlay.js +119 -9
  43. package/dist/hooks/useOffline.js +65 -11
  44. package/dist/hooks/usePageAssignment.d.ts +8 -5
  45. package/dist/hooks/usePageAssignment.js +37 -16
  46. package/dist/hooks/useRecordSearch.d.ts +34 -1
  47. package/dist/hooks/useRecordSearch.js +42 -7
  48. package/dist/hooks/useResolvedFilter.d.ts +46 -0
  49. package/dist/hooks/useResolvedFilter.js +130 -0
  50. package/dist/hooks/useSchemaPersistence.d.ts +7 -0
  51. package/dist/hooks/useSchemaPersistence.js +144 -5
  52. package/dist/hooks/useSettledSchema.d.ts +123 -0
  53. package/dist/hooks/useSettledSchema.js +186 -0
  54. package/dist/hooks/useViewData.d.ts +7 -2
  55. package/dist/index.d.ts +8 -1
  56. package/dist/index.js +45 -1
  57. package/dist/schema-input.d.ts +38 -8
  58. package/dist/schema-input.js +39 -7
  59. package/dist/utils/configBag.d.ts +68 -0
  60. package/dist/utils/configBag.js +70 -0
  61. package/dist/utils/error-message.d.ts +1 -1
  62. package/dist/utils/error-message.js +1 -1
  63. package/dist/utils/inlineAria.d.ts +61 -0
  64. package/dist/utils/inlineAria.js +70 -0
  65. package/dist/utils/nonGridRowCeiling.d.ts +44 -0
  66. package/dist/utils/nonGridRowCeiling.js +78 -0
  67. package/dist/utils/paramsBag.d.ts +28 -0
  68. package/dist/utils/paramsBag.js +146 -0
  69. package/dist/utils/propsBagDiagnostic.d.ts +196 -0
  70. package/dist/utils/propsBagDiagnostic.js +421 -0
  71. package/dist/utils/refusedDataPropDiagnostic.d.ts +44 -0
  72. package/dist/utils/refusedDataPropDiagnostic.js +94 -0
  73. package/dist/utils/unevaluatedExpression.d.ts +42 -1
  74. package/dist/utils/unevaluatedExpression.js +74 -11
  75. package/dist/utils/visibilityDiagnostic.d.ts +271 -19
  76. package/dist/utils/visibilityDiagnostic.js +311 -44
  77. package/package.json +10 -10
  78. package/dist/hooks/useClientNotifications.d.ts +0 -47
  79. package/dist/hooks/useClientNotifications.js +0 -162
  80. package/dist/spec-bridge/SpecBridge.d.ts +0 -28
  81. package/dist/spec-bridge/SpecBridge.js +0 -56
  82. package/dist/spec-bridge/bridges/form-view.d.ts +0 -158
  83. package/dist/spec-bridge/bridges/form-view.js +0 -170
  84. package/dist/spec-bridge/bridges/list-view.d.ts +0 -20
  85. package/dist/spec-bridge/bridges/list-view.js +0 -268
  86. package/dist/spec-bridge/index.d.ts +0 -11
  87. package/dist/spec-bridge/index.js +0 -10
  88. package/dist/spec-bridge/types.d.ts +0 -55
  89. package/dist/spec-bridge/types.js +0 -8
@@ -50,6 +50,27 @@
50
50
  * designer emits for an unconfigured column list, and supplying the columns is
51
51
  * exactly why a view was named.
52
52
  *
53
+ * The row cap answers "did the component author one?" the same way, and for the
54
+ * same reason (objectui#9899). A cap the contract REFUSES — `pageSize: 0`,
55
+ * a negative, a fraction — is not a cap this relay may treat as the author's
56
+ * intent, so it does not suppress the one the bound view supplies. Deciding
57
+ * that question by PRESENCE is how a view's legitimate cap was dropped in
58
+ * favour of the consuming renderer's own default: measured end to end on
59
+ * `list-view` before the repair, a component carrying `pagination.pageSize: 0`
60
+ * under a view supplying `7` put `$top: 100` on the wire — the view's cap never
61
+ * reached it, so the read was WIDER than the view asked for.
62
+ *
63
+ * The VIEW's own cap can be refused too, and `@object-ui/core` drops it before
64
+ * it reaches this relay (objectui#9928). The drop is right, but it left the
65
+ * consuming renderer nothing to refuse, so it went silent — and this relay
66
+ * reports the view's refusal instead (objectui#10015). See
67
+ * {@link describeRefusedViewRowLimit} for when that message applies.
68
+ *
69
+ * The BINDING's own cap follows the same rule (objectui#10016): a `limit` the
70
+ * contract refuses is not authored, so `@object-ui/core` lets the view's cap
71
+ * through in its place, and that cap is a baseline here like any other
72
+ * view-sourced value. See {@link describeRefusedBindingRowLimit}.
73
+ *
53
74
  * ## What a mapping may NOT do
54
75
  *
55
76
  * {@link ElementDataSourceMapping} names only keys the target block genuinely
@@ -79,7 +100,7 @@
79
100
  * like it works.
80
101
  */
81
102
  import * as React from 'react';
82
- import { type ElementDataSourceConfig } from '@object-ui/core';
103
+ import { type ElementDataSourceConfig, type FilterOperatorError } from '@object-ui/core';
83
104
  import { type ElementDataSourceStatus } from '../hooks/useElementDataSource.js';
84
105
  /**
85
106
  * Where a block's row cap lives. Three spellings are real in this repo and each
@@ -139,6 +160,15 @@ export interface UseElementDataSourceSchemaResult<S> {
139
160
  config?: ElementDataSourceConfig;
140
161
  /** Author-facing explanation, set only for `missing`. */
141
162
  error?: string;
163
+ /**
164
+ * The refused filter that stopped the binding from applying — set only with
165
+ * `missing`, and `error` then carries its message (objectui#10789). Either
166
+ * merge can refuse: the view's filter with the binding's own
167
+ * ({@link useElementDataSource}), or the component's filter with that result
168
+ * (this hook). {@link ElementDataSourceGate} draws the malformed-filter
169
+ * notice from it.
170
+ */
171
+ filterRefusal?: FilterOperatorError;
142
172
  }
143
173
  /**
144
174
  * Resolve a block's `dataSource` binding and apply it to the block's own schema
@@ -225,6 +255,63 @@ export declare function noDataSourceMessage(blockKey: string, objectName?: unkno
225
255
  * metadata is answerable, so the answer is rendered where the author can see it.
226
256
  */
227
257
  export declare function NoDataSourcePanel({ testId, title, message, }: ElementDataSourceStatusPanelProps): React.ReactElement;
258
+ /**
259
+ * The wrapping seam — mark `renderer` as one that wraps {@link
260
+ * ElementDataSourceGate}, so its registrations DECLARE the `dataSource` key this
261
+ * gate READS (objectui#6678).
262
+ *
263
+ * ```tsx
264
+ * export const ObjectMapRenderer = elementDataSourceBlock<React.FC<any>>(({ schema }) => (
265
+ * <ElementDataSourceGate schema={schema} testId="object-map" …>
266
+ * {(bound) => <ObjectMap schema={bound} />}
267
+ * </ElementDataSourceGate>
268
+ * ));
269
+ * ```
270
+ *
271
+ * ## What this closes
272
+ *
273
+ * `PageComponentSchema.dataSource` is the one spelling that resolves a saved
274
+ * view for an object-bound block. It works — and because no registration
275
+ * declared it, `sdui-parser`'s `validateTree` reported it with the SAME
276
+ * `unknown-prop` warning it gives the spellings that do nothing. On the tier
277
+ * built to accept AI-authored pages the diagnostic IS the contract, so the
278
+ * tier's only signal pointed away from the one key that works.
279
+ *
280
+ * ## Why a seam and not nine declarations
281
+ *
282
+ * Maintainer ruling, 2026-08-29: option B **in the injection form**. Nine
283
+ * hand-written copies across nine packages is the shape that ruling refused —
284
+ * they drift, and the tenth block forgets. Passing through here is the only
285
+ * thing a block does; `Registry.register` emits the declaration
286
+ * (`withElementDataSourceInput`), and `ELEMENT_DATA_SOURCE_INPUT` in
287
+ * `@object-ui/core` is its single copy, beside the binding's own semantics.
288
+ *
289
+ * ## And it cannot be forgotten
290
+ *
291
+ * `scripts/check-element-data-source-declaration.mjs` fails any source that
292
+ * renders `ElementDataSourceGate` for a registration without passing that
293
+ * registration's renderer through this function. That is the mechanical half of
294
+ * "a tenth block gets it automatically"; this function is the seam it enforces.
295
+ *
296
+ * Returns the renderer UNCHANGED (the mark is held in a `WeakSet`), so it is safe
297
+ * over a `React.forwardRef` object, over `React.memo`, and over a component that
298
+ * something else already re-exports by reference.
299
+ *
300
+ * ⚠️ Re-exported from `@object-ui/core` — where the marker and the declaration
301
+ * live — rather than wrapped here: ONE function under ONE name. This export
302
+ * exists for DISCOVERABILITY, so the seam is findable beside the gate it is
303
+ * about; **call sites must import it from `@object-ui/core`**, and
304
+ * `check:element-data-source-declaration` enforces that.
305
+ *
306
+ * The rule is measured, not stylistic. A registration runs at MODULE SCOPE, 101
307
+ * suites in this repo partially mock `@object-ui/react` by hand-listing the
308
+ * exports they return, and a module-scope read of a name absent from such a list
309
+ * throws at COLLECTION time — the importing test file dies before running one
310
+ * assertion. Taking the seam from here reddened 17 files across all four CI
311
+ * shards with zero failed assertions among them. Nothing mocks `@object-ui/core`,
312
+ * and every registration module already imports `ComponentRegistry` from it.
313
+ */
314
+ export { elementDataSourceBlock } from '@object-ui/core';
228
315
  export interface ElementDataSourceGateProps<S> {
229
316
  /** The block's schema node. */
230
317
  schema: S;
@@ -51,6 +51,27 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
51
51
  * designer emits for an unconfigured column list, and supplying the columns is
52
52
  * exactly why a view was named.
53
53
  *
54
+ * The row cap answers "did the component author one?" the same way, and for the
55
+ * same reason (objectui#9899). A cap the contract REFUSES — `pageSize: 0`,
56
+ * a negative, a fraction — is not a cap this relay may treat as the author's
57
+ * intent, so it does not suppress the one the bound view supplies. Deciding
58
+ * that question by PRESENCE is how a view's legitimate cap was dropped in
59
+ * favour of the consuming renderer's own default: measured end to end on
60
+ * `list-view` before the repair, a component carrying `pagination.pageSize: 0`
61
+ * under a view supplying `7` put `$top: 100` on the wire — the view's cap never
62
+ * reached it, so the read was WIDER than the view asked for.
63
+ *
64
+ * The VIEW's own cap can be refused too, and `@object-ui/core` drops it before
65
+ * it reaches this relay (objectui#9928). The drop is right, but it left the
66
+ * consuming renderer nothing to refuse, so it went silent — and this relay
67
+ * reports the view's refusal instead (objectui#10015). See
68
+ * {@link describeRefusedViewRowLimit} for when that message applies.
69
+ *
70
+ * The BINDING's own cap follows the same rule (objectui#10016): a `limit` the
71
+ * contract refuses is not authored, so `@object-ui/core` lets the view's cap
72
+ * through in its place, and that cap is a baseline here like any other
73
+ * view-sourced value. See {@link describeRefusedBindingRowLimit}.
74
+ *
54
75
  * ## What a mapping may NOT do
55
76
  *
56
77
  * {@link ElementDataSourceMapping} names only keys the target block genuinely
@@ -80,9 +101,135 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
80
101
  * like it works.
81
102
  */
82
103
  import * as React from 'react';
83
- import { isElementDataSourceConfig, mergeFilterNodes, } from '@object-ui/core';
104
+ import { elementDataSourceRefusedLimitMessage, filterRefusalSubject, isElementDataSourceConfig, mergeFilterNodes, toFilterNodeSafely, } from '@object-ui/core';
105
+ import { useObjectTranslation } from '@object-ui/i18n';
84
106
  import { useElementDataSource, } from '../hooks/useElementDataSource.js';
85
107
  import { SchemaRendererContext } from '../context/SchemaRendererContext.js';
108
+ /**
109
+ * What the contract admits as a row cap (objectui#9899).
110
+ *
111
+ * `@objectstack/spec` has already answered what `0` means for the keys this
112
+ * branch writes. Its view pagination config declares `pageSize` a positive
113
+ * integer with a default; every component row cap its component props map
114
+ * declares is `z.number().int().positive()`; and that flat `limit` is the key a
115
+ * view's `pagination.pageSize` is LOWERED INTO for the blocks that read it. So
116
+ * `0`, a negative and a fraction are not spellings this relay may assign a
117
+ * meaning to — they are values the contract refuses.
118
+ *
119
+ * ⚠️ Said precisely, because it is easy to overstate: the `object-grid` props
120
+ * face declares its OWN `pagination` key `z.unknown()`, so a save gate does not
121
+ * refuse a component-level `pagination.pageSize: 0` on that face, and the flat
122
+ * `pageSize` shorthand beside it is a bare `z.number()`. The refusal rests on
123
+ * the pagination CONFIG contract and on the `limit` the value is lowered into —
124
+ * the same ground objectui#9853, objectui#9897 and objectui#9925 already stood
125
+ * on at the renderer sites.
126
+ *
127
+ * ⛔ Deliberately a LOCAL restatement of the predicate objectui#9925 landed at
128
+ * the renderer sites, not an import and not a shared helper: hoisting it would
129
+ * be a cross-package extraction this card may not make. The cost is stated
130
+ * rather than hidden — one rule now has a spelling at every site that enforces
131
+ * it, and `git grep 'isUsableRowLimit\|isUsablePageSize'` is what enumerates
132
+ * them; the day one is meant to change, all of them are.
133
+ */
134
+ function isUsableRowLimit(value) {
135
+ return typeof value === 'number' && Number.isInteger(value) && value > 0;
136
+ }
137
+ /**
138
+ * The loud half, and the reason a RELAY owes one at all.
139
+ *
140
+ * A relay that merely declines to treat a value as authored could reasonably
141
+ * say nothing — the renderer it forwards to has its own refusal and its own
142
+ * message. Measured rather than assumed: BEFORE this repair, a `list-view`
143
+ * carrying `pagination.pageSize: 0` under a bound view was told about it, once,
144
+ * by `ListView`'s own diagnostic. AFTER it, the gate writes the view's usable
145
+ * cap, that renderer sees a value it accepts, and its message correctly goes
146
+ * silent — so the repair REMOVES the only place the author was being told.
147
+ * This restores that one message at the layer that now makes the decision.
148
+ *
149
+ * ⛔ NOT a second guard: the predicate lives once, in {@link isUsableRowLimit},
150
+ * and this reads it. `null` means "nothing to say" — an absent cap is not a
151
+ * mistake and a usable one is not either, which is what keeps this from
152
+ * becoming an always-on marker that states nothing.
153
+ */
154
+ const describeDisplacedRowLimit = (authored, key, used, componentType, objectName) => {
155
+ if (authored === undefined || authored === null)
156
+ return null;
157
+ if (isUsableRowLimit(authored))
158
+ return null;
159
+ const type = typeof componentType === 'string' && componentType ? componentType : 'component';
160
+ const where = typeof objectName === 'string' && objectName ? `${type} on ${objectName}` : type;
161
+ return (`[ObjectUI] ElementDataSourceGate row cap: ${where} declared ${key}: ${String(authored)}, `
162
+ + 'which is not a positive integer. A row cap must be a positive integer '
163
+ + '(the spec refuses zero and negative values), so it was ignored and the cap '
164
+ + `from this component's bound data source (${used}) was used instead.`);
165
+ };
166
+ /**
167
+ * The loud half for a cap the SAVED VIEW declared and the contract refuses
168
+ * (objectui#10015).
169
+ *
170
+ * `@object-ui/core` drops such a cap while composing, so `composed.limit`
171
+ * never carries it and the renderer below never sees it. Its own diagnostic
172
+ * treats an absent key as "not a mistake", so after that drop nothing said
173
+ * anything. The wording is the core builder's, not this file's: it is the one
174
+ * sentence both consumers of the composed binding use, and `ViewDataProvider`
175
+ * already reports it on the path with no renderer. The builder is pure because
176
+ * the composer runs inside a `useMemo`; this relay reports its result from an
177
+ * effect, the same way it reports {@link describeDisplacedRowLimit}.
178
+ *
179
+ * It speaks only when the refusal CHANGED what this block receives — when a
180
+ * usable view cap would have been written here:
181
+ *
182
+ * - the binding declared no usable `limit` of its own (if it did, the
183
+ * binding's cap is what the block gets whatever the view carries). The
184
+ * builder applies this condition itself, because `ViewDataProvider` needs
185
+ * it too (objectui#10016), so this relay hands it the binding; and
186
+ * - the component's own cap is not a usable one (a usable one wins over any
187
+ * view cap).
188
+ *
189
+ * In both of those excluded cases the builder's sentence — the block falls
190
+ * back to its own default — would not be true, so silence is the honest
191
+ * answer, not a gap. The caller adds the third condition: the block reads a
192
+ * row cap at all.
193
+ *
194
+ * ## Never together with {@link describeDisplacedRowLimit}
195
+ *
196
+ * That message needs a usable cap to have been WRITTEN. With a refused view
197
+ * cap, the only usable cap left is a binding's usable `limit`, and that is
198
+ * excluded above. So the two are exclusive by construction. When the component
199
+ * AND the view both carry a refused cap, this message names the view and the
200
+ * renderer names the component: two declarations, one message each.
201
+ */
202
+ const describeRefusedViewRowLimit = (view, config, authored) => {
203
+ if (!config)
204
+ return null;
205
+ if (isUsableRowLimit(authored))
206
+ return null;
207
+ return elementDataSourceRefusedLimitMessage(view, config.view, config.object, config);
208
+ };
209
+ /**
210
+ * The loud half for a cap the BINDING declared and the contract refuses
211
+ * (objectui#10016), in the core builder's words for that operand.
212
+ *
213
+ * `@object-ui/core` treats such a cap as not authored: it lets the view's
214
+ * usable cap through in its place, or none. Nothing downstream can see the
215
+ * refused value any more, so this relay reports it, from an effect, the same
216
+ * way it reports the view's refusal.
217
+ *
218
+ * It needs no condition beyond the caller's (the block reads a row cap at
219
+ * all). A usable binding cap is written whatever the component or the view
220
+ * carries, so a refused one is always a cap this block did not get.
221
+ *
222
+ * It can fire beside the other two, and that is one message per declaration,
223
+ * not two per mistake: beside {@link describeRefusedViewRowLimit} when the view
224
+ * ALSO carries a refused cap, and beside {@link describeDisplacedRowLimit} when
225
+ * the component ALSO carries a refused cap and the view's usable one displaced
226
+ * it.
227
+ */
228
+ const describeRefusedBindingRowLimit = (view, config) => {
229
+ if (!config)
230
+ return null;
231
+ return elementDataSourceRefusedLimitMessage(view, config.view, config.object, config, 'binding');
232
+ };
86
233
  const readLimit = (base, key) => {
87
234
  if (key === 'pagination.pageSize')
88
235
  return base.pagination?.pageSize;
@@ -122,10 +269,16 @@ export function useElementDataSourceSchema(schema, mapping = {}, dataSource) {
122
269
  const { object: objectKey = 'objectName', columns, filter, sort, limit, viewType } = mapping;
123
270
  const mapped = React.useMemo(() => {
124
271
  const composed = binding.composed;
272
+ // BY REFERENCE when there is nothing to apply — a fresh object every render
273
+ // would remount the block and refetch. The wrapper is memoised alongside it,
274
+ // so the identity this carries is the one the caller sees.
125
275
  if (!composed)
126
- return schema;
276
+ return { schema, capMessage: null, viewCapMessage: null, bindingCapMessage: null };
127
277
  const base = (schema ?? {});
128
278
  const next = { ...base };
279
+ let capMessage = null;
280
+ let viewCapMessage = null;
281
+ let bindingCapMessage = null;
129
282
  if (objectKey !== false)
130
283
  next[objectKey] = composed.object;
131
284
  if (columns) {
@@ -139,7 +292,23 @@ export function useElementDataSourceSchema(schema, mapping = {}, dataSource) {
139
292
  // Component filter AND (view filter AND binding filter). `composed.filter`
140
293
  // already carries the latter pair; a single surviving source comes back
141
294
  // unwrapped, so the common "only the view filters" case stays flat.
142
- const merged = mergeFilterNodes(base.filter, composed.filter);
295
+ //
296
+ // ⚠️ Each source is lowered through `toFilterNodeSafely` first, the way
297
+ // `RelatedList` and `LineItemsPanel` lower their own filter before they
298
+ // merge it — objectui#10789. This is a RENDER-time `useMemo`, and the
299
+ // lowering refuses malformed authored shapes with a `FilterOperatorError`:
300
+ // uncaught, that was a render error, thrown BEFORE the wrapped block
301
+ // reached its own safe lowering and its malformed-filter state. The
302
+ // refusal is returned as a VALUE and the block is not rendered at all —
303
+ // never merged as "no filter", which would run it unconstrained. The
304
+ // merge below then only re-reads nodes the lowering already produced.
305
+ const own = toFilterNodeSafely(base.filter);
306
+ if (!own.ok)
307
+ return { schema, capMessage: null, viewCapMessage: null, bindingCapMessage: null, filterRefusal: own.refusal };
308
+ const bound = toFilterNodeSafely(composed.filter);
309
+ if (!bound.ok)
310
+ return { schema, capMessage: null, viewCapMessage: null, bindingCapMessage: null, filterRefusal: bound.refusal };
311
+ const merged = mergeFilterNodes(own.node, bound.node);
143
312
  if (merged !== undefined)
144
313
  next.filter = merged;
145
314
  else
@@ -153,22 +322,65 @@ export function useElementDataSourceSchema(schema, mapping = {}, dataSource) {
153
322
  next.sort = composed.sort;
154
323
  }
155
324
  if (limit && composed.limit !== undefined) {
156
- const fromView = binding.config?.limit === undefined;
157
- if (!fromView || readLimit(base, limit) === undefined) {
325
+ // For the row cap, "declared one" means a cap the contract admits
326
+ // (objectui#10016): a refused binding `limit` is not authored, so the
327
+ // composer let the view's cap through in its place, and that cap is a
328
+ // baseline the component's usable cap still wins over.
329
+ const fromView = !isUsableRowLimit(binding.config?.limit);
330
+ // PRESENCE is not authorship (objectui#9899) — the same question the
331
+ // `columns` branch above answers by CONTENT, answered the same way here.
332
+ const authored = readLimit(base, limit);
333
+ if (!fromView || !isUsableRowLimit(authored)) {
158
334
  writeLimit(next, base, limit, composed.limit);
335
+ capMessage = describeDisplacedRowLimit(authored, limit, composed.limit, base.type, composed.object);
159
336
  }
160
337
  }
338
+ // Outside the branch above on purpose: a refused cap never reaches
339
+ // `composed.limit`, so that branch does not run for it (objectui#10015,
340
+ // and objectui#10016 for the binding's).
341
+ if (limit) {
342
+ viewCapMessage = describeRefusedViewRowLimit(binding.view, binding.config, readLimit(base, limit));
343
+ bindingCapMessage = describeRefusedBindingRowLimit(binding.view, binding.config);
344
+ }
161
345
  if (viewType && composed.viewType !== undefined && base.viewType === undefined) {
162
346
  next.viewType = composed.viewType;
163
347
  }
164
- return next;
165
- }, [schema, binding.composed, binding.config, objectKey, columns, filter, sort, limit, viewType]);
166
- return React.useMemo(() => ({
167
- status: binding.status,
168
- schema: mapped,
169
- config: binding.config,
170
- error: binding.error,
171
- }), [binding.status, binding.config, binding.error, mapped]);
348
+ return { schema: next, capMessage, viewCapMessage, bindingCapMessage };
349
+ }, [schema, binding.composed, binding.config, binding.view, objectKey, columns, filter, sort, limit, viewType]);
350
+ // Keyed on the MESSAGE, so it is one warning per declaration rather than one
351
+ // per render — and it fires from an effect, never from render, which is the
352
+ // same shape the renderer sites use for "you declared it, we dropped it".
353
+ // One effect per message, so a change to one never re-emits another.
354
+ const { schema: boundSchema, capMessage, viewCapMessage, bindingCapMessage, filterRefusal: mergeRefusal } = mapped;
355
+ React.useEffect(() => {
356
+ if (capMessage)
357
+ console.warn(capMessage);
358
+ }, [capMessage]);
359
+ React.useEffect(() => {
360
+ if (viewCapMessage)
361
+ console.warn(viewCapMessage);
362
+ }, [viewCapMessage]);
363
+ React.useEffect(() => {
364
+ if (bindingCapMessage)
365
+ console.warn(bindingCapMessage);
366
+ }, [bindingCapMessage]);
367
+ // A refused filter — from either merge — is `missing`, the one state every
368
+ // caller already answers by withholding the block (objectui#10789).
369
+ const filterRefusal = binding.filterRefusal ?? mergeRefusal;
370
+ return React.useMemo(() => filterRefusal
371
+ ? {
372
+ status: 'missing',
373
+ schema: boundSchema,
374
+ config: binding.config,
375
+ error: filterRefusal.message,
376
+ filterRefusal,
377
+ }
378
+ : {
379
+ status: binding.status,
380
+ schema: boundSchema,
381
+ config: binding.config,
382
+ error: binding.error,
383
+ }, [binding.status, binding.config, binding.error, boundSchema, filterRefusal]);
172
384
  }
173
385
  /**
174
386
  * The "named view does not resolve" panel. Same posture (and shape) as
@@ -178,6 +390,41 @@ export function useElementDataSourceSchema(schema, mapping = {}, dataSource) {
178
390
  export function ElementDataSourceErrorPanel({ testId, title = 'This component’s data source could not be resolved', message, }) {
179
391
  return (_jsxs("div", { className: "p-4 border border-red-500 rounded text-red-500 bg-red-50 my-2", role: "alert", "data-testid": `${testId}-datasource-error`, children: [_jsx("p", { className: "font-medium", children: title }), message ? _jsx("p", { className: "text-sm mt-1", children: message }) : null] }));
180
392
  }
393
+ /**
394
+ * The malformed-filter notice, drawn IN PLACE OF the block when a filter the
395
+ * gate had to merge is refused (objectui#10789).
396
+ *
397
+ * It is the notice the wrapped blocks draw themselves (objectui#9050 step 2 —
398
+ * `record:related_list`, the line-items panel, `object-grid`): the same
399
+ * sentence (`view.malformedFilter`), naming the refused operator or field in a
400
+ * separately addressable headline, with the refusal's own message beneath it.
401
+ * The gate draws it because its merge refuses FIRST — before the block's own
402
+ * safe lowering ever runs — so without it the author would get the error
403
+ * boundary's "failed to render" instead of the sentence those blocks give.
404
+ *
405
+ * ⚠️ A separate component, so `useObjectTranslation` runs only on this path:
406
+ * the gate wraps every object-bound block, and a translation hook in the
407
+ * gate's own body would run for all of them, including under the many suites
408
+ * that mock `@object-ui/i18n` by hand.
409
+ *
410
+ * The provider-less default is the sentence itself, written the way the
411
+ * sibling blocks carry it: a LITERAL copy of the `en` pack's value, never a
412
+ * read of the pack object. The siblings keep theirs in a module-scope
413
+ * `createSafeTranslation` table, which this module cannot have: it is
414
+ * re-exported from the package entry, so a factory call at module scope would
415
+ * run on import for every consumer (`NonGridRowCeilingNote` records that
416
+ * hazard). So the copy sits in the call's inline `defaultValue`, where
417
+ * `pnpm check:i18n-keys` holds it byte-identical to the pack, as the siblings'
418
+ * tables are held. With an `I18nProvider` the pack's value wins, and without
419
+ * one this literal is interpolated (objectui#6219).
420
+ */
421
+ function ElementDataSourceMalformedFilterPanel({ testId, refusal, }) {
422
+ const { t } = useObjectTranslation();
423
+ return (_jsxs("div", { role: "alert", className: "rounded-md border border-amber-300 bg-amber-50 p-3 my-2 text-sm text-amber-800", "data-testid": `${testId}-malformed-filter`, children: [_jsx("p", { className: "font-medium", "data-testid": `${testId}-malformed-filter-subject`, children: t('view.malformedFilter', {
424
+ subject: filterRefusalSubject(refusal) ?? '',
425
+ defaultValue: 'This view’s filter is malformed, so no records are shown: the {{subject}} condition cannot be applied.',
426
+ }) }), _jsx("p", { className: "mt-1 text-xs opacity-80", children: refusal.message })] }));
427
+ }
181
428
  /**
182
429
  * The "saved views are still being fetched" placeholder. Distinct from the error
183
430
  * panel because a component that treated "not resolved yet" as "does not exist"
@@ -203,6 +450,9 @@ export function ElementDataSourceLoadingPanel({ testId, }) {
203
450
  * it off and you get `unknown`, which is the honest default.
204
451
  */
205
452
  export function useResolvedDataSource(explicit) {
453
+ // Read AS DECLARED (objectui#7209): the only cast in this hook is the
454
+ // caller's `T` on the way out, never one erasing the context on the way in.
455
+ // Pinned by `useResolvedDataSource.schemaRendererContextRead-7209.test.ts`.
206
456
  const context = React.useContext(SchemaRendererContext);
207
457
  const named = isElementDataSourceConfig(explicit) ? undefined : explicit;
208
458
  return (named ?? context?.dataSource ?? undefined);
@@ -244,6 +494,63 @@ export function noDataSourceMessage(blockKey, objectName) {
244
494
  export function NoDataSourcePanel({ testId, title = 'No data source resolved', message, }) {
245
495
  return (_jsxs("div", { className: "p-4 border border-red-500 rounded text-red-500 bg-red-50 my-2", role: "alert", "data-testid": `${testId}-no-data-source`, children: [_jsx("p", { className: "font-medium", children: title }), message ? _jsx("p", { className: "text-sm mt-1", children: message }) : null] }));
246
496
  }
497
+ /**
498
+ * The wrapping seam — mark `renderer` as one that wraps {@link
499
+ * ElementDataSourceGate}, so its registrations DECLARE the `dataSource` key this
500
+ * gate READS (objectui#6678).
501
+ *
502
+ * ```tsx
503
+ * export const ObjectMapRenderer = elementDataSourceBlock<React.FC<any>>(({ schema }) => (
504
+ * <ElementDataSourceGate schema={schema} testId="object-map" …>
505
+ * {(bound) => <ObjectMap schema={bound} />}
506
+ * </ElementDataSourceGate>
507
+ * ));
508
+ * ```
509
+ *
510
+ * ## What this closes
511
+ *
512
+ * `PageComponentSchema.dataSource` is the one spelling that resolves a saved
513
+ * view for an object-bound block. It works — and because no registration
514
+ * declared it, `sdui-parser`'s `validateTree` reported it with the SAME
515
+ * `unknown-prop` warning it gives the spellings that do nothing. On the tier
516
+ * built to accept AI-authored pages the diagnostic IS the contract, so the
517
+ * tier's only signal pointed away from the one key that works.
518
+ *
519
+ * ## Why a seam and not nine declarations
520
+ *
521
+ * Maintainer ruling, 2026-08-29: option B **in the injection form**. Nine
522
+ * hand-written copies across nine packages is the shape that ruling refused —
523
+ * they drift, and the tenth block forgets. Passing through here is the only
524
+ * thing a block does; `Registry.register` emits the declaration
525
+ * (`withElementDataSourceInput`), and `ELEMENT_DATA_SOURCE_INPUT` in
526
+ * `@object-ui/core` is its single copy, beside the binding's own semantics.
527
+ *
528
+ * ## And it cannot be forgotten
529
+ *
530
+ * `scripts/check-element-data-source-declaration.mjs` fails any source that
531
+ * renders `ElementDataSourceGate` for a registration without passing that
532
+ * registration's renderer through this function. That is the mechanical half of
533
+ * "a tenth block gets it automatically"; this function is the seam it enforces.
534
+ *
535
+ * Returns the renderer UNCHANGED (the mark is held in a `WeakSet`), so it is safe
536
+ * over a `React.forwardRef` object, over `React.memo`, and over a component that
537
+ * something else already re-exports by reference.
538
+ *
539
+ * ⚠️ Re-exported from `@object-ui/core` — where the marker and the declaration
540
+ * live — rather than wrapped here: ONE function under ONE name. This export
541
+ * exists for DISCOVERABILITY, so the seam is findable beside the gate it is
542
+ * about; **call sites must import it from `@object-ui/core`**, and
543
+ * `check:element-data-source-declaration` enforces that.
544
+ *
545
+ * The rule is measured, not stylistic. A registration runs at MODULE SCOPE, 101
546
+ * suites in this repo partially mock `@object-ui/react` by hand-listing the
547
+ * exports they return, and a module-scope read of a name absent from such a list
548
+ * throws at COLLECTION time — the importing test file dies before running one
549
+ * assertion. Taking the seam from here reddened 17 files across all four CI
550
+ * shards with zero failed assertions among them. Nothing mocks `@object-ui/core`,
551
+ * and every registration module already imports `ComponentRegistry` from it.
552
+ */
553
+ export { elementDataSourceBlock } from '@object-ui/core';
247
554
  /**
248
555
  * Wrap an object-bound block so its `dataSource` binding reaches the keys it
249
556
  * reads, and so the two non-final resolution states render once, the same way,
@@ -264,6 +571,12 @@ export function ElementDataSourceGate({ schema, mapping, dataSource, testId, err
264
571
  if (requiresDataSource && adapter == null) {
265
572
  return _jsx(NoDataSourcePanel, { testId: testId, message: noDataSourceText });
266
573
  }
574
+ // A refused filter is `missing` too, but it gets the malformed-filter notice
575
+ // rather than the "could not be resolved" panel: the binding resolved, and
576
+ // what the author has to fix is a filter condition (objectui#10789).
577
+ if (bound.filterRefusal) {
578
+ return _jsx(ElementDataSourceMalformedFilterPanel, { testId: testId, refusal: bound.filterRefusal });
579
+ }
267
580
  if (bound.status === 'missing') {
268
581
  return _jsx(ElementDataSourceErrorPanel, { testId: testId, title: errorTitle, message: bound.error });
269
582
  }
@@ -7,6 +7,7 @@
7
7
  */
8
8
  export * from './useExpression.js';
9
9
  export * from './useFilterScope.js';
10
+ export * from './useResolvedFilter.js';
10
11
  export * from './useActionRunner.js';
11
12
  export * from './useNavigationOverlay.js';
12
13
  export * from './usePageVariables.js';
@@ -23,7 +24,6 @@ export * from './useReducedMotion.js';
23
24
  export * from './useAnimation.js';
24
25
  export * from './useDensityMode.js';
25
26
  export * from './useViewSharing.js';
26
- export * from './useClientNotifications.js';
27
27
  export * from './useOffline.js';
28
28
  export * from './usePerformance.js';
29
29
  export * from './usePerformanceBudget.js';
@@ -36,7 +36,9 @@ export * from './useDebugMode.js';
36
36
  export * from './useActionEngine.js';
37
37
  export * from './useActionTextLocalizer.js';
38
38
  export * from './useCapabilityGate.js';
39
+ export * from './useConfigBagEvaluator.js';
39
40
  export * from './useDatasetDimensionLabels.js';
40
41
  export * from './useDataRefresh.js';
41
42
  export * from './usePageAssignment.js';
42
43
  export * from './useRecordSearch.js';
44
+ export * from './useSettledSchema.js';
@@ -8,6 +8,9 @@
8
8
  export * from './useExpression.js';
9
9
  // Session scope for filter placeholders ({current_user_id}/{current_org_id}).
10
10
  export * from './useFilterScope.js';
11
+ // The ONE hold a data node resolves its own authored `filter` through
12
+ // (objectui#10666), fed by `useFilterScope()`.
13
+ export * from './useResolvedFilter.js';
11
14
  export * from './useActionRunner.js';
12
15
  export * from './useNavigationOverlay.js';
13
16
  export * from './usePageVariables.js';
@@ -25,7 +28,6 @@ export * from './useReducedMotion.js';
25
28
  export * from './useAnimation.js';
26
29
  export * from './useDensityMode.js';
27
30
  export * from './useViewSharing.js';
28
- export * from './useClientNotifications.js';
29
31
  export * from './useOffline.js';
30
32
  export * from './usePerformance.js';
31
33
  export * from './usePerformanceBudget.js';
@@ -40,6 +42,9 @@ export * from './useActionEngine.js';
40
42
  // authored strings an action carries (label / confirmText / successMessage).
41
43
  export * from './useActionTextLocalizer.js';
42
44
  export * from './useCapabilityGate.js';
45
+ // The `SchemaRenderer` memo's `properties` evaluation, for an action container's
46
+ // members, which are drawn without `SchemaRenderer` (objectui#10290).
47
+ export * from './useConfigBagEvaluator.js';
43
48
  // The analytics label net's React glue, consumed by BOTH plugin-dashboard's
44
49
  // `DatasetWidget` and plugin-report's dataset block (objectui#4389). It lives
45
50
  // here rather than in `@object-ui/core` because it reads `SchemaRendererContext`
@@ -48,3 +53,7 @@ export * from './useDatasetDimensionLabels.js';
48
53
  export * from './useDataRefresh.js';
49
54
  export * from './usePageAssignment.js';
50
55
  export * from './useRecordSearch.js';
56
+ // The settled-schema RESOLUTION half shared across ObjectKanban / ObjectView /
57
+ // ObjectCalendar's hand copies (objectui#6482, maintainer ruling Option A).
58
+ // Gate PLACEMENT stays per-component — see the hook's own doc comment.
59
+ export * from './useSettledSchema.js';