@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
@@ -0,0 +1,198 @@
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 { useContext, useEffect, useMemo, useState } from 'react';
53
+ import { ElementApiDataSourceSchema } from '@objectstack/spec/ui';
54
+ import { SchemaRendererContext } from '../context/SchemaRendererContext.js';
55
+ import { isConfigBag } from '../utils/configBag.js';
56
+ /**
57
+ * Is this `dataSource` value the API binding — whatever it says beyond
58
+ * `provider`? `provider: 'api'` is the spec's discriminator between the two
59
+ * shapes of `PageComponentSchema.dataSource`; the object binding carries no
60
+ * `provider`.
61
+ */
62
+ export function isElementApiDataSourceShape(value) {
63
+ return isConfigBag(value) && value.provider === 'api';
64
+ }
65
+ /**
66
+ * Judge a node's `dataSource` as an API binding: `absent` when it is not one,
67
+ * `refused` with the spec's message when the spec refuses it, otherwise the
68
+ * parsed binding.
69
+ */
70
+ export function judgeElementApiDataSource(value) {
71
+ if (!isElementApiDataSourceShape(value))
72
+ return { status: 'absent' };
73
+ const parsed = ElementApiDataSourceSchema.safeParse(value);
74
+ if (!parsed.success) {
75
+ return {
76
+ status: 'refused',
77
+ error: parsed.error.issues.map((issue) => issue.message).join(' '),
78
+ };
79
+ }
80
+ return { status: 'parsed', config: parsed.data };
81
+ }
82
+ /**
83
+ * Select `path` (dotted segments; a numeric segment indexes an array) from a
84
+ * response body. `found: false` when any segment has nothing under it — an
85
+ * absent key, not a key whose value is `undefined`, is what "selects nothing"
86
+ * means.
87
+ */
88
+ export function selectElementApiPath(body, path) {
89
+ if (!path)
90
+ return { found: true, value: body };
91
+ let current = body;
92
+ for (const segment of path.split('.')) {
93
+ if (Array.isArray(current) && /^\d+$/.test(segment)) {
94
+ const index = Number(segment);
95
+ if (index >= current.length)
96
+ return { found: false, segment };
97
+ current = current[index];
98
+ continue;
99
+ }
100
+ if (!isConfigBag(current) || !Object.prototype.hasOwnProperty.call(current, segment)) {
101
+ return { found: false, segment };
102
+ }
103
+ current = current[segment];
104
+ }
105
+ return { found: true, value: current };
106
+ }
107
+ /** Pending reads, keyed by URL. An entry lives exactly as long as its request. */
108
+ const IN_FLIGHT = new Map();
109
+ /**
110
+ * `GET url` through `doFetch`, resolving with the parsed JSON body. Rejects on
111
+ * a network failure, a non-2xx answer or a body that is not JSON. Callers that
112
+ * ask while a read of the same URL is pending share it.
113
+ */
114
+ export function readElementApi(url, doFetch) {
115
+ const pending = IN_FLIGHT.get(url);
116
+ if (pending)
117
+ return pending;
118
+ const request = doFetch(url, {
119
+ method: 'GET',
120
+ headers: { Accept: 'application/json' },
121
+ credentials: 'include',
122
+ })
123
+ .then(async (res) => {
124
+ if (!res.ok)
125
+ throw new Error(`GET ${url} answered ${res.status}.`);
126
+ try {
127
+ return await res.json();
128
+ }
129
+ catch {
130
+ throw new Error(`GET ${url} did not answer JSON.`);
131
+ }
132
+ })
133
+ .finally(() => {
134
+ IN_FLIGHT.delete(url);
135
+ });
136
+ IN_FLIGHT.set(url, request);
137
+ return request;
138
+ }
139
+ /**
140
+ * Resolve a node's API binding: read its endpoint and select its `path`.
141
+ *
142
+ * Called unconditionally by `SchemaRenderer` for every node; a node without an
143
+ * API binding costs one `isPlainObject` test and issues no request.
144
+ */
145
+ export function useElementApiDataSource(schema) {
146
+ const context = useContext(SchemaRendererContext);
147
+ const doFetch = context?.apiFetch ?? fetch;
148
+ const raw = isConfigBag(schema) ? schema.dataSource : undefined;
149
+ // Keyed on the binding's CONTENT, never on its identity (AGENTS.md #10): a
150
+ // re-render that hands over an equal binding is the same read.
151
+ const bindingKey = isElementApiDataSourceShape(raw) ? JSON.stringify(raw) : '';
152
+ const judged = useMemo(() => judgeElementApiDataSource(bindingKey ? JSON.parse(bindingKey) : undefined), [bindingKey]);
153
+ const url = judged.status === 'parsed' ? judged.config.read.url : '';
154
+ const path = judged.status === 'parsed' ? judged.config.path : undefined;
155
+ const requestKey = url ? JSON.stringify([url, path ?? null]) : '';
156
+ // The settled answer carries its own key instead of being cleared when the
157
+ // binding changes, so the effect never sets state synchronously; an answer
158
+ // whose key no longer matches reads as `loading` (useElementDataSource's rule).
159
+ const [settled, setSettled] = useState(null);
160
+ useEffect(() => {
161
+ if (!url)
162
+ return;
163
+ let cancelled = false;
164
+ readElementApi(url, doFetch).then((body) => {
165
+ if (cancelled)
166
+ return;
167
+ const selected = selectElementApiPath(body, path);
168
+ setSettled(selected.found
169
+ ? { key: requestKey, ok: true, data: selected.value }
170
+ : {
171
+ key: requestKey,
172
+ ok: false,
173
+ error: `dataSource.path "${path}" selects nothing in the answer of GET ${url}: `
174
+ + `it has no "${selected.segment}".`,
175
+ });
176
+ }, (error) => {
177
+ if (cancelled)
178
+ return;
179
+ setSettled({
180
+ key: requestKey,
181
+ ok: false,
182
+ error: error instanceof Error ? error.message : `GET ${url} failed.`,
183
+ });
184
+ });
185
+ return () => {
186
+ cancelled = true;
187
+ };
188
+ }, [url, path, requestKey, doFetch]);
189
+ if (judged.status === 'absent')
190
+ return { status: 'absent' };
191
+ if (judged.status === 'refused')
192
+ return { status: 'refused', error: judged.error };
193
+ if (settled?.key !== requestKey)
194
+ return { status: 'loading', config: judged.config };
195
+ if (!settled.ok)
196
+ return { status: 'error', config: judged.config, error: settled.error };
197
+ return { status: 'ready', config: judged.config, data: settled.data };
198
+ }
@@ -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';
@@ -22,8 +22,9 @@
22
22
  * ```
23
23
  */
24
24
  import { useCallback, useContext, useMemo } from 'react';
25
- import { ActionEngine, } from '@object-ui/core';
25
+ import { ActionEngine, subjectPermissionsOf, } from '@object-ui/core';
26
26
  import { ActionCtxReact } from '../context/ActionContext.js';
27
+ import { usePredicateScope } from './useExpression.js';
27
28
  export function useActionEngine(options = {}) {
28
29
  const { actions = [], context = {} } = options;
29
30
  // When wrapped in an <ActionProvider>, reuse its ActionRunner so that:
@@ -39,7 +40,42 @@ export function useActionEngine(options = {}) {
39
40
  // fall back to a self-contained runner constructed from the `context` arg.
40
41
  const providerCtx = useContext(ActionCtxReact);
41
42
  const sharedRunner = providerCtx?.runner ?? null;
43
+ // The acting subject the host's predicate scope binds as `current_user`
44
+ // (objectui#11212): the ONE object `ExpressionProvider` publishes under
45
+ // `current_user` / `user` / `ctx.user` / `os.user`, carrying the caller's
46
+ // effective object permissions once they have loaded. The engine filters
47
+ // `visible` against the RUNNER's bag, which a host seeds with its own `user`
48
+ // and never with `current_user` — so `current_user.can(…)`, and any other
49
+ // `current_user.*` predicate, faulted on this path in every state and the
50
+ // action was hidden even for a grant holder, while the same predicate
51
+ // answered on every predicate-scope surface. Binding the scope's subject
52
+ // itself — the same object, not a copy, because the engine answers `can`
53
+ // only for a receiver IDENTICAL to the bound `current_user` — is what makes
54
+ // the two paths one bag (objectui#6493). With no host scope (`{}`) nothing
55
+ // is bound and the path behaves as it always did.
56
+ //
57
+ // Only `current_user` is added. The runner's `user` / `ctx.user` / `os.user`
58
+ // stay the host's own object: it carries what the runner itself reads (the
59
+ // `systemPermissions` capability set), which the scope's subject does not.
60
+ //
61
+ // Since objectui#11242 `<ActionProvider>` binds the same subject on its own
62
+ // runner, so under a provider mounted INSIDE the predicate scope this write
63
+ // re-binds the object that is already there. It stays for the two runners
64
+ // no provider binds: this hook's own standalone runner (no provider at
65
+ // all), and a shared runner whose provider sits ABOVE the scope — the
66
+ // console's global provider does (`GlobalActionRuntimeProvider` wraps the
67
+ // routes, and `ExpressionProvider` is mounted inside them) — where the
68
+ // provider has no scope to read.
69
+ const subject = usePredicateScope().current_user;
70
+ // Keyed on what the binding READS, never on the subject's identity (a
71
+ // memoised value upstream — AGENTS.md #10): its serialisable fields, and the
72
+ // permissions map it carries, which the upstream adapter caches per payload
73
+ // outside React. A discarded-and-recomputed subject with the same content
74
+ // keeps the engine; the permissions arriving (or leaving) rebuilds it.
75
+ const subjectKey = subject === undefined ? '' : JSON.stringify(subject);
76
+ const subjectPermissions = subjectPermissionsOf(subject);
42
77
  const engine = useMemo(() => {
78
+ const bound = subject !== undefined ? { current_user: subject } : {};
43
79
  // When standalone (no surrounding `<ActionProvider>`), normalize the
44
80
  // context so predicates can use both `record`/`user` and `ctx.*`.
45
81
  const normalizedStandalone = (context && Object.keys(context).length > 0)
@@ -48,8 +84,9 @@ export function useActionEngine(options = {}) {
48
84
  ctx: (context.ctx && typeof context.ctx === 'object')
49
85
  ? { ...context, ...context.ctx }
50
86
  : { ...context },
87
+ ...bound,
51
88
  }
52
- : context;
89
+ : { ...context, ...bound };
53
90
  const e = sharedRunner ? new ActionEngine(sharedRunner) : new ActionEngine(normalizedStandalone);
54
91
  // When sharing a provider runner, MERGE per-render flat keys into the
55
92
  // existing `ctx` instead of overwriting it. The provider seeds
@@ -67,13 +104,19 @@ export function useActionEngine(options = {}) {
67
104
  const merged = {
68
105
  ...context,
69
106
  ctx: { ...existingCtx, ...callerCtx },
107
+ ...bound,
70
108
  };
71
109
  runner.updateContext(merged);
72
110
  }
111
+ else if (sharedRunner && subject !== undefined) {
112
+ // A caller that passes no per-render keys: the subject is still bound,
113
+ // onto the same shared runner the location filter reads.
114
+ e.getRunner().updateContext(bound);
115
+ }
73
116
  e.registerActions(actions);
74
117
  return e;
75
118
  // eslint-disable-next-line react-hooks/exhaustive-deps
76
- }, [sharedRunner, JSON.stringify(actions), JSON.stringify(context)]);
119
+ }, [sharedRunner, JSON.stringify(actions), JSON.stringify(context), subjectKey, subjectPermissions]);
77
120
  const getActionsForLocation = useCallback((location) => engine.getActionsForLocation(location), [engine]);
78
121
  const getBulkActions = useCallback(() => engine.getBulkActions(), [engine]);
79
122
  const executeAction = useCallback((name, contextOverride) => engine.executeAction(name, contextOverride), [engine]);
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { useState, useCallback, useMemo } from 'react';
9
9
  import { ActionRunner, } from '@object-ui/core';
10
+ import { useActionRunnerTranslator } from '../context/actionRunnerTranslator.js';
10
11
  /**
11
12
  * Hook for executing actions with loading state, typed with ActionDef.
12
13
  *
@@ -35,8 +36,11 @@ export function useActionRunner(optionsOrContext = {}) {
35
36
  const [loading, setLoading] = useState(false);
36
37
  const [error, setError] = useState(null);
37
38
  const [result, setResult] = useState(null);
39
+ // The runner's own copy (the generic success toast) in the session's language.
40
+ const translate = useActionRunnerTranslator();
38
41
  const runner = useMemo(() => {
39
42
  const r = new ActionRunner(context);
43
+ r.setTranslator(translate);
40
44
  if (onConfirm)
41
45
  r.setConfirmHandler(onConfirm);
42
46
  if (onToast)
@@ -40,18 +40,41 @@
40
40
  * AUTHORED text. That is `useObjectLabel`'s own fallback contract; nothing
41
41
  * here overrides it.
42
42
  * - **A nameless action is not translatable.** Without `action.name` there is
43
- * no `_actions.<name>` key to look up, so the literals pass through.
43
+ * no `_actions.<name>` key to look up, so the literals pass through —
44
+ * collapsed to the active language like every other caller's (below), since
45
+ * an inline action (`element:button`) is often nameless (objectui#12757).
46
+ * - **The entry is keyed on the action's own object** (objectui#11439): its
47
+ * declared `objectName`, else the host object the caller draws it on — the
48
+ * object `translateObject` in `@objectstack/spec` stamps on an embedded
49
+ * action. Only an action with neither reads `globalActions.<name>`; a bound
50
+ * action never does (see `actionSuffixes` in `@object-ui/i18n`). The
51
+ * runtime-side resolvers (`useConsoleActionRuntime`, `RecordDetailView`)
52
+ * already key the param / description / result-dialog copy the same way, so
53
+ * one action reads one bundle node on every surface.
44
54
  * - **`confirmText` / `successMessage` are resolved only when the action
45
55
  * DECLARES them.** A bundle must not be able to bolt a confirmation gate
46
56
  * onto an action whose metadata never asked for one (nor a success toast).
47
57
  * Translation localizes what exists; it does not add behaviour. This mirrors
48
58
  * what the already-correct call sites (`RecordDetailView`, `ObjectView`,
49
59
  * `DeclaredActionsBar`) each spelled out by hand.
50
- * - **`label` is reduced through `pickLocalized` first**, because since rc.6 an
51
- * authored `label` may be an `I18nLabel` map rather than a string. The map is
52
- * collapsed to the active language BEFORE it is offered to the bundle as the
53
- * fallback, so a per-locale literal and a bundle entry cannot disagree about
54
- * what "the authored label" is.
60
+ * - **`outcomeMessages` follows the same rule, entry by entry**
61
+ * (`@objectstack/spec` 17.6.0, objectui#11344): each outcome the action
62
+ * declares resolves `_actions.<name>.outcomeMessages.<outcome>`; a bundle
63
+ * entry for an outcome the action does not declare is never read, so a
64
+ * bundle cannot add copy for an answer the author never wrote one for.
65
+ * - **`label`, `confirmText`, `successMessage`, `errorMessage` and each
66
+ * `outcomeMessages` entry are reduced through `pickLocalized` first**,
67
+ * because each is an `I18nLabel` — a string or an inline per-locale map. The map is collapsed to the active language
68
+ * BEFORE it is offered to the bundle as the fallback, so a per-locale
69
+ * literal and a bundle entry cannot disagree about what "the authored text"
70
+ * is, and the action runner, which has no language, only ever receives a
71
+ * string. `errorMessage` has no bundle key (the spec's action translation
72
+ * declares none), so the collapse is all it gets (objectui#12757).
73
+ * - **The success copy stays a template.** A `${result.*}` token, in a bundle
74
+ * entry or in the authored text, passes through this hook untouched: the
75
+ * action has not run yet, so there is no result to read. The runner fills
76
+ * the token in after the action succeeds, through the same `${result.*}`
77
+ * scope `onSuccess.navigate` reads — one interpolation, in one place.
55
78
  */
56
79
  export interface ActionTextLocalizerOptions {
57
80
  /**
@@ -61,9 +84,12 @@ export interface ActionTextLocalizerOptions {
61
84
  fallbackLabel?: string;
62
85
  }
63
86
  /**
64
- * Returns a copy of `action` with `label` — plus `confirmText` and
65
- * `successMessage` when the action declares them — resolved from the active
66
- * locale's bundle, falling back to the authored literals.
87
+ * Returns a copy of `action` with `label` — plus `confirmText`,
88
+ * `successMessage`, `errorMessage` and `outcomeMessages` when the action
89
+ * declares them —
90
+ * resolved from the active locale's bundle, falling back to the authored
91
+ * literals. `objectName` is the host object the action is drawn on; the
92
+ * action's own declared `objectName` wins over it when present.
67
93
  */
68
94
  export type ActionTextLocalizer = <T extends Record<string, any>>(objectName: string | undefined, action: T, options?: ActionTextLocalizerOptions) => T;
69
95
  export declare function useActionTextLocalizer(): ActionTextLocalizer;
@@ -40,23 +40,49 @@
40
40
  * AUTHORED text. That is `useObjectLabel`'s own fallback contract; nothing
41
41
  * here overrides it.
42
42
  * - **A nameless action is not translatable.** Without `action.name` there is
43
- * no `_actions.<name>` key to look up, so the literals pass through.
43
+ * no `_actions.<name>` key to look up, so the literals pass through —
44
+ * collapsed to the active language like every other caller's (below), since
45
+ * an inline action (`element:button`) is often nameless (objectui#12757).
46
+ * - **The entry is keyed on the action's own object** (objectui#11439): its
47
+ * declared `objectName`, else the host object the caller draws it on — the
48
+ * object `translateObject` in `@objectstack/spec` stamps on an embedded
49
+ * action. Only an action with neither reads `globalActions.<name>`; a bound
50
+ * action never does (see `actionSuffixes` in `@object-ui/i18n`). The
51
+ * runtime-side resolvers (`useConsoleActionRuntime`, `RecordDetailView`)
52
+ * already key the param / description / result-dialog copy the same way, so
53
+ * one action reads one bundle node on every surface.
44
54
  * - **`confirmText` / `successMessage` are resolved only when the action
45
55
  * DECLARES them.** A bundle must not be able to bolt a confirmation gate
46
56
  * onto an action whose metadata never asked for one (nor a success toast).
47
57
  * Translation localizes what exists; it does not add behaviour. This mirrors
48
58
  * what the already-correct call sites (`RecordDetailView`, `ObjectView`,
49
59
  * `DeclaredActionsBar`) each spelled out by hand.
50
- * - **`label` is reduced through `pickLocalized` first**, because since rc.6 an
51
- * authored `label` may be an `I18nLabel` map rather than a string. The map is
52
- * collapsed to the active language BEFORE it is offered to the bundle as the
53
- * fallback, so a per-locale literal and a bundle entry cannot disagree about
54
- * what "the authored label" is.
60
+ * - **`outcomeMessages` follows the same rule, entry by entry**
61
+ * (`@objectstack/spec` 17.6.0, objectui#11344): each outcome the action
62
+ * declares resolves `_actions.<name>.outcomeMessages.<outcome>`; a bundle
63
+ * entry for an outcome the action does not declare is never read, so a
64
+ * bundle cannot add copy for an answer the author never wrote one for.
65
+ * - **`label`, `confirmText`, `successMessage`, `errorMessage` and each
66
+ * `outcomeMessages` entry are reduced through `pickLocalized` first**,
67
+ * because each is an `I18nLabel` — a string or an inline per-locale map. The map is collapsed to the active language
68
+ * BEFORE it is offered to the bundle as the fallback, so a per-locale
69
+ * literal and a bundle entry cannot disagree about what "the authored text"
70
+ * is, and the action runner, which has no language, only ever receives a
71
+ * string. `errorMessage` has no bundle key (the spec's action translation
72
+ * declares none), so the collapse is all it gets (objectui#12757).
73
+ * - **The success copy stays a template.** A `${result.*}` token, in a bundle
74
+ * entry or in the authored text, passes through this hook untouched: the
75
+ * action has not run yet, so there is no result to read. The runner fills
76
+ * the token in after the action succeeds, through the same `${result.*}`
77
+ * scope `onSuccess.navigate` reads — one interpolation, in one place.
55
78
  */
56
79
  import { useMemo } from 'react';
57
80
  import { useObjectLabel, useObjectTranslation, pickLocalized } from '@object-ui/i18n';
81
+ import { isConfigBag } from '../utils/configBag.js';
82
+ /** The declared `I18nLabel` copy, besides `label`, that reaches the runner. */
83
+ const DECLARED_COPY_KEYS = ['confirmText', 'successMessage', 'errorMessage'];
58
84
  export function useActionTextLocalizer() {
59
- const { actionLabel, actionConfirm, actionSuccess } = useObjectLabel();
85
+ const { actionLabel, actionConfirm, actionSuccess, actionOutcome } = useObjectLabel();
60
86
  const { language } = useObjectTranslation();
61
87
  return useMemo(() => {
62
88
  return ((objectName, action, options) => {
@@ -69,23 +95,53 @@ export function useActionTextLocalizer() {
69
95
  ? action.name
70
96
  : undefined;
71
97
  const displayLabel = authoredLabel || name || options?.fallbackLabel || '';
72
- // No name, no translation key: the literals are the answer. The label is
98
+ // Every declared `I18nLabel` copy collapses the same way, so neither the
99
+ // bundle fallbacks below nor the nameless return hand the runner a map.
100
+ const collapsed = { ...action, label: displayLabel };
101
+ let changed = displayLabel !== action.label;
102
+ for (const key of DECLARED_COPY_KEYS) {
103
+ const declaredCopy = action[key];
104
+ if (declaredCopy === undefined)
105
+ continue;
106
+ collapsed[key] = pickLocalized(declaredCopy, language);
107
+ if (collapsed[key] !== declaredCopy)
108
+ changed = true;
109
+ }
110
+ // No name, no translation key: the literals are the answer. They are
73
111
  // still normalized (map to string, last-resort placeholder applied) so
74
112
  // every caller reads one shape.
75
113
  if (!name) {
76
- return displayLabel === action.label ? action : { ...action, label: displayLabel };
114
+ return (changed ? collapsed : action);
77
115
  }
116
+ // The key object: the action's declared `objectName`, else the host the
117
+ // caller passes (objectui#11439). An action declared on `sales_order`
118
+ // drawn where the caller knows no object still reads `sales_order`'s
119
+ // `_actions` node, never `globalActions`.
120
+ const declared = action.objectName;
121
+ const keyObject = (typeof declared === 'string' && declared) || objectName;
78
122
  const out = {
79
- ...action,
80
- label: actionLabel(objectName, name, displayLabel),
123
+ ...collapsed,
124
+ label: actionLabel(keyObject, name, displayLabel),
81
125
  };
82
126
  if (action.confirmText !== undefined) {
83
- out.confirmText = actionConfirm(objectName, name, action.confirmText);
127
+ out.confirmText = actionConfirm(keyObject, name, collapsed.confirmText);
84
128
  }
85
129
  if (action.successMessage !== undefined) {
86
- out.successMessage = actionSuccess(objectName, name, action.successMessage);
130
+ out.successMessage = actionSuccess(keyObject, name, collapsed.successMessage);
131
+ }
132
+ // The map is an authored bag (outcome → copy), so the one "is this a
133
+ // real bag?" predicate in this package answers whether to read it.
134
+ const authoredOutcomes = action.outcomeMessages;
135
+ if (isConfigBag(authoredOutcomes)) {
136
+ const outcomeMessages = {};
137
+ for (const [outcome, copy] of Object.entries(authoredOutcomes)) {
138
+ const text = actionOutcome(keyObject, name, outcome, pickLocalized(copy, language));
139
+ if (text !== undefined)
140
+ outcomeMessages[outcome] = text;
141
+ }
142
+ out.outcomeMessages = outcomeMessages;
87
143
  }
88
144
  return out;
89
145
  });
90
- }, [actionLabel, actionConfirm, actionSuccess, language]);
146
+ }, [actionLabel, actionConfirm, actionSuccess, actionOutcome, language]);
91
147
  }
@@ -0,0 +1,31 @@
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
+ * The `properties` evaluation of the `SchemaRenderer` memo, for a node that is
10
+ * rendered WITHOUT `SchemaRenderer` (objectui#10290).
11
+ *
12
+ * `action:bar` (in `@object-ui/components`) mounts its member actions itself,
13
+ * so a member's `properties` never passes through the memo. Its static
14
+ * execution values ride `properties.params` (objectui#10289; an `action:group`
15
+ * / `action:menu` member carries no `properties` bag and its container reads
16
+ * none, objectui#11638), and those values are templates
17
+ * evaluated where `properties` are (objectui#7867). This hook is that
18
+ * evaluation: the memo's per-key rule, its `ExpressionEvaluator`, and its
19
+ * scope (the host's ambient roots, `current_user`, the page's bound row as
20
+ * `record`, and page variables as `page`), read from the same contexts the memo
21
+ * reads. It is not a second template engine. See `evaluateConfigBagInScope`.
22
+ *
23
+ * The returned function takes a config bag and returns a new, evaluated bag. A
24
+ * value that is not a config bag is returned unchanged. Call it once per
25
+ * authored bag: the result is already evaluated, and evaluating it again would
26
+ * interpolate text that came out of the row.
27
+ *
28
+ * A fresh function each render. Nothing may depend on its identity
29
+ * (AGENTS.md #10).
30
+ */
31
+ export declare function useConfigBagEvaluator(): (bag: unknown) => unknown;
@@ -0,0 +1,40 @@
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 { useRecordContext } from '../context/RecordContext.js';
9
+ import { evaluateConfigBagInScope } from '../SchemaRenderer.js';
10
+ import { usePredicateScope } from './useExpression.js';
11
+ import { usePageVariables } from './usePageVariables.js';
12
+ /**
13
+ * The `properties` evaluation of the `SchemaRenderer` memo, for a node that is
14
+ * rendered WITHOUT `SchemaRenderer` (objectui#10290).
15
+ *
16
+ * `action:bar` (in `@object-ui/components`) mounts its member actions itself,
17
+ * so a member's `properties` never passes through the memo. Its static
18
+ * execution values ride `properties.params` (objectui#10289; an `action:group`
19
+ * / `action:menu` member carries no `properties` bag and its container reads
20
+ * none, objectui#11638), and those values are templates
21
+ * evaluated where `properties` are (objectui#7867). This hook is that
22
+ * evaluation: the memo's per-key rule, its `ExpressionEvaluator`, and its
23
+ * scope (the host's ambient roots, `current_user`, the page's bound row as
24
+ * `record`, and page variables as `page`), read from the same contexts the memo
25
+ * reads. It is not a second template engine. See `evaluateConfigBagInScope`.
26
+ *
27
+ * The returned function takes a config bag and returns a new, evaluated bag. A
28
+ * value that is not a config bag is returned unchanged. Call it once per
29
+ * authored bag: the result is already evaluated, and evaluating it again would
30
+ * interpolate text that came out of the row.
31
+ *
32
+ * A fresh function each render. Nothing may depend on its identity
33
+ * (AGENTS.md #10).
34
+ */
35
+ export function useConfigBagEvaluator() {
36
+ const predicateScope = usePredicateScope();
37
+ const boundRecord = useRecordContext()?.data;
38
+ const { variables: pageVariables } = usePageVariables();
39
+ return (bag) => evaluateConfigBagInScope(bag, predicateScope, boundRecord, pageVariables);
40
+ }