@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.
- package/CHANGELOG.md +4263 -0
- package/README.md +383 -42
- package/dist/SchemaRenderer.d.ts +64 -7
- package/dist/SchemaRenderer.js +1326 -106
- package/dist/context/ActionContext.js +40 -1
- package/dist/context/AppShellContext.d.ts +27 -0
- package/dist/context/DiscussionContext.d.ts +27 -0
- package/dist/context/DiscussionContext.js +4 -0
- package/dist/context/DrillNavigationContext.d.ts +13 -0
- package/dist/context/InlineEditContext.d.ts +151 -1
- package/dist/context/InlineEditContext.js +36 -2
- package/dist/context/PrintPageContext.d.ts +60 -0
- package/dist/context/PrintPageContext.js +32 -0
- package/dist/context/RecordContext.d.ts +48 -5
- package/dist/context/RecordContext.js +10 -1
- package/dist/context/RelatedRecordActionsContext.d.ts +72 -2
- package/dist/context/RelatedRecordActionsContext.js +4 -0
- package/dist/context/SchemaRendererContext.d.ts +61 -6
- package/dist/context/SchemaRendererContext.js +39 -6
- package/dist/context/ThemeContext.js +1 -1
- package/dist/context/actionRunnerTranslator.d.ts +27 -0
- package/dist/context/actionRunnerTranslator.js +37 -0
- package/dist/context/index.d.ts +1 -0
- package/dist/context/index.js +1 -0
- package/dist/element-data-source/ElementApiDataSourcePanel.d.ts +28 -0
- package/dist/element-data-source/ElementApiDataSourcePanel.js +10 -0
- package/dist/element-data-source/ElementDataSourceGate.d.ts +118 -3
- package/dist/element-data-source/ElementDataSourceGate.js +401 -19
- package/dist/element-data-source/useElementApiDataSource.d.ts +119 -0
- package/dist/element-data-source/useElementApiDataSource.js +198 -0
- package/dist/hooks/index.d.ts +3 -1
- package/dist/hooks/index.js +10 -1
- package/dist/hooks/useActionEngine.js +46 -3
- package/dist/hooks/useActionRunner.js +4 -0
- package/dist/hooks/useActionTextLocalizer.d.ts +35 -9
- package/dist/hooks/useActionTextLocalizer.js +70 -14
- package/dist/hooks/useConfigBagEvaluator.d.ts +31 -0
- package/dist/hooks/useConfigBagEvaluator.js +40 -0
- package/dist/hooks/useDatasetDimensionLabels.js +25 -10
- package/dist/hooks/useDensityMode.d.ts +8 -2
- package/dist/hooks/useDensityMode.js +8 -2
- package/dist/hooks/useDiscovery.d.ts +1 -10
- package/dist/hooks/useETagCache.js +25 -4
- package/dist/hooks/useElementDataSource.d.ts +15 -2
- package/dist/hooks/useElementDataSource.js +24 -7
- package/dist/hooks/useExpression.d.ts +52 -59
- package/dist/hooks/useExpression.js +58 -90
- package/dist/hooks/useFilterScope.d.ts +22 -2
- package/dist/hooks/useFilterScope.js +29 -4
- package/dist/hooks/useGlobalUndo.d.ts +10 -0
- package/dist/hooks/useGlobalUndo.js +112 -46
- package/dist/hooks/useNavigationOverlay.d.ts +76 -11
- package/dist/hooks/useNavigationOverlay.js +119 -9
- package/dist/hooks/useOffline.js +65 -11
- package/dist/hooks/usePageAssignment.d.ts +33 -7
- package/dist/hooks/usePageAssignment.js +85 -21
- package/dist/hooks/useRecordSearch.d.ts +34 -1
- package/dist/hooks/useRecordSearch.js +42 -7
- package/dist/hooks/useResolvedFilter.d.ts +46 -0
- package/dist/hooks/useResolvedFilter.js +130 -0
- package/dist/hooks/useSchemaPersistence.d.ts +7 -0
- package/dist/hooks/useSchemaPersistence.js +144 -5
- package/dist/hooks/useSettledSchema.d.ts +123 -0
- package/dist/hooks/useSettledSchema.js +186 -0
- package/dist/hooks/useViewData.d.ts +10 -3
- package/dist/hooks/useViewData.js +5 -14
- package/dist/index.d.ts +9 -1
- package/dist/index.js +48 -1
- package/dist/schema-input.d.ts +38 -8
- package/dist/schema-input.js +39 -7
- package/dist/utils/configBag.d.ts +68 -0
- package/dist/utils/configBag.js +70 -0
- package/dist/utils/error-message.d.ts +1 -1
- package/dist/utils/error-message.js +1 -1
- package/dist/utils/inlineAria.d.ts +61 -0
- package/dist/utils/inlineAria.js +70 -0
- package/dist/utils/localeMap.d.ts +27 -0
- package/dist/utils/localeMap.js +106 -0
- package/dist/utils/nonGridRowCeiling.d.ts +44 -0
- package/dist/utils/nonGridRowCeiling.js +78 -0
- package/dist/utils/paramsBag.d.ts +28 -0
- package/dist/utils/paramsBag.js +146 -0
- package/dist/utils/propsBagDiagnostic.d.ts +196 -0
- package/dist/utils/propsBagDiagnostic.js +422 -0
- package/dist/utils/refusedDataPropDiagnostic.d.ts +44 -0
- package/dist/utils/refusedDataPropDiagnostic.js +94 -0
- package/dist/utils/unevaluatedExpression.d.ts +44 -1
- package/dist/utils/unevaluatedExpression.js +92 -11
- package/dist/utils/visibilityDiagnostic.d.ts +282 -21
- package/dist/utils/visibilityDiagnostic.js +321 -46
- package/package.json +10 -10
- package/dist/hooks/useClientNotifications.d.ts +0 -47
- package/dist/hooks/useClientNotifications.js +0 -162
- package/dist/spec-bridge/SpecBridge.d.ts +0 -28
- package/dist/spec-bridge/SpecBridge.js +0 -56
- package/dist/spec-bridge/bridges/form-view.d.ts +0 -158
- package/dist/spec-bridge/bridges/form-view.js +0 -170
- package/dist/spec-bridge/bridges/list-view.d.ts +0 -20
- package/dist/spec-bridge/bridges/list-view.js +0 -268
- package/dist/spec-bridge/index.d.ts +0 -11
- package/dist/spec-bridge/index.js +0 -10
- package/dist/spec-bridge/types.d.ts +0 -55
- 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
|
+
}
|
package/dist/hooks/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/hooks/index.js
CHANGED
|
@@ -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
|
-
* - **`
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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
|
|
65
|
-
* `successMessage` when the action
|
|
66
|
-
*
|
|
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
|
-
* - **`
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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
|
-
//
|
|
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
|
|
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
|
-
...
|
|
80
|
-
label: actionLabel(
|
|
123
|
+
...collapsed,
|
|
124
|
+
label: actionLabel(keyObject, name, displayLabel),
|
|
81
125
|
};
|
|
82
126
|
if (action.confirmText !== undefined) {
|
|
83
|
-
out.confirmText = actionConfirm(
|
|
127
|
+
out.confirmText = actionConfirm(keyObject, name, collapsed.confirmText);
|
|
84
128
|
}
|
|
85
129
|
if (action.successMessage !== undefined) {
|
|
86
|
-
out.successMessage = actionSuccess(
|
|
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
|
+
}
|