@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
@@ -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,38 +322,172 @@ 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
175
387
  * `SchemaRenderer`'s "Unknown component type": authored metadata pointing at
176
388
  * something that is not there, reported where the author can see it.
177
389
  */
178
- export function ElementDataSourceErrorPanel({ testId, title = 'This component’s data source could not be resolved', message, }) {
179
- 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] }));
390
+ export function ElementDataSourceErrorPanel({ testId, title, message, }) {
391
+ // objectui#12920 — the default reads the bundle; it was an English literal.
392
+ // Only an ABSENT `title` takes it, as the parameter default did. The hook
393
+ // runs on this error path only, for the reason
394
+ // {@link ElementDataSourceMalformedFilterPanel} gives, and the inline
395
+ // `defaultValue` is the `en` pack's value (`pnpm check:i18n-keys`).
396
+ const { t } = useObjectTranslation();
397
+ const heading = title === undefined
398
+ ? t('element.component.sourceUnresolved', {
399
+ defaultValue: 'This component’s data source could not be resolved',
400
+ })
401
+ : title;
402
+ 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: heading }), message ? _jsx("p", { className: "text-sm mt-1", children: message }) : null] }));
180
403
  }
404
+ /**
405
+ * The malformed-filter notice, drawn IN PLACE OF the block when a filter the
406
+ * gate had to merge is refused (objectui#10789).
407
+ *
408
+ * It is the notice the wrapped blocks draw themselves (objectui#9050 step 2 —
409
+ * `record:related_list`, the line-items panel, `object-grid`): the same
410
+ * sentence (`view.malformedFilter`), naming the refused operator or field in a
411
+ * separately addressable headline, with the refusal's own message beneath it.
412
+ * The gate draws it because its merge refuses FIRST — before the block's own
413
+ * safe lowering ever runs — so without it the author would get the error
414
+ * boundary's "failed to render" instead of the sentence those blocks give.
415
+ *
416
+ * ⚠️ A separate component, so `useObjectTranslation` runs only on this path:
417
+ * the gate wraps every object-bound block, and a translation hook in the
418
+ * gate's own body would run for all of them, including under the many suites
419
+ * that mock `@object-ui/i18n` by hand.
420
+ *
421
+ * The provider-less default is the sentence itself, written the way the
422
+ * sibling blocks carry it: a LITERAL copy of the `en` pack's value, never a
423
+ * read of the pack object. The siblings keep theirs in a module-scope
424
+ * `createSafeTranslation` table, which this module cannot have: it is
425
+ * re-exported from the package entry, so a factory call at module scope would
426
+ * run on import for every consumer (`NonGridRowCeilingNote` records that
427
+ * hazard). So the copy sits in the call's inline `defaultValue`, where
428
+ * `pnpm check:i18n-keys` holds it byte-identical to the pack, as the siblings'
429
+ * tables are held. With an `I18nProvider` the pack's value wins, and without
430
+ * one this literal is interpolated (objectui#6219).
431
+ */
432
+ function ElementDataSourceMalformedFilterPanel({ testId, refusal, }) {
433
+ const { t } = useObjectTranslation();
434
+ 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', {
435
+ subject: filterRefusalSubject(refusal) ?? '',
436
+ defaultValue: 'This view’s filter is malformed, so no records are shown: the {{subject}} condition cannot be applied.',
437
+ }) }), _jsx("p", { className: "mt-1 text-xs opacity-80", children: refusal.message })] }));
438
+ }
439
+ /**
440
+ * The "no object named" hint, drawn IN PLACE OF the block when a placement
441
+ * that opted in with `requiresObject` names its object in neither place: not
442
+ * on the block's own object key, and not through `dataSource.object`
443
+ * (objectui#11605).
444
+ *
445
+ * Why it exists: the object-bound registrations no longer declare their object
446
+ * key `required`, because the binding can supply it and the manifest has no
447
+ * "this key or that binding" form. So the page compile accepts a node that
448
+ * names no object at all, and this hint is the one signal left for it. Before
449
+ * it, those blocks drew an empty board, a field-less form, a dash or a "no
450
+ * data yet" state, which reads as an empty query rather than a missing object.
451
+ *
452
+ * The wording is `element:number`'s own no-object notice (objectui#10951) with
453
+ * the property as a hole, so the two blocks that answer this question say it
454
+ * the same way. The property name is interpolated, never translated.
455
+ *
456
+ * A separate component for the reason {@link ElementDataSourceMalformedFilterPanel}
457
+ * gives: `useObjectTranslation` runs only on this path, and the provider-less
458
+ * default is a literal copy of the `en` pack's value, held byte-identical to it
459
+ * by `pnpm check:i18n-keys`.
460
+ */
461
+ function ElementDataSourceNoObjectPanel({ testId, property, }) {
462
+ const { t } = useObjectTranslation();
463
+ return (_jsx("div", { className: "p-4 text-sm text-muted-foreground my-2", "data-testid": `${testId}-no-object`, children: t('view.noObject', {
464
+ property,
465
+ defaultValue: 'No object named: set {{property}} or dataSource.object.',
466
+ }) }));
467
+ }
468
+ /**
469
+ * Does the bound schema name an object on `key`? A blank string names nothing,
470
+ * the same reading `noDataSourceMessage` gives an object name.
471
+ */
472
+ const namesObject = (schema, key) => {
473
+ const value = schema?.[key];
474
+ return typeof value === 'string' && value.trim().length > 0;
475
+ };
181
476
  /**
182
477
  * The "saved views are still being fetched" placeholder. Distinct from the error
183
478
  * panel because a component that treated "not resolved yet" as "does not exist"
184
479
  * would flash a configuration error on every mount.
480
+ *
481
+ * Its copy reads `common.loading` (objectui#12889), the key the rest of the
482
+ * tree reads for a bare "Loading…": it printed an English "Loading view…" on
483
+ * every locale before. `useObjectTranslation` runs only while a view resolves,
484
+ * for the reason {@link ElementDataSourceMalformedFilterPanel} gives, and the
485
+ * inline `defaultValue` is the `en` pack's value, held byte-identical to it by
486
+ * `pnpm check:i18n-keys`.
185
487
  */
186
488
  export function ElementDataSourceLoadingPanel({ testId, }) {
187
- return (_jsx("div", { className: "p-2 text-sm text-muted-foreground animate-pulse", role: "status", "aria-live": "polite", "data-testid": `${testId}-resolving-view`, children: "Loading view\u2026" }));
489
+ const { t } = useObjectTranslation();
490
+ return (_jsx("div", { className: "p-2 text-sm text-muted-foreground animate-pulse", role: "status", "aria-live": "polite", "data-testid": `${testId}-resolving-view`, children: t('common.loading', { defaultValue: 'Loading…' }) }));
188
491
  }
189
492
  /**
190
493
  * The adapter this block would use, resolved the one way every block in the
@@ -203,6 +506,9 @@ export function ElementDataSourceLoadingPanel({ testId, }) {
203
506
  * it off and you get `unknown`, which is the honest default.
204
507
  */
205
508
  export function useResolvedDataSource(explicit) {
509
+ // Read AS DECLARED (objectui#7209): the only cast in this hook is the
510
+ // caller's `T` on the way out, never one erasing the context on the way in.
511
+ // Pinned by `useResolvedDataSource.schemaRendererContextRead-7209.test.ts`.
206
512
  const context = React.useContext(SchemaRendererContext);
207
513
  const named = isElementDataSourceConfig(explicit) ? undefined : explicit;
208
514
  return (named ?? context?.dataSource ?? undefined);
@@ -241,9 +547,72 @@ export function noDataSourceMessage(blockKey, objectName) {
241
547
  * WIRING one level up. Same posture as {@link ElementDataSourceErrorPanel}: the
242
548
  * metadata is answerable, so the answer is rendered where the author can see it.
243
549
  */
244
- export function NoDataSourcePanel({ testId, title = 'No data source resolved', message, }) {
245
- 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] }));
550
+ export function NoDataSourcePanel({ testId, title, message, }) {
551
+ // objectui#12920 — same reading as {@link ElementDataSourceErrorPanel}: an
552
+ // absent `title` reads the bundle, a passed one wins.
553
+ const { t } = useObjectTranslation();
554
+ const heading = title === undefined
555
+ ? t('element.component.noDataSource', { defaultValue: 'No data source resolved' })
556
+ : title;
557
+ 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: heading }), message ? _jsx("p", { className: "text-sm mt-1", children: message }) : null] }));
246
558
  }
559
+ /**
560
+ * The wrapping seam — mark `renderer` as one that wraps {@link
561
+ * ElementDataSourceGate}, so its registrations DECLARE the `dataSource` key this
562
+ * gate READS (objectui#6678).
563
+ *
564
+ * ```tsx
565
+ * export const ObjectMapRenderer = elementDataSourceBlock<React.FC<any>>(({ schema }) => (
566
+ * <ElementDataSourceGate schema={schema} testId="object-map" …>
567
+ * {(bound) => <ObjectMap schema={bound} />}
568
+ * </ElementDataSourceGate>
569
+ * ));
570
+ * ```
571
+ *
572
+ * ## What this closes
573
+ *
574
+ * `PageComponentSchema.dataSource` is the one spelling that resolves a saved
575
+ * view for an object-bound block. It works — and because no registration
576
+ * declared it, `sdui-parser`'s `validateTree` reported it with the SAME
577
+ * `unknown-prop` warning it gives the spellings that do nothing. On the tier
578
+ * built to accept AI-authored pages the diagnostic IS the contract, so the
579
+ * tier's only signal pointed away from the one key that works.
580
+ *
581
+ * ## Why a seam and not nine declarations
582
+ *
583
+ * Maintainer ruling, 2026-08-29: option B **in the injection form**. Nine
584
+ * hand-written copies across nine packages is the shape that ruling refused —
585
+ * they drift, and the tenth block forgets. Passing through here is the only
586
+ * thing a block does; `Registry.register` emits the declaration
587
+ * (`withElementDataSourceInput`), and `ELEMENT_DATA_SOURCE_INPUT` in
588
+ * `@object-ui/core` is its single copy, beside the binding's own semantics.
589
+ *
590
+ * ## And it cannot be forgotten
591
+ *
592
+ * `scripts/check-element-data-source-declaration.mjs` fails any source that
593
+ * renders `ElementDataSourceGate` for a registration without passing that
594
+ * registration's renderer through this function. That is the mechanical half of
595
+ * "a tenth block gets it automatically"; this function is the seam it enforces.
596
+ *
597
+ * Returns the renderer UNCHANGED (the mark is held in a `WeakSet`), so it is safe
598
+ * over a `React.forwardRef` object, over `React.memo`, and over a component that
599
+ * something else already re-exports by reference.
600
+ *
601
+ * ⚠️ Re-exported from `@object-ui/core` — where the marker and the declaration
602
+ * live — rather than wrapped here: ONE function under ONE name. This export
603
+ * exists for DISCOVERABILITY, so the seam is findable beside the gate it is
604
+ * about; **call sites must import it from `@object-ui/core`**, and
605
+ * `check:element-data-source-declaration` enforces that.
606
+ *
607
+ * The rule is measured, not stylistic. A registration runs at MODULE SCOPE, 101
608
+ * suites in this repo partially mock `@object-ui/react` by hand-listing the
609
+ * exports they return, and a module-scope read of a name absent from such a list
610
+ * throws at COLLECTION time — the importing test file dies before running one
611
+ * assertion. Taking the seam from here reddened 17 files across all four CI
612
+ * shards with zero failed assertions among them. Nothing mocks `@object-ui/core`,
613
+ * and every registration module already imports `ComponentRegistry` from it.
614
+ */
615
+ export { elementDataSourceBlock } from '@object-ui/core';
247
616
  /**
248
617
  * Wrap an object-bound block so its `dataSource` binding reaches the keys it
249
618
  * reads, and so the two non-final resolution states render once, the same way,
@@ -253,7 +622,7 @@ export function NoDataSourcePanel({ testId, title = 'No data source resolved', m
253
622
  * against a half-resolved query is how a page shows a wider answer than the one
254
623
  * that was authored.
255
624
  */
256
- export function ElementDataSourceGate({ schema, mapping, dataSource, testId, errorTitle, requiresDataSource, noDataSourceMessage: noDataSourceText, children, }) {
625
+ export function ElementDataSourceGate({ schema, mapping, dataSource, testId, errorTitle, requiresDataSource, noDataSourceMessage: noDataSourceText, requiresObject, children, }) {
257
626
  const bound = useElementDataSourceSchema(schema, mapping, dataSource);
258
627
  const adapter = useResolvedDataSource(dataSource);
259
628
  // Reported BEFORE the view state, because with no adapter every downstream
@@ -264,11 +633,24 @@ export function ElementDataSourceGate({ schema, mapping, dataSource, testId, err
264
633
  if (requiresDataSource && adapter == null) {
265
634
  return _jsx(NoDataSourcePanel, { testId: testId, message: noDataSourceText });
266
635
  }
636
+ // A refused filter is `missing` too, but it gets the malformed-filter notice
637
+ // rather than the "could not be resolved" panel: the binding resolved, and
638
+ // what the author has to fix is a filter condition (objectui#10789).
639
+ if (bound.filterRefusal) {
640
+ return _jsx(ElementDataSourceMalformedFilterPanel, { testId: testId, refusal: bound.filterRefusal });
641
+ }
267
642
  if (bound.status === 'missing') {
268
643
  return _jsx(ElementDataSourceErrorPanel, { testId: testId, title: errorTitle, message: bound.error });
269
644
  }
270
645
  if (bound.status === 'loading') {
271
646
  return _jsx(ElementDataSourceLoadingPanel, { testId: testId });
272
647
  }
648
+ // Named in neither place: not on the block's own key, and not by a binding
649
+ // (whose `object` the hook above has already landed on that key). See
650
+ // `requiresObject` for why the call site, not this gate, decides it applies.
651
+ const objectKey = mapping?.object ?? 'objectName';
652
+ if (requiresObject && objectKey !== false && !namesObject(bound.schema, objectKey)) {
653
+ return _jsx(ElementDataSourceNoObjectPanel, { testId: testId, property: objectKey });
654
+ }
273
655
  return children(bound.schema);
274
656
  }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ *
8
+ * The renderer half of the spec's API binding of a page element (objectui#12197):
9
+ * `PageComponentSchema.dataSource` as `ElementApiDataSourceSchema`,
10
+ *
11
+ * ```json
12
+ * { "provider": "api", "read": { "url": "/api/v1/cloud/home" }, "path": "data" }
13
+ * ```
14
+ *
15
+ * The spec states what a renderer does with it: the element reads ONE
16
+ * same-origin endpoint with the console's signed-in fetch and binds the selected
17
+ * value, "which the element reads as `data` in its expression holes and its
18
+ * `visibleWhen`". `SchemaRenderer` is the one place both of those are evaluated,
19
+ * so it calls {@link useElementApiDataSource} for every node and binds the
20
+ * answer as the `data` root (see "The API binding" there). This module owns the
21
+ * read itself.
22
+ *
23
+ * ## The binding is judged by the spec's own schema
24
+ *
25
+ * A node's `dataSource` reaches the renderer as stored metadata, unparsed. The
26
+ * hook parses it with `ElementApiDataSourceSchema` before it reads anything,
27
+ * so a binding the spec refuses — an absolute or protocol-relative `url`, a
28
+ * credential key, a `method` other than `GET`, a malformed `path` — is
29
+ * `refused` here with the spec's own message, and no request is sent. There is
30
+ * no second, hand-kept same-origin rule in this repo to drift from the
31
+ * contract's.
32
+ *
33
+ * A `dataSource` that is not an API binding (absent, or the object binding
34
+ * `useElementDataSource` reads) is `absent` here: the two shapes are told apart
35
+ * by `provider: 'api'`, the spec's discriminator.
36
+ *
37
+ * ## The read
38
+ *
39
+ * Through `SchemaRendererContext.apiFetch`, the host's authenticated fetch the
40
+ * console hands every renderer (`ConsoleShell`), falling back to the global
41
+ * fetch only when no host supplies one — the rule every `apiFetch` reader in
42
+ * the tree follows. `GET`, `Accept: application/json`, `credentials: 'include'`.
43
+ * A non-2xx answer, a body that is not JSON, or a `path` that selects nothing
44
+ * is `error`, with a sentence naming which; the value is never guessed.
45
+ *
46
+ * Concurrent elements that bind the same URL share ONE request: {@link
47
+ * readElementApi} keeps a pending request per URL and forgets it the moment it
48
+ * settles, so it caches no answer (the shape `readStorageUsage` uses).
49
+ *
50
+ * @module
51
+ */
52
+ import { type ElementApiDataSource } from '@objectstack/spec/ui';
53
+ import { type ApiFetch } from '../context/SchemaRendererContext.js';
54
+ /**
55
+ * Where an API binding stands.
56
+ *
57
+ * - `absent` — the node carries no API binding; nothing is read.
58
+ * - `refused` — the node carries one the spec refuses; nothing is read.
59
+ * - `loading` — the read is in flight.
60
+ * - `ready` — the read answered and `path` selected a value.
61
+ * - `error` — the read failed, or `path` selected nothing.
62
+ */
63
+ export type ElementApiDataSourceStatus = 'absent' | 'refused' | 'loading' | 'ready' | 'error';
64
+ export interface UseElementApiDataSourceResult {
65
+ status: ElementApiDataSourceStatus;
66
+ /** The parsed binding, for every status but `absent` and `refused`. */
67
+ config?: ElementApiDataSource;
68
+ /** The selected value, set only for `ready`. */
69
+ data?: unknown;
70
+ /** Author-facing explanation, set for `refused` and `error`. */
71
+ error?: string;
72
+ }
73
+ /**
74
+ * Is this `dataSource` value the API binding — whatever it says beyond
75
+ * `provider`? `provider: 'api'` is the spec's discriminator between the two
76
+ * shapes of `PageComponentSchema.dataSource`; the object binding carries no
77
+ * `provider`.
78
+ */
79
+ export declare function isElementApiDataSourceShape(value: unknown): boolean;
80
+ /**
81
+ * Judge a node's `dataSource` as an API binding: `absent` when it is not one,
82
+ * `refused` with the spec's message when the spec refuses it, otherwise the
83
+ * parsed binding.
84
+ */
85
+ export declare function judgeElementApiDataSource(value: unknown): {
86
+ status: 'absent';
87
+ } | {
88
+ status: 'refused';
89
+ error: string;
90
+ } | {
91
+ status: 'parsed';
92
+ config: ElementApiDataSource;
93
+ };
94
+ /**
95
+ * Select `path` (dotted segments; a numeric segment indexes an array) from a
96
+ * response body. `found: false` when any segment has nothing under it — an
97
+ * absent key, not a key whose value is `undefined`, is what "selects nothing"
98
+ * means.
99
+ */
100
+ export declare function selectElementApiPath(body: unknown, path: string | undefined): {
101
+ found: true;
102
+ value: unknown;
103
+ } | {
104
+ found: false;
105
+ segment: string;
106
+ };
107
+ /**
108
+ * `GET url` through `doFetch`, resolving with the parsed JSON body. Rejects on
109
+ * a network failure, a non-2xx answer or a body that is not JSON. Callers that
110
+ * ask while a read of the same URL is pending share it.
111
+ */
112
+ export declare function readElementApi(url: string, doFetch: ApiFetch): Promise<unknown>;
113
+ /**
114
+ * Resolve a node's API binding: read its endpoint and select its `path`.
115
+ *
116
+ * Called unconditionally by `SchemaRenderer` for every node; a node without an
117
+ * API binding costs one `isPlainObject` test and issues no request.
118
+ */
119
+ export declare function useElementApiDataSource(schema: unknown): UseElementApiDataSourceResult;