@object-ui/react 17.5.0 → 17.6.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 +334 -0
- package/README.md +9 -8
- package/dist/LazyPluginLoader.d.ts +14 -7
- package/dist/LazyPluginLoader.js +14 -7
- package/dist/SchemaRenderer.js +434 -22
- package/dist/context/HostNavigationContext.d.ts +82 -0
- package/dist/context/HostNavigationContext.js +65 -0
- package/dist/context/RelatedRecordActionsContext.d.ts +15 -0
- package/dist/context/index.d.ts +13 -12
- package/dist/context/index.js +13 -12
- package/dist/element-data-source/ElementDataSourceGate.d.ts +75 -2
- package/dist/element-data-source/ElementDataSourceGate.js +81 -2
- package/dist/hooks/index.d.ts +35 -35
- package/dist/hooks/index.js +35 -35
- package/dist/hooks/useActionEngine.js +1 -1
- package/dist/hooks/useCapabilityGate.js +6 -5
- package/dist/hooks/useClientNotifications.js +2 -2
- package/dist/hooks/useCrudShortcuts.js +1 -1
- package/dist/hooks/useDatasetDimensionLabels.js +1 -1
- package/dist/hooks/useDiscovery.js +1 -1
- package/dist/hooks/useElementDataSource.js +5 -2
- package/dist/hooks/useExpression.d.ts +24 -3
- package/dist/hooks/useExpression.js +56 -4
- package/dist/hooks/useNavigationOverlay.d.ts +1 -1
- package/dist/hooks/usePageAssignment.js +1 -1
- package/dist/hooks/usePageTransition.js +1 -1
- package/dist/hooks/usePageVariableActionBridge.js +2 -2
- package/dist/hooks/useViewData.js +1 -1
- package/dist/hooks/useViewTransition.js +1 -1
- package/dist/index.d.ts +11 -11
- package/dist/index.js +10 -10
- package/dist/schema-input.d.ts +16 -5
- package/dist/schema-input.js +15 -4
- package/dist/spec-bridge/SpecBridge.d.ts +1 -1
- package/dist/spec-bridge/SpecBridge.js +2 -2
- package/dist/spec-bridge/bridges/form-view.d.ts +99 -23
- package/dist/spec-bridge/bridges/form-view.js +51 -7
- package/dist/spec-bridge/bridges/list-view.d.ts +1 -1
- package/dist/spec-bridge/bridges/list-view.js +37 -10
- package/dist/spec-bridge/index.d.ts +4 -4
- package/dist/spec-bridge/index.js +3 -3
- package/dist/utils/error-message.d.ts +59 -0
- package/dist/utils/error-message.js +116 -1
- package/dist/utils/unevaluatedExpression.d.ts +69 -0
- package/dist/utils/unevaluatedExpression.js +194 -0
- package/dist/utils/visibilityDiagnostic.d.ts +119 -0
- package/dist/utils/visibilityDiagnostic.js +284 -0
- package/package.json +7 -7
|
@@ -5,15 +5,48 @@
|
|
|
5
5
|
* This source code is licensed under the MIT license found in the
|
|
6
6
|
* LICENSE file in the root directory of this source tree.
|
|
7
7
|
*/
|
|
8
|
+
/**
|
|
9
|
+
* A spec `ListColumn` in, the same column out — in the spelling the spec
|
|
10
|
+
* declares (objectui#5068).
|
|
11
|
+
*
|
|
12
|
+
* This used to down-translate every column to the data-table adapter's
|
|
13
|
+
* `{ accessorKey, header }` before handing it to an `object-grid` node, and
|
|
14
|
+
* `ObjectGrid` translated it straight back through a tolerance branch that
|
|
15
|
+
* sniffed `columns[0]`. A round trip whose input was ALREADY canonical: the
|
|
16
|
+
* bridge's own parameter is `@objectstack/spec/ui`'s `ListColumn`, where
|
|
17
|
+
* `field` is required and `accessorKey` / `header` are refused by name
|
|
18
|
+
* (`ListColumnSchema` is a strict object). So the producer emitted a spelling
|
|
19
|
+
* the contract rejects, and the renderer grew a second de-facto contract to
|
|
20
|
+
* read it back — exactly the shape AGENTS.md #0.1 forbids, and the disposition
|
|
21
|
+
* objectui#3951 already settled: unify at the producer, no consumer-side alias.
|
|
22
|
+
*
|
|
23
|
+
* `accessorKey` is not retired as a concept — it is the TanStack adapter key
|
|
24
|
+
* `@object-ui/core` deliberately keeps OUT of the metadata identity fold
|
|
25
|
+
* (`column-identity.ts`'s `TABLE_ADAPTER_COLUMN_KEY`). `ObjectGrid` still
|
|
26
|
+
* applies it on the way OUT, at the one boundary that owns it. Metadata
|
|
27
|
+
* vocabulary comes in; adapter vocabulary goes out; one translation, one place.
|
|
28
|
+
*
|
|
29
|
+
* Nothing is invented on the way through, which is the second half of the fix.
|
|
30
|
+
* `header: col.label ?? col.field` used to turn "no label declared" into "the
|
|
31
|
+
* machine name was declared as the label", and that synthesized value then
|
|
32
|
+
* pre-empted `ObjectGrid`'s own header chain — `col.label` → the OBJECT
|
|
33
|
+
* FIELD's label → the prettified name — whose whole purpose is a column
|
|
34
|
+
* authored as a bare `{ field }`, and whose middle step exists so a localized
|
|
35
|
+
* field label wins over the name-derived English one on a non-English app. A
|
|
36
|
+
* bridged view therefore rendered raw machine names where a directly authored
|
|
37
|
+
* `object-grid` rendered the field's real, localizable label. It now forwards
|
|
38
|
+
* the declaration and lets the renderer resolve what was left unsaid.
|
|
39
|
+
*/
|
|
8
40
|
function mapColumn(col) {
|
|
9
|
-
// Spec
|
|
41
|
+
// Spec shorthand: a bare field name stands for a default column.
|
|
10
42
|
if (typeof col === 'string') {
|
|
11
|
-
return {
|
|
43
|
+
return { field: col };
|
|
12
44
|
}
|
|
13
45
|
const mapped = {
|
|
14
|
-
|
|
15
|
-
header: col.label ?? col.field,
|
|
46
|
+
field: col.field,
|
|
16
47
|
};
|
|
48
|
+
if (col.label != null)
|
|
49
|
+
mapped.label = col.label;
|
|
17
50
|
if (col.width != null)
|
|
18
51
|
mapped.width = col.width;
|
|
19
52
|
if (col.align)
|
|
@@ -178,10 +211,6 @@ export const bridgeListView = (spec, _context) => {
|
|
|
178
211
|
node.filterableFields = spec.filterableFields;
|
|
179
212
|
if (spec.resizable != null)
|
|
180
213
|
node.resizable = spec.resizable;
|
|
181
|
-
if (spec.striped != null)
|
|
182
|
-
node.striped = spec.striped;
|
|
183
|
-
if (spec.bordered != null)
|
|
184
|
-
node.bordered = spec.bordered;
|
|
185
214
|
if (spec.navigation)
|
|
186
215
|
node.navigation = spec.navigation;
|
|
187
216
|
if (spec.kanban)
|
|
@@ -201,8 +230,6 @@ export const bridgeListView = (spec, _context) => {
|
|
|
201
230
|
node.bulkActions = spec.bulkActions;
|
|
202
231
|
if (spec.bulkActionDefs)
|
|
203
232
|
node.bulkActionDefs = spec.bulkActionDefs;
|
|
204
|
-
if (spec.virtualScroll != null)
|
|
205
|
-
node.virtualScroll = spec.virtualScroll;
|
|
206
233
|
if (spec.conditionalFormatting)
|
|
207
234
|
node.conditionalFormatting = spec.conditionalFormatting;
|
|
208
235
|
if (spec.inlineEdit != null)
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* This source code is licensed under the MIT license found in the
|
|
6
6
|
* LICENSE file in the root directory of this source tree.
|
|
7
7
|
*/
|
|
8
|
-
export { SpecBridge } from './SpecBridge';
|
|
9
|
-
export type { BridgeContext, BridgeFn, ObjectDefLite } from './types';
|
|
10
|
-
export { bridgeListView } from './bridges/list-view';
|
|
11
|
-
export { bridgeFormView } from './bridges/form-view';
|
|
8
|
+
export { SpecBridge } from './SpecBridge.js';
|
|
9
|
+
export type { BridgeContext, BridgeFn, ObjectDefLite } from './types.js';
|
|
10
|
+
export { bridgeListView } from './bridges/list-view.js';
|
|
11
|
+
export { bridgeFormView } from './bridges/form-view.js';
|
|
@@ -5,6 +5,6 @@
|
|
|
5
5
|
* This source code is licensed under the MIT license found in the
|
|
6
6
|
* LICENSE file in the root directory of this source tree.
|
|
7
7
|
*/
|
|
8
|
-
export { SpecBridge } from './SpecBridge';
|
|
9
|
-
export { bridgeListView } from './bridges/list-view';
|
|
10
|
-
export { bridgeFormView } from './bridges/form-view';
|
|
8
|
+
export { SpecBridge } from './SpecBridge.js';
|
|
9
|
+
export { bridgeListView } from './bridges/list-view.js';
|
|
10
|
+
export { bridgeFormView } from './bridges/form-view.js';
|
|
@@ -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#9934 (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
|
|
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#9934 (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,69 @@
|
|
|
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
|
+
* Mirrors `ExpressionEvaluator.evaluate`: a `${` with no closing brace, or an
|
|
10
|
+
* empty `${}`, is not an expression to the evaluator (its interpolation regex
|
|
11
|
+
* requires at least one character), so it is not one here either. Matching the
|
|
12
|
+
* PRODUCER's definition is what keeps this from reporting strings the engine
|
|
13
|
+
* never intended to touch — prose and code samples that merely contain `${`.
|
|
14
|
+
*/
|
|
15
|
+
export declare function findExpressionSources(value: string): string[];
|
|
16
|
+
/** Which authored channel a residual expression was found on. */
|
|
17
|
+
export type UnevaluatedChannel = 'schema' | 'properties' | 'props';
|
|
18
|
+
export interface UnevaluatedExpressionFinding {
|
|
19
|
+
/** The key holding the raw source. */
|
|
20
|
+
key: string;
|
|
21
|
+
/** The authored channel the key was read from. */
|
|
22
|
+
channel: UnevaluatedChannel;
|
|
23
|
+
/** The value, verbatim, as it will be handed to the renderer. */
|
|
24
|
+
value: string;
|
|
25
|
+
/** The `${…}` fragments inside it. */
|
|
26
|
+
expressions: string[];
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Collect every unevaluated `${…}` that is about to be handed to a renderer.
|
|
30
|
+
*
|
|
31
|
+
* Pure and side-effect free — the reporting half is {@link reportUnevaluatedExpressions}.
|
|
32
|
+
* Kept separate so the scan can be asserted directly: a diagnostic whose only
|
|
33
|
+
* test is "a spy was called" goes green the moment someone no-ops it.
|
|
34
|
+
*
|
|
35
|
+
* @param spread The post-strip top-level bag — exactly what `SchemaRenderer`
|
|
36
|
+
* spreads as React props, which is also what renderers read as `schema.<key>`.
|
|
37
|
+
* Schema METADATA (`visible` / `hidden` / `disabled` / … ) must already be
|
|
38
|
+
* removed by the caller: those keys carry raw predicate source BY DESIGN and
|
|
39
|
+
* are evaluated as conditions, never placed.
|
|
40
|
+
* @param properties The node's `properties` bag, if any. Reported in
|
|
41
|
+
* preference to the top-level copy the hoist makes of it, so the finding
|
|
42
|
+
* names the key the author actually wrote.
|
|
43
|
+
* @param props The node's legacy `props` bag, if any.
|
|
44
|
+
*/
|
|
45
|
+
export declare function collectUnevaluatedExpressions(spread: Record<string, unknown> | undefined | null, properties?: unknown, props?: unknown): UnevaluatedExpressionFinding[];
|
|
46
|
+
/**
|
|
47
|
+
* Prefix every diagnostic line starts with. Exported so tests can match on it
|
|
48
|
+
* and so an app can filter it out of its console transport if it must.
|
|
49
|
+
*/
|
|
50
|
+
export declare const UNEVALUATED_EXPRESSION_PREFIX = "[ObjectUI] Unevaluated expression reached the DOM";
|
|
51
|
+
/**
|
|
52
|
+
* Build the message. Separate from the emit so a test can assert the words a
|
|
53
|
+
* developer is going to read, not merely that something was logged.
|
|
54
|
+
*/
|
|
55
|
+
export declare function formatUnevaluatedExpressionMessage(type: unknown, id: unknown, findings: UnevaluatedExpressionFinding[]): string;
|
|
56
|
+
/**
|
|
57
|
+
* Dev-build only. Scans, and on a hit reports once per schema object via
|
|
58
|
+
* `console.error`.
|
|
59
|
+
*
|
|
60
|
+
* `console.error` and not `warn`: this is the loud refusal objectui#4795's
|
|
61
|
+
* ruling asked for, and it follows the assertion shape objectui#5092 landed in
|
|
62
|
+
* SchemaForm. (It is also the level that survives happy-dom, which swallows
|
|
63
|
+
* `console.log`.)
|
|
64
|
+
*
|
|
65
|
+
* The caller applies the production gate, so this stays a single dev-only
|
|
66
|
+
* branch at the call site and the whole module is dead code in a production
|
|
67
|
+
* build.
|
|
68
|
+
*/
|
|
69
|
+
export declare function reportUnevaluatedExpressions(schema: object | null | undefined, type: unknown, id: unknown, spread: Record<string, unknown> | undefined | null, properties?: unknown, props?: unknown): UnevaluatedExpressionFinding[];
|
|
@@ -0,0 +1,194 @@
|
|
|
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
|
+
* Dev-build diagnostic: an UNEVALUATED template expression reached the DOM.
|
|
10
|
+
*
|
|
11
|
+
* ## The defect this names (objectui#4795, Direction 3)
|
|
12
|
+
*
|
|
13
|
+
* A value only reaches the user if it passes two gates: `SchemaRenderer`
|
|
14
|
+
* must EVALUATE it, and the renderer must READ IT BACK. Those two sets do not
|
|
15
|
+
* fully overlap, and where they miss, the failure is SILENT — measured on a
|
|
16
|
+
* real render with `dataSource: { total: 99 }`:
|
|
17
|
+
*
|
|
18
|
+
* { type: 'ui:statistic', value: '${data.n}' } -> screen shows
|
|
19
|
+
* `${data.n}`
|
|
20
|
+
* { type: 'ui:card', props: { title: '${data.total}' } } -> card body EMPTY
|
|
21
|
+
* (the evaluated
|
|
22
|
+
* `99` landed on a
|
|
23
|
+
* DOM attribute)
|
|
24
|
+
*
|
|
25
|
+
* The first shape is what this module catches: the memo in `SchemaRenderer`
|
|
26
|
+
* evaluates `content`, the `properties` / `props` bags and the predicate keys,
|
|
27
|
+
* and passes every other top-level key through untouched — so the raw source
|
|
28
|
+
* text is what the renderer receives, and it is placed verbatim. An author
|
|
29
|
+
* (increasingly, an AI authoring metadata) gets no signal at all: a literal
|
|
30
|
+
* `${data.n}` on screen reads like a data problem, not a contract violation.
|
|
31
|
+
*
|
|
32
|
+
* The second shape this module also catches, in its residue form: when an
|
|
33
|
+
* expression IS evaluated but the evaluation THROWS, `ExpressionEvaluator`
|
|
34
|
+
* returns the original source verbatim (`return match` in its interpolation
|
|
35
|
+
* branch, `defaultValue ?? expression` in its outer catch), so a failed
|
|
36
|
+
* evaluation is indistinguishable on screen from a key that was never
|
|
37
|
+
* evaluated at all. Both end as raw `${…}` in front of a user.
|
|
38
|
+
*
|
|
39
|
+
* ## What this deliberately does NOT do
|
|
40
|
+
*
|
|
41
|
+
* It changes NO evaluation behaviour — it reads the schema after the memo has
|
|
42
|
+
* run and reports. Widening the set of evaluated keys is objectui#4795's
|
|
43
|
+
* Direction 1, which the maintainer DEFERRED behind a named restart condition
|
|
44
|
+
* (ruling of 2026-08-17); this diagnostic is Direction 3 and must not
|
|
45
|
+
* pre-empt it. Merging `props` into the node (Direction 2) is permanently
|
|
46
|
+
* rejected.
|
|
47
|
+
*
|
|
48
|
+
* ## Depth: exactly the evaluator's own radius, and not one level more
|
|
49
|
+
*
|
|
50
|
+
* The scan is SHALLOW because evaluation is shallow. `ExpressionEvaluator`
|
|
51
|
+
* returns every non-string untouched, so a nested `aria: { label: '${…}' }`
|
|
52
|
+
* under either bag keeps its raw source today — deliberately, and pinned as
|
|
53
|
+
* such by objectui#4799's parity tests. A deeper scan would therefore report,
|
|
54
|
+
* as a defect, a shape the repo has explicitly decided not to change yet: the
|
|
55
|
+
* diagnostic would be louder than the contract it speaks for. Deepening
|
|
56
|
+
* evaluation is a separate decision, and this scan follows it the day it is
|
|
57
|
+
* taken, not before.
|
|
58
|
+
*/
|
|
59
|
+
/** What counts as an expression — the evaluator's own definition. */
|
|
60
|
+
const EXPRESSION_PATTERN = /\$\{([^}]+)\}/g;
|
|
61
|
+
/**
|
|
62
|
+
* Mirrors `ExpressionEvaluator.evaluate`: a `${` with no closing brace, or an
|
|
63
|
+
* empty `${}`, is not an expression to the evaluator (its interpolation regex
|
|
64
|
+
* requires at least one character), so it is not one here either. Matching the
|
|
65
|
+
* PRODUCER's definition is what keeps this from reporting strings the engine
|
|
66
|
+
* never intended to touch — prose and code samples that merely contain `${`.
|
|
67
|
+
*/
|
|
68
|
+
export function findExpressionSources(value) {
|
|
69
|
+
const found = [];
|
|
70
|
+
// `matchAll` needs the /g flag and a fresh lastIndex per call; the literal
|
|
71
|
+
// above is module-scope, so reset before use rather than allocating a regex
|
|
72
|
+
// per value (this runs per key, per node, per render, in dev).
|
|
73
|
+
EXPRESSION_PATTERN.lastIndex = 0;
|
|
74
|
+
let match = EXPRESSION_PATTERN.exec(value);
|
|
75
|
+
while (match !== null) {
|
|
76
|
+
found.push(match[0]);
|
|
77
|
+
match = EXPRESSION_PATTERN.exec(value);
|
|
78
|
+
}
|
|
79
|
+
return found;
|
|
80
|
+
}
|
|
81
|
+
function scanBag(bag, channel, into, skip) {
|
|
82
|
+
if (!bag || typeof bag !== 'object' || Array.isArray(bag))
|
|
83
|
+
return;
|
|
84
|
+
for (const [key, value] of Object.entries(bag)) {
|
|
85
|
+
if (typeof value !== 'string')
|
|
86
|
+
continue;
|
|
87
|
+
if (skip?.(key, value))
|
|
88
|
+
continue;
|
|
89
|
+
const expressions = findExpressionSources(value);
|
|
90
|
+
if (expressions.length > 0) {
|
|
91
|
+
into.push({ key, channel, value, expressions });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Collect every unevaluated `${…}` that is about to be handed to a renderer.
|
|
97
|
+
*
|
|
98
|
+
* Pure and side-effect free — the reporting half is {@link reportUnevaluatedExpressions}.
|
|
99
|
+
* Kept separate so the scan can be asserted directly: a diagnostic whose only
|
|
100
|
+
* test is "a spy was called" goes green the moment someone no-ops it.
|
|
101
|
+
*
|
|
102
|
+
* @param spread The post-strip top-level bag — exactly what `SchemaRenderer`
|
|
103
|
+
* spreads as React props, which is also what renderers read as `schema.<key>`.
|
|
104
|
+
* Schema METADATA (`visible` / `hidden` / `disabled` / … ) must already be
|
|
105
|
+
* removed by the caller: those keys carry raw predicate source BY DESIGN and
|
|
106
|
+
* are evaluated as conditions, never placed.
|
|
107
|
+
* @param properties The node's `properties` bag, if any. Reported in
|
|
108
|
+
* preference to the top-level copy the hoist makes of it, so the finding
|
|
109
|
+
* names the key the author actually wrote.
|
|
110
|
+
* @param props The node's legacy `props` bag, if any.
|
|
111
|
+
*/
|
|
112
|
+
export function collectUnevaluatedExpressions(spread, properties, props) {
|
|
113
|
+
const findings = [];
|
|
114
|
+
scanBag(properties, 'properties', findings);
|
|
115
|
+
// The hoist copies every `properties.*` value onto the node's top level, so
|
|
116
|
+
// the same residue is visible twice. Report the authored spelling only.
|
|
117
|
+
const hoisted = properties && typeof properties === 'object' && !Array.isArray(properties)
|
|
118
|
+
? properties
|
|
119
|
+
: undefined;
|
|
120
|
+
scanBag(spread, 'schema', findings, (key, value) => hoisted?.[key] === value);
|
|
121
|
+
scanBag(props, 'props', findings);
|
|
122
|
+
return findings;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Prefix every diagnostic line starts with. Exported so tests can match on it
|
|
126
|
+
* and so an app can filter it out of its console transport if it must.
|
|
127
|
+
*/
|
|
128
|
+
export const UNEVALUATED_EXPRESSION_PREFIX = '[ObjectUI] Unevaluated expression reached the DOM';
|
|
129
|
+
/** Where a finding sits, spelled the way an author would search for it. */
|
|
130
|
+
function locate(finding) {
|
|
131
|
+
switch (finding.channel) {
|
|
132
|
+
case 'properties':
|
|
133
|
+
return 'properties.' + finding.key;
|
|
134
|
+
case 'props':
|
|
135
|
+
return 'props.' + finding.key;
|
|
136
|
+
default:
|
|
137
|
+
return finding.key;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Build the message. Separate from the emit so a test can assert the words a
|
|
142
|
+
* developer is going to read, not merely that something was logged.
|
|
143
|
+
*/
|
|
144
|
+
export function formatUnevaluatedExpressionMessage(type, id, findings) {
|
|
145
|
+
const node = typeof type === 'string' && type ? '"' + type + '"' : '(untyped node)';
|
|
146
|
+
const where = typeof id === 'string' && id ? ' (id: "' + id + '")' : '';
|
|
147
|
+
const lines = findings.map(f => {
|
|
148
|
+
const site = locate(f);
|
|
149
|
+
return (' - ' + site + ': ' + JSON.stringify(f.value) +
|
|
150
|
+
' [' + f.expressions.join(', ') + ']');
|
|
151
|
+
});
|
|
152
|
+
return (UNEVALUATED_EXPRESSION_PREFIX + ' - node ' + node + where + '\n' +
|
|
153
|
+
lines.join('\n') + '\n' +
|
|
154
|
+
'The value above is placed VERBATIM: it is handed to the renderer, and spread\n' +
|
|
155
|
+
'to the DOM, with its source text intact. Either SchemaRenderer does not\n' +
|
|
156
|
+
'evaluate this key, or the expression threw and the evaluator returned the\n' +
|
|
157
|
+
'source unchanged.\n' +
|
|
158
|
+
'Channels that do evaluate and read back today: `content`, or resolve the\n' +
|
|
159
|
+
'value in the host before handing the schema to SchemaRenderer.');
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Reported nodes, so a re-render (or the post-mount forceUpdate that picks up
|
|
163
|
+
* lazy plugin registrations) does not repeat the message. Keyed on the ORIGINAL
|
|
164
|
+
* schema object, matching `validateSchemaOnce`'s dedup: the evaluated copy is a
|
|
165
|
+
* fresh object on every memo recompute and would dedup nothing.
|
|
166
|
+
*/
|
|
167
|
+
const _reportedNodes = typeof WeakSet !== 'undefined'
|
|
168
|
+
? new WeakSet()
|
|
169
|
+
: { add() { }, has() { return false; } };
|
|
170
|
+
/**
|
|
171
|
+
* Dev-build only. Scans, and on a hit reports once per schema object via
|
|
172
|
+
* `console.error`.
|
|
173
|
+
*
|
|
174
|
+
* `console.error` and not `warn`: this is the loud refusal objectui#4795's
|
|
175
|
+
* ruling asked for, and it follows the assertion shape objectui#5092 landed in
|
|
176
|
+
* SchemaForm. (It is also the level that survives happy-dom, which swallows
|
|
177
|
+
* `console.log`.)
|
|
178
|
+
*
|
|
179
|
+
* The caller applies the production gate, so this stays a single dev-only
|
|
180
|
+
* branch at the call site and the whole module is dead code in a production
|
|
181
|
+
* build.
|
|
182
|
+
*/
|
|
183
|
+
export function reportUnevaluatedExpressions(schema, type, id, spread, properties, props) {
|
|
184
|
+
const findings = collectUnevaluatedExpressions(spread, properties, props);
|
|
185
|
+
if (findings.length === 0)
|
|
186
|
+
return findings;
|
|
187
|
+
if (schema && typeof schema === 'object') {
|
|
188
|
+
if (_reportedNodes.has(schema))
|
|
189
|
+
return findings;
|
|
190
|
+
_reportedNodes.add(schema);
|
|
191
|
+
}
|
|
192
|
+
console.error(formatUnevaluatedExpressionMessage(type, id, findings));
|
|
193
|
+
return findings;
|
|
194
|
+
}
|