@object-ui/react 17.5.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 (103) hide show
  1. package/CHANGELOG.md +4100 -0
  2. package/README.md +303 -46
  3. package/dist/LazyPluginLoader.d.ts +14 -7
  4. package/dist/LazyPluginLoader.js +14 -7
  5. package/dist/SchemaRenderer.d.ts +38 -7
  6. package/dist/SchemaRenderer.js +1603 -60
  7. package/dist/context/ActionContext.js +40 -1
  8. package/dist/context/DiscussionContext.d.ts +12 -0
  9. package/dist/context/DiscussionContext.js +2 -0
  10. package/dist/context/DrillNavigationContext.d.ts +13 -0
  11. package/dist/context/HostNavigationContext.d.ts +82 -0
  12. package/dist/context/HostNavigationContext.js +65 -0
  13. package/dist/context/InlineEditContext.d.ts +81 -1
  14. package/dist/context/InlineEditContext.js +8 -2
  15. package/dist/context/RecordContext.d.ts +48 -5
  16. package/dist/context/RecordContext.js +10 -1
  17. package/dist/context/RelatedRecordActionsContext.d.ts +20 -1
  18. package/dist/context/SchemaRendererContext.d.ts +54 -2
  19. package/dist/context/SchemaRendererContext.js +38 -5
  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 +13 -12
  24. package/dist/context/index.js +13 -12
  25. package/dist/element-data-source/ElementDataSourceGate.d.ts +163 -3
  26. package/dist/element-data-source/ElementDataSourceGate.js +407 -15
  27. package/dist/hooks/index.d.ts +37 -35
  28. package/dist/hooks/index.js +44 -35
  29. package/dist/hooks/useActionEngine.js +47 -4
  30. package/dist/hooks/useActionRunner.js +4 -0
  31. package/dist/hooks/useActionTextLocalizer.d.ts +21 -8
  32. package/dist/hooks/useActionTextLocalizer.js +33 -8
  33. package/dist/hooks/useCapabilityGate.js +6 -5
  34. package/dist/hooks/useConfigBagEvaluator.d.ts +30 -0
  35. package/dist/hooks/useConfigBagEvaluator.js +39 -0
  36. package/dist/hooks/useCrudShortcuts.js +1 -1
  37. package/dist/hooks/useDatasetDimensionLabels.js +1 -1
  38. package/dist/hooks/useDensityMode.d.ts +6 -2
  39. package/dist/hooks/useDensityMode.js +6 -2
  40. package/dist/hooks/useDiscovery.d.ts +1 -10
  41. package/dist/hooks/useDiscovery.js +1 -1
  42. package/dist/hooks/useETagCache.js +25 -4
  43. package/dist/hooks/useElementDataSource.d.ts +15 -2
  44. package/dist/hooks/useElementDataSource.js +29 -9
  45. package/dist/hooks/useExpression.d.ts +52 -38
  46. package/dist/hooks/useExpression.js +62 -42
  47. package/dist/hooks/useFilterScope.d.ts +22 -2
  48. package/dist/hooks/useFilterScope.js +29 -4
  49. package/dist/hooks/useGlobalUndo.d.ts +10 -0
  50. package/dist/hooks/useGlobalUndo.js +112 -46
  51. package/dist/hooks/useNavigationOverlay.d.ts +77 -12
  52. package/dist/hooks/useNavigationOverlay.js +119 -9
  53. package/dist/hooks/useOffline.js +65 -11
  54. package/dist/hooks/usePageAssignment.d.ts +8 -5
  55. package/dist/hooks/usePageAssignment.js +38 -17
  56. package/dist/hooks/usePageTransition.js +1 -1
  57. package/dist/hooks/usePageVariableActionBridge.js +2 -2
  58. package/dist/hooks/useRecordSearch.d.ts +34 -1
  59. package/dist/hooks/useRecordSearch.js +42 -7
  60. package/dist/hooks/useResolvedFilter.d.ts +46 -0
  61. package/dist/hooks/useResolvedFilter.js +130 -0
  62. package/dist/hooks/useSchemaPersistence.d.ts +7 -0
  63. package/dist/hooks/useSchemaPersistence.js +144 -5
  64. package/dist/hooks/useSettledSchema.d.ts +123 -0
  65. package/dist/hooks/useSettledSchema.js +186 -0
  66. package/dist/hooks/useViewData.d.ts +7 -2
  67. package/dist/hooks/useViewData.js +1 -1
  68. package/dist/hooks/useViewTransition.js +1 -1
  69. package/dist/index.d.ts +18 -11
  70. package/dist/index.js +54 -10
  71. package/dist/schema-input.d.ts +55 -14
  72. package/dist/schema-input.js +55 -12
  73. package/dist/utils/configBag.d.ts +68 -0
  74. package/dist/utils/configBag.js +70 -0
  75. package/dist/utils/error-message.d.ts +59 -0
  76. package/dist/utils/error-message.js +116 -1
  77. package/dist/utils/inlineAria.d.ts +61 -0
  78. package/dist/utils/inlineAria.js +70 -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 +421 -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 +110 -0
  88. package/dist/utils/unevaluatedExpression.js +257 -0
  89. package/dist/utils/visibilityDiagnostic.d.ts +371 -0
  90. package/dist/utils/visibilityDiagnostic.js +551 -0
  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 -82
  97. package/dist/spec-bridge/bridges/form-view.js +0 -126
  98. package/dist/spec-bridge/bridges/list-view.d.ts +0 -20
  99. package/dist/spec-bridge/bridges/list-view.js +0 -241
  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
@@ -12,6 +12,40 @@
12
12
  * usable can be extracted, so callers fall back to a generic localized string.
13
13
  */
14
14
  export declare function extractWriteErrorMessage(err: unknown): string | null;
15
+ /**
16
+ * The refusal text the PRODUCER explicitly marked as addressed to the end user,
17
+ * or `null` when the refusal carries no marking.
18
+ *
19
+ * `userMessage` is the opt-in channel added by objectstack `79c46da90` (maintainer
20
+ * ruling 2026-08-19 on objectui#5210), declared on `ApiErrorSchema` /
21
+ * `EnhancedApiErrorSchema` in the pinned `@objectstack/spec`.
22
+ * Three properties of the contract decide this reader's whole shape:
23
+ *
24
+ * - **Presence IS the marking.** The mark and the marked text are ONE value —
25
+ * a field carrying the text, not a boolean beside `message` — so no boundary
26
+ * that rewraps or substitutes `message` can promote platform prose into the
27
+ * marked channel. Platform and driver code never set it.
28
+ * - **Status-agnostic.** Not a 403 special case; any refusal status may carry
29
+ * it. Callers must not gate this read on a status.
30
+ * - **Verbatim.** The author wrote it for their user, already localized. It is
31
+ * returned untouched — none of {@link extractWriteErrorMessage}'s prefix
32
+ * stripping applies, because there is no machine prefix to strip.
33
+ *
34
+ * Read from the two places the adapter boundary parks the envelope, both under
35
+ * the SAME declared key: the error itself (`@objectstack/client` lifts a marked
36
+ * body onto `err.userMessage`, from either wire dialect) and `details`, which
37
+ * the client falls back to the whole response body — the identical pair
38
+ * {@link isPermissionError} reads `code` from. Deliberately NOT read: the raw
39
+ * JSON tail `ApiDataSource` bakes into its message string. Scraping a marking
40
+ * out of a transport's prose is the consumer-side guessing objectstack#3821
41
+ * exists to prevent; a transport that flattens the envelope should carry the
42
+ * field instead.
43
+ *
44
+ * Everything unmarked answers `null`, so callers keep their generic localized
45
+ * substitution and objectstack#3821's protection holds BY CONSTRUCTION: this
46
+ * function can never return text the producer did not opt in.
47
+ */
48
+ export declare function declaredUserMessage(err: unknown): string | null;
15
49
  /** One field the server rejected, normalised for `form.setError`. */
16
50
  export interface WriteFieldError {
17
51
  /** Field name, matching the form control's `name`. */
@@ -50,3 +84,28 @@ export declare function extractFieldErrors(err: unknown): WriteFieldError[] | nu
50
84
  * the raw server text.
51
85
  */
52
86
  export declare function isPermissionError(err: unknown): boolean;
87
+ /** What {@link classifyLoadError} answers. */
88
+ export type LoadErrorKind = 'api-disabled' | 'forbidden' | 'unauthorized' | 'rejected' | 'network';
89
+ /**
90
+ * Classify a failed data FETCH (as opposed to a failed write, above) by HTTP
91
+ * status / error code, so an error panel can say what actually happened. A
92
+ * 403 rendered as "check your connection" is indistinguishable from a real
93
+ * outage — users were told to debug their network when the server had
94
+ * (correctly) denied them access.
95
+ *
96
+ * Originally `ListView`'s module-local `classifyLoadError`
97
+ * (`packages/plugin-list/src/ListView.tsx`); lifted here (objectui#4693) so
98
+ * `RecordAttachmentsPanel` can reuse the same "is this retry-invariant"
99
+ * verdict for the `sys_attachment` list read instead of re-deriving it. Both
100
+ * `@object-ui/plugin-list` and `@object-ui/app-shell` already depend on this
101
+ * package, so the lift adds no new dependency edge to either.
102
+ *
103
+ * Purely a classifier — it returns a KIND, never rendered text. Each caller
104
+ * owns its own copy for what a kind means on its surface (`ListView` shows a
105
+ * title + message per kind and a Retry for every kind but `api-disabled`;
106
+ * `RecordAttachmentsPanel` only distinguishes `api-disabled` from everything
107
+ * else, folding `forbidden`/`unauthorized`/`rejected`/`network` into its own
108
+ * coarser `denied`/`unavailable` split via {@link isPermissionError}) — so
109
+ * lifting this function does not couple the two surfaces' i18n or copy.
110
+ */
111
+ export declare function classifyLoadError(err: unknown): LoadErrorKind;
@@ -6,7 +6,8 @@
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
8
  /**
9
- * Helpers for turning a failed data-source write into user-facing feedback.
9
+ * Helpers for turning a failed data-source read or write into user-facing
10
+ * feedback.
10
11
  *
11
12
  * Different DataSource adapters throw differently shaped errors:
12
13
  * - the ObjectStack adapter (`@object-ui/data-objectstack`) decorates the
@@ -18,6 +19,7 @@
18
19
  * These helpers normalise across both so a caller's `catch` can surface the
19
20
  * real reason (e.g. a row-level-security denial) instead of swallowing it.
20
21
  */
22
+ import { isApiAccessDeniedError } from '@object-ui/data-objectstack';
21
23
  function firstString(...vals) {
22
24
  for (const v of vals) {
23
25
  if (typeof v === 'string' && v.trim())
@@ -68,6 +70,54 @@ export function extractWriteErrorMessage(err) {
68
70
  .trim();
69
71
  return clean || null;
70
72
  }
73
+ /**
74
+ * The refusal text the PRODUCER explicitly marked as addressed to the end user,
75
+ * or `null` when the refusal carries no marking.
76
+ *
77
+ * `userMessage` is the opt-in channel added by objectstack `79c46da90` (maintainer
78
+ * ruling 2026-08-19 on objectui#5210), declared on `ApiErrorSchema` /
79
+ * `EnhancedApiErrorSchema` in the pinned `@objectstack/spec`.
80
+ * Three properties of the contract decide this reader's whole shape:
81
+ *
82
+ * - **Presence IS the marking.** The mark and the marked text are ONE value —
83
+ * a field carrying the text, not a boolean beside `message` — so no boundary
84
+ * that rewraps or substitutes `message` can promote platform prose into the
85
+ * marked channel. Platform and driver code never set it.
86
+ * - **Status-agnostic.** Not a 403 special case; any refusal status may carry
87
+ * it. Callers must not gate this read on a status.
88
+ * - **Verbatim.** The author wrote it for their user, already localized. It is
89
+ * returned untouched — none of {@link extractWriteErrorMessage}'s prefix
90
+ * stripping applies, because there is no machine prefix to strip.
91
+ *
92
+ * Read from the two places the adapter boundary parks the envelope, both under
93
+ * the SAME declared key: the error itself (`@objectstack/client` lifts a marked
94
+ * body onto `err.userMessage`, from either wire dialect) and `details`, which
95
+ * the client falls back to the whole response body — the identical pair
96
+ * {@link isPermissionError} reads `code` from. Deliberately NOT read: the raw
97
+ * JSON tail `ApiDataSource` bakes into its message string. Scraping a marking
98
+ * out of a transport's prose is the consumer-side guessing objectstack#3821
99
+ * exists to prevent; a transport that flattens the envelope should carry the
100
+ * field instead.
101
+ *
102
+ * Everything unmarked answers `null`, so callers keep their generic localized
103
+ * substitution and objectstack#3821's protection holds BY CONSTRUCTION: this
104
+ * function can never return text the producer did not opt in.
105
+ */
106
+ export function declaredUserMessage(err) {
107
+ if (!err || typeof err !== 'object')
108
+ return null;
109
+ const e = err;
110
+ // Non-empty after trimming, returned untrimmed — the same predicate
111
+ // `@objectstack/client` applies when it lifts the field off the wire.
112
+ if (typeof e.userMessage === 'string' && e.userMessage.trim())
113
+ return e.userMessage;
114
+ const details = e.details;
115
+ if (details && typeof details === 'object'
116
+ && typeof details.userMessage === 'string' && details.userMessage.trim()) {
117
+ return details.userMessage;
118
+ }
119
+ return null;
120
+ }
71
121
  /**
72
122
  * Per-field errors from a rejected write, or `null` when the failure was not
73
123
  * field-scoped.
@@ -136,3 +186,68 @@ export function isPermissionError(err) {
136
186
  const text = `${typeof e.message === 'string' ? e.message : ''} ${e.details?.error ?? e.error ?? ''}`;
137
187
  return /row-level security|access denied|permission denied|not permitted/i.test(text);
138
188
  }
189
+ /** 400-class error codes the data API returns for a request it will never accept. */
190
+ const REJECTED_REQUEST_CODES = new Set([
191
+ 'INVALID_FILTER',
192
+ 'UNSUPPORTED_QUERY_PARAM',
193
+ 'INVALID_QUERY',
194
+ ]);
195
+ /**
196
+ * Classify a failed data FETCH (as opposed to a failed write, above) by HTTP
197
+ * status / error code, so an error panel can say what actually happened. A
198
+ * 403 rendered as "check your connection" is indistinguishable from a real
199
+ * outage — users were told to debug their network when the server had
200
+ * (correctly) denied them access.
201
+ *
202
+ * Originally `ListView`'s module-local `classifyLoadError`
203
+ * (`packages/plugin-list/src/ListView.tsx`); lifted here (objectui#4693) so
204
+ * `RecordAttachmentsPanel` can reuse the same "is this retry-invariant"
205
+ * verdict for the `sys_attachment` list read instead of re-deriving it. Both
206
+ * `@object-ui/plugin-list` and `@object-ui/app-shell` already depend on this
207
+ * package, so the lift adds no new dependency edge to either.
208
+ *
209
+ * Purely a classifier — it returns a KIND, never rendered text. Each caller
210
+ * owns its own copy for what a kind means on its surface (`ListView` shows a
211
+ * title + message per kind and a Retry for every kind but `api-disabled`;
212
+ * `RecordAttachmentsPanel` only distinguishes `api-disabled` from everything
213
+ * else, folding `forbidden`/`unauthorized`/`rejected`/`network` into its own
214
+ * coarser `denied`/`unavailable` split via {@link isPermissionError}) — so
215
+ * lifting this function does not couple the two surfaces' i18n or copy.
216
+ */
217
+ export function classifyLoadError(err) {
218
+ const e = err;
219
+ // The ObjectStack client decorates errors with `httpStatus`; raw fetch
220
+ // wrappers surface `status` / `statusCode`; some adapters only embed the
221
+ // status in the message ("HTTP 403 Forbidden — …").
222
+ let status = [e?.httpStatus, e?.status, e?.statusCode]
223
+ .find((s) => typeof s === 'number');
224
+ if (status === undefined) {
225
+ const m = /HTTP (\d{3})\b/.exec(String(e?.message ?? ''));
226
+ if (m)
227
+ status = Number(m[1]);
228
+ }
229
+ const code = typeof e?.code === 'string' ? e.code.toUpperCase() : '';
230
+ // The object's `enable` block withholds this operation — checked FIRST, on
231
+ // the CODE alone (delegated to the shared predicate so this file and
232
+ // `@object-ui/data-objectstack` cannot drift on which codes count). Status
233
+ // cannot carry this verdict: the denial is a 404, the same status a missing
234
+ // collection and a missing record answer with, and its 405 sibling is the
235
+ // same status any other method rejection uses. Unlike every kind below it
236
+ // this one is not about the request, the session or the network — it is a
237
+ // permanent property of the object, identical for every persona and every
238
+ // retry (objectui#4408).
239
+ if (isApiAccessDeniedError(err))
240
+ return 'api-disabled';
241
+ if (status === 403 || code === 'PERMISSION_DENIED' || code === 'FORBIDDEN')
242
+ return 'forbidden';
243
+ if (status === 401 || code === 'UNAUTHORIZED' || code === 'UNAUTHENTICATED')
244
+ return 'unauthorized';
245
+ // The server understood the request and refused it as malformed. Retrying
246
+ // sends the identical bad request, so "check your connection and try again"
247
+ // is the same wrong advice this function exists to stop giving — one status
248
+ // code over. The server now rejects a `$filter` that is not a filter AST
249
+ // (objectstack#4121) and an unsupported `$`-parameter, both as 400.
250
+ if (status === 400 || REJECTED_REQUEST_CODES.has(code))
251
+ return 'rejected';
252
+ return 'network';
253
+ }
@@ -0,0 +1,61 @@
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
+ import type { AriaProps } from '@objectstack/spec/ui';
9
+ /**
10
+ * Resolves the spec's NESTED `aria` bag to the DOM attributes it declares
11
+ * (objectui#11051).
12
+ *
13
+ * The bag is `@objectstack/spec`'s `AriaPropsSchema`, the `aria` member a
14
+ * block's own props schema carries (`element:text`, `element:button`,
15
+ * `element:image` and `element:number` among them). It declares three keys,
16
+ * and each maps to one attribute:
17
+ *
18
+ * `ariaLabel` → `aria-label`, resolved with the spec's own
19
+ * `resolveI18nLabel` against `locale`: a plain string is
20
+ * used as written, and an inline locale map
21
+ * (`{ en: 'Order total', 'zh-CN': '订单合计' }`) gives the
22
+ * entry for the viewer's language;
23
+ * `ariaDescribedBy` → `aria-describedby`;
24
+ * `role` → `role`.
25
+ *
26
+ * A key that resolves to nothing, or to an empty string, is left out of the
27
+ * result instead of being written as an empty or `undefined` attribute. A key
28
+ * the bag does not declare is not read: the spec refuses such keys when the
29
+ * document is parsed, so this function adds no alias for them.
30
+ *
31
+ * ## Two vocabularies, two readers (objectui#4580 Q2-B)
32
+ *
33
+ * This is NOT `SchemaRenderer`'s `resolveAriaProps`. That one reads the FLAT
34
+ * node keys (`BaseSchema.ariaLabel` / `ariaDescribedBy` / `role`) and resolves
35
+ * `ariaLabel` in objectui's KEYED vocabulary (`{ key, defaultValue?, params? }`,
36
+ * via `resolveKeyedI18nLabel`). The nested bag uses the INLINE locale map.
37
+ * Neither resolver accepts the other's shape: the keyed one returns `undefined`
38
+ * for a locale map, and the spec's one treats `key` / `defaultValue` as locale
39
+ * tags. So the two readers stay separate, as objectui#4580 Q2-B ruled.
40
+ *
41
+ * ## Why a pure function and not a hook
42
+ *
43
+ * The caller passes the locale; this function reads no React context. That
44
+ * lets a renderer call it anywhere in its body, including after an early
45
+ * return, and lets code outside React call it too. Renderers pass
46
+ * `useDisplayLocale()` from `@object-ui/i18n`, the locale every
47
+ * `resolveI18nLabel` read site in this repo resolves against (objectui#4580
48
+ * revised Q1-A). Like the spec's resolver, `locale` is a required parameter
49
+ * that may be `undefined` (which resolves as `en`), so a caller cannot forget
50
+ * it by accident.
51
+ *
52
+ * @param aria - The authored bag, taken directly from the node's config. A
53
+ * missing bag, or a value that is not an object, gives `{}`.
54
+ * @param locale - BCP-47 tag of the viewer's language, e.g. `zh-CN`.
55
+ * @returns The attributes to spread onto the element that carries the name.
56
+ */
57
+ export declare function resolveInlineAriaProps(aria: AriaProps | null | undefined, locale: string | undefined): {
58
+ 'aria-label'?: string;
59
+ 'aria-describedby'?: string;
60
+ role?: string;
61
+ };
@@ -0,0 +1,70 @@
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
+ import { resolveI18nLabel as resolveInlineI18nLabel } from '@objectstack/spec/ui';
9
+ /**
10
+ * Resolves the spec's NESTED `aria` bag to the DOM attributes it declares
11
+ * (objectui#11051).
12
+ *
13
+ * The bag is `@objectstack/spec`'s `AriaPropsSchema`, the `aria` member a
14
+ * block's own props schema carries (`element:text`, `element:button`,
15
+ * `element:image` and `element:number` among them). It declares three keys,
16
+ * and each maps to one attribute:
17
+ *
18
+ * `ariaLabel` → `aria-label`, resolved with the spec's own
19
+ * `resolveI18nLabel` against `locale`: a plain string is
20
+ * used as written, and an inline locale map
21
+ * (`{ en: 'Order total', 'zh-CN': '订单合计' }`) gives the
22
+ * entry for the viewer's language;
23
+ * `ariaDescribedBy` → `aria-describedby`;
24
+ * `role` → `role`.
25
+ *
26
+ * A key that resolves to nothing, or to an empty string, is left out of the
27
+ * result instead of being written as an empty or `undefined` attribute. A key
28
+ * the bag does not declare is not read: the spec refuses such keys when the
29
+ * document is parsed, so this function adds no alias for them.
30
+ *
31
+ * ## Two vocabularies, two readers (objectui#4580 Q2-B)
32
+ *
33
+ * This is NOT `SchemaRenderer`'s `resolveAriaProps`. That one reads the FLAT
34
+ * node keys (`BaseSchema.ariaLabel` / `ariaDescribedBy` / `role`) and resolves
35
+ * `ariaLabel` in objectui's KEYED vocabulary (`{ key, defaultValue?, params? }`,
36
+ * via `resolveKeyedI18nLabel`). The nested bag uses the INLINE locale map.
37
+ * Neither resolver accepts the other's shape: the keyed one returns `undefined`
38
+ * for a locale map, and the spec's one treats `key` / `defaultValue` as locale
39
+ * tags. So the two readers stay separate, as objectui#4580 Q2-B ruled.
40
+ *
41
+ * ## Why a pure function and not a hook
42
+ *
43
+ * The caller passes the locale; this function reads no React context. That
44
+ * lets a renderer call it anywhere in its body, including after an early
45
+ * return, and lets code outside React call it too. Renderers pass
46
+ * `useDisplayLocale()` from `@object-ui/i18n`, the locale every
47
+ * `resolveI18nLabel` read site in this repo resolves against (objectui#4580
48
+ * revised Q1-A). Like the spec's resolver, `locale` is a required parameter
49
+ * that may be `undefined` (which resolves as `en`), so a caller cannot forget
50
+ * it by accident.
51
+ *
52
+ * @param aria - The authored bag, taken directly from the node's config. A
53
+ * missing bag, or a value that is not an object, gives `{}`.
54
+ * @param locale - BCP-47 tag of the viewer's language, e.g. `zh-CN`.
55
+ * @returns The attributes to spread onto the element that carries the name.
56
+ */
57
+ export function resolveInlineAriaProps(aria, locale) {
58
+ if (!aria || typeof aria !== 'object')
59
+ return {};
60
+ const out = {};
61
+ const label = resolveInlineI18nLabel(aria.ariaLabel, locale);
62
+ if (label)
63
+ out['aria-label'] = label;
64
+ if (typeof aria.ariaDescribedBy === 'string' && aria.ariaDescribedBy) {
65
+ out['aria-describedby'] = aria.ariaDescribedBy;
66
+ }
67
+ if (typeof aria.role === 'string' && aria.role)
68
+ out.role = aria.role;
69
+ return out;
70
+ }
@@ -0,0 +1,44 @@
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
+ import React from 'react';
9
+ import type { NonGridCeilingResult } from '@object-ui/core';
10
+ /**
11
+ * The loud footnote a non-grid view shows when it drew only the first
12
+ * `NON_GRID_ROW_CEILING` rows of a larger result set (objectui#7210).
13
+ *
14
+ * Placement and tone follow objectui#7148's chart footnote — a `role="note"`
15
+ * line in muted small type directly under the visualisation, naming BOTH
16
+ * numbers, because the count is the half a reader cannot recover from the
17
+ * picture. A truncated schedule renders as a healthy, confident schedule of a
18
+ * fraction of itself; "some rows are missing" leaves it indistinguishable from
19
+ * a complete one, and `2,000 of 40,000` is the bit that was missing.
20
+ *
21
+ * Two sentences because there are two conditions: a known total states the
22
+ * fact with both numbers; an adapter that reported no `total` still gets a
23
+ * DEFINITE sentence (the probe row proves more rows exist), it simply cannot
24
+ * name how many.
25
+ *
26
+ * ## It takes the RESULT, and nothing else (objectui#7508, ruling A′)
27
+ *
28
+ * The only prop is the `NonGridCeilingResult` that `applyNonGridRowCeiling`
29
+ * returned. Every number the note prints is read off that one object — the
30
+ * drawn count is `result.rows.length`, the total is `result.total`, the
31
+ * verdict is `result.truncated` — so a footnote whose numbers disagree with
32
+ * the rows actually drawn is not something a caller can write. The earlier
33
+ * shape took `drawn` / `total` / `truncated` as three loose props, and every
34
+ * call site hand-wrote `drawn={NON_GRID_ROW_CEILING}` next to a `total` from
35
+ * somewhere else: an incoherent pair was expressible and nothing refused it.
36
+ * ⛔ No deprecated path keeps those props; there were no released consumers.
37
+ *
38
+ * Renders `null` when nothing was truncated, so a caller holding a result can
39
+ * mount it unconditionally and gains no wrapper element on the healthy path.
40
+ */
41
+ export declare function NonGridRowCeilingNote({ result, }: {
42
+ /** What `applyNonGridRowCeiling` returned for the rows being drawn. */
43
+ result: NonGridCeilingResult;
44
+ }): React.JSX.Element | null;
@@ -0,0 +1,78 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useObjectTranslation, en } from '@object-ui/i18n';
3
+ // The non-React half of the non-grid row ceiling — `NON_GRID_ROW_CEILING`,
4
+ // `nonGridRowCeilingQuery`, `applyNonGridRowCeiling` and `NonGridCeilingResult`
5
+ // — lives in `@object-ui/core` beside the `extractRecords` it wraps
6
+ // (objectui#7508, ruling A′). Only the footnote is React, so only the footnote
7
+ // is here.
8
+ /**
9
+ * The loud footnote a non-grid view shows when it drew only the first
10
+ * `NON_GRID_ROW_CEILING` rows of a larger result set (objectui#7210).
11
+ *
12
+ * Placement and tone follow objectui#7148's chart footnote — a `role="note"`
13
+ * line in muted small type directly under the visualisation, naming BOTH
14
+ * numbers, because the count is the half a reader cannot recover from the
15
+ * picture. A truncated schedule renders as a healthy, confident schedule of a
16
+ * fraction of itself; "some rows are missing" leaves it indistinguishable from
17
+ * a complete one, and `2,000 of 40,000` is the bit that was missing.
18
+ *
19
+ * Two sentences because there are two conditions: a known total states the
20
+ * fact with both numbers; an adapter that reported no `total` still gets a
21
+ * DEFINITE sentence (the probe row proves more rows exist), it simply cannot
22
+ * name how many.
23
+ *
24
+ * ## It takes the RESULT, and nothing else (objectui#7508, ruling A′)
25
+ *
26
+ * The only prop is the `NonGridCeilingResult` that `applyNonGridRowCeiling`
27
+ * returned. Every number the note prints is read off that one object — the
28
+ * drawn count is `result.rows.length`, the total is `result.total`, the
29
+ * verdict is `result.truncated` — so a footnote whose numbers disagree with
30
+ * the rows actually drawn is not something a caller can write. The earlier
31
+ * shape took `drawn` / `total` / `truncated` as three loose props, and every
32
+ * call site hand-wrote `drawn={NON_GRID_ROW_CEILING}` next to a `total` from
33
+ * somewhere else: an incoherent pair was expressible and nothing refused it.
34
+ * ⛔ No deprecated path keeps those props; there were no released consumers.
35
+ *
36
+ * Renders `null` when nothing was truncated, so a caller holding a result can
37
+ * mount it unconditionally and gains no wrapper element on the healthy path.
38
+ */
39
+ export function NonGridRowCeilingNote({ result, }) {
40
+ // ⚠️ A HOOK AT RENDER, never a module-scope `createSafeTranslation(...)`
41
+ // factory, and that is a correctness constraint rather than taste. This
42
+ // module is re-exported from `@object-ui/react`'s entry, so anything at its
43
+ // module scope runs on IMPORT for every consumer that touches the barrel —
44
+ // and a factory call there throws inside any test that partially mocks
45
+ // `@object-ui/i18n` with an object literal instead of `importOriginal`,
46
+ // before a single assertion runs. Two such files went red in CI on exactly
47
+ // that, and the population is repo-wide rather than knowable from here, so
48
+ // the call moved to where it cannot fire at import time. `en` is likewise
49
+ // dereferenced HERE, at render, not in a module-scope constant.
50
+ //
51
+ // `useObjectTranslation` is the one that interpolates on the provider-less
52
+ // path too (objectui#6219), so `{{shown}}` / `{{total}}` are filled whether
53
+ // or not the host mounted an `I18nProvider`. The `defaultValue` is read from
54
+ // the `en` pack rather than retyped: both sit in the same eagerly-loaded
55
+ // chunk, so a hand copy would ship identical bytes twice, and reading the
56
+ // pack makes an inline default that disagrees with `en` unrepresentable
57
+ // instead of merely policed.
58
+ const { t } = useObjectTranslation();
59
+ if (!result.truncated)
60
+ return null;
61
+ const shown = result.rows.length;
62
+ const text = typeof result.total === 'number'
63
+ ? t('common.rowCeilingNote', {
64
+ shown,
65
+ total: result.total,
66
+ defaultValue: en.common.rowCeilingNote,
67
+ })
68
+ : t('common.rowCeilingNoteUnknownTotal', {
69
+ shown,
70
+ defaultValue: en.common.rowCeilingNoteUnknownTotal,
71
+ });
72
+ // `shrink-0` is the note's own, not a caller's: under a `flex-1` view pane in
73
+ // a fixed-height host a plain sibling can be clipped out of view — the
74
+ // construction objectui#7148's `ChartFootnote` measured, and the reason the
75
+ // gantt used to pass this exact class in. Outside a flex container it does
76
+ // nothing, so all four views take it and no caller needs a styling prop.
77
+ return (_jsx("p", { role: "note", "data-row-ceiling-note": "non-grid", className: "shrink-0 px-1 py-1 text-xs text-muted-foreground", children: text }));
78
+ }
@@ -0,0 +1,28 @@
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 key this rule is about. One spelling, shared by both readers. */
9
+ export declare const PARAMS_KEY = "params";
10
+ /**
11
+ * Is this value a `params` BAG this rule walks? A plain object only - see the
12
+ * module header for why an array `params` (the definition list) is not.
13
+ */
14
+ export declare function isParamsBag(value: unknown): value is Record<string, unknown>;
15
+ /**
16
+ * Visit one string leaf. `path` spells where it sits the way an author would
17
+ * search for it: `params.target.id`, `params.ids[0]`.
18
+ */
19
+ export type ParamsLeafVisitor = (leaf: string, path: string) => unknown;
20
+ /**
21
+ * Map every string leaf of a `params` bag through `visit`, at any depth, and
22
+ * return the result - the bag itself when nothing changed (copy on write).
23
+ *
24
+ * The evaluation memo passes the evaluator; the dev diagnostic passes a visitor
25
+ * that records and returns the leaf unchanged, which is what makes the two
26
+ * radii one radius by construction.
27
+ */
28
+ export declare function mapParamsLeaves(bag: Record<string, unknown>, visit: ParamsLeafVisitor, basePath?: string): Record<string, unknown>;
@@ -0,0 +1,146 @@
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
+ /**
9
+ * An action's `params` values are templates, evaluated where `properties` are
10
+ * (objectui#7867, ruling A, maintainer 「其他同意」 2026-09-20).
11
+ *
12
+ * ## The rule
13
+ *
14
+ * Every STRING LEAF of a node's `params` bag - the node-level `params` and
15
+ * `properties.params` alike, at any depth - is template-evaluated by the
16
+ * `SchemaRenderer` evaluation memo, with the same evaluator and the same
17
+ * scope (`record`, `current_user`, `page`, the host's ambient roots) that
18
+ * already evaluates `properties`. Before it, `params` was the one argument bag
19
+ * an `action:button` hands the runner, and nothing walked it: the node-level
20
+ * bag was never evaluated at all, and `properties.params` was ONE value of a
21
+ * per-value, shallow loop, so a template one level inside it was never
22
+ * visited. A metadata-authored `navigate_edit` could not name the record it
23
+ * sits on.
24
+ *
25
+ * ## Why this module exists, and why it is not in `SchemaRenderer.tsx`
26
+ *
27
+ * Two readers must agree on exactly one radius: the memo that EVALUATES the
28
+ * leaves, and the dev diagnostic (`unevaluatedExpression.ts`) that REPORTS the
29
+ * leaves still carrying `${…}` afterwards. That diagnostic's header states its
30
+ * own rule - "Depth: exactly the evaluator's own radius, and not one level
31
+ * more" - so a second copy of this walk would be a second definition of that
32
+ * radius, free to drift. Both import this one. It lives in its own module
33
+ * rather than in the diagnostic's because the memo runs in production and the
34
+ * diagnostic module is meant to be dead code there.
35
+ *
36
+ * ## What counts as a bag, a container and a leaf
37
+ *
38
+ * - **The bag** is a PLAIN object: an object literal or JSON object, whose
39
+ * prototype is `Object.prototype` or `null`. An ARRAY `params` is not a bag:
40
+ * it is the `ActionParam[]` definition list `action:button` routes to
41
+ * `actionParams` for the params dialog, whose members carry predicates and
42
+ * formats (`visible`, `titleFormat`, option `visibleWhen`) that are evaluated
43
+ * later, against another scope. It is left untouched, as before.
44
+ * - **Containers** inside the bag - plain objects and arrays - are walked.
45
+ * - **String leaves** are handed to the visitor. Everything else is passed
46
+ * through by IDENTITY and never looked inside: numbers, booleans, `null`,
47
+ * `undefined`, functions, and non-plain objects (`Date`, `Map`, class
48
+ * instances). Those are host-built values, not authored templates.
49
+ * - **Keys are never visited**, only values. A key that looks like a template
50
+ * stays exactly as written.
51
+ * - **An expression envelope** (`{ dialect, source }`) is a plain object like
52
+ * any other: it is walked, and its `source` string is a leaf. It is never
53
+ * collapsed to its `source` the way a bare top-level config value is.
54
+ * - **Cycles** end the walk: a container already on the current path is passed
55
+ * through unwalked. JSON metadata cannot carry one; a host-built schema can.
56
+ *
57
+ * ## Copy on write
58
+ *
59
+ * A container is copied only when one of its descendants changed, so a bag
60
+ * with no template in it comes back as the very object that went in, and a
61
+ * changed bag never mutates the authored one (the memo's input is the
62
+ * caller's schema). Nothing may depend on that identity (AGENTS.md #10); it is
63
+ * there so that a template-free bag stays byte-for-byte and reference-for-
64
+ * reference what it was.
65
+ */
66
+ import { isConfigBag } from './configBag.js';
67
+ /** The key this rule is about. One spelling, shared by both readers. */
68
+ export const PARAMS_KEY = 'params';
69
+ /**
70
+ * A plain object: an object literal / JSON object, and nothing more exotic.
71
+ *
72
+ * Built ON the package's one "is this a config bag?" answer (objectui#6761)
73
+ * and narrowed by prototype, rather than re-spelling the object/array test:
74
+ * this rule asks that question plus one more (a `Date` or class instance is a
75
+ * bag to {@link isConfigBag}, and is not something this walk looks inside).
76
+ */
77
+ function isPlainObject(value) {
78
+ if (!isConfigBag(value))
79
+ return false;
80
+ const proto = Object.getPrototypeOf(value);
81
+ return proto === Object.prototype || proto === null;
82
+ }
83
+ /**
84
+ * Is this value a `params` BAG this rule walks? A plain object only - see the
85
+ * module header for why an array `params` (the definition list) is not.
86
+ */
87
+ export function isParamsBag(value) {
88
+ return isPlainObject(value);
89
+ }
90
+ function walk(value, path, visit, onPath) {
91
+ if (typeof value === 'string')
92
+ return visit(value, path);
93
+ if (Array.isArray(value)) {
94
+ if (onPath.has(value))
95
+ return value;
96
+ onPath.add(value);
97
+ let copy;
98
+ for (let i = 0; i < value.length; i++) {
99
+ const next = walk(value[i], path + '[' + i + ']', visit, onPath);
100
+ if (next !== value[i]) {
101
+ if (!copy)
102
+ copy = value.slice();
103
+ copy[i] = next;
104
+ }
105
+ }
106
+ onPath.delete(value);
107
+ return copy ?? value;
108
+ }
109
+ if (isPlainObject(value)) {
110
+ if (onPath.has(value))
111
+ return value;
112
+ onPath.add(value);
113
+ let copy;
114
+ for (const key of Object.keys(value)) {
115
+ const leaf = value[key];
116
+ const next = walk(leaf, path + '.' + key, visit, onPath);
117
+ if (next !== leaf) {
118
+ if (!copy)
119
+ copy = { ...value };
120
+ // `defineProperty`, not assignment: an own `__proto__` key (which
121
+ // `JSON.parse` does produce) must stay an own data property rather than
122
+ // reach the prototype setter.
123
+ Object.defineProperty(copy, key, {
124
+ value: next,
125
+ writable: true,
126
+ enumerable: true,
127
+ configurable: true,
128
+ });
129
+ }
130
+ }
131
+ onPath.delete(value);
132
+ return copy ?? value;
133
+ }
134
+ return value;
135
+ }
136
+ /**
137
+ * Map every string leaf of a `params` bag through `visit`, at any depth, and
138
+ * return the result - the bag itself when nothing changed (copy on write).
139
+ *
140
+ * The evaluation memo passes the evaluator; the dev diagnostic passes a visitor
141
+ * that records and returns the leaf unchanged, which is what makes the two
142
+ * radii one radius by construction.
143
+ */
144
+ export function mapParamsLeaves(bag, visit, basePath = PARAMS_KEY) {
145
+ return walk(bag, basePath, visit, new Set());
146
+ }