@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
|
@@ -63,6 +63,13 @@ export declare function createLocalStorageAdapter(prefix?: string): SchemaPersis
|
|
|
63
63
|
* Accepts a pluggable adapter for connecting to any backend.
|
|
64
64
|
* Falls back to a localStorage adapter for development use.
|
|
65
65
|
*
|
|
66
|
+
* `save()` refuses a schema carrying function-valued keys anywhere in it —
|
|
67
|
+
* every adapter ends in a `JSON.stringify`, which drops callables silently, so
|
|
68
|
+
* the save would report success and the reload would hand back a schema that
|
|
69
|
+
* lost them. The refusal sets `error` (naming the offending key paths and both
|
|
70
|
+
* escapes), returns `null`, leaves `lastSavedAt` untouched, and never reaches
|
|
71
|
+
* the adapter. Fully declarative schemas are unaffected.
|
|
72
|
+
*
|
|
66
73
|
* @example
|
|
67
74
|
* ```tsx
|
|
68
75
|
* // With default localStorage adapter
|
|
@@ -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
|
-
import { useState, useCallback, useRef, useMemo } from 'react';
|
|
8
|
+
import { useState, useCallback, useRef, useMemo, useInsertionEffect } from 'react';
|
|
9
9
|
/**
|
|
10
10
|
* Default localStorage adapter for schema persistence.
|
|
11
11
|
* Useful for development and demos.
|
|
@@ -51,6 +51,96 @@ export function createLocalStorageAdapter(prefix = 'objectui-schema') {
|
|
|
51
51
|
},
|
|
52
52
|
};
|
|
53
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Cap on how many offending paths the refusal message spells out in full. A
|
|
56
|
+
* schema that puts callables on every row would otherwise produce an error
|
|
57
|
+
* string nobody can read; the count is always reported exactly.
|
|
58
|
+
*/
|
|
59
|
+
const MAX_REPORTED_CALLABLE_PATHS = 20;
|
|
60
|
+
/**
|
|
61
|
+
* Collect the path of every function-valued property reachable in `value`.
|
|
62
|
+
*
|
|
63
|
+
* Walks own enumerable properties — the same set `JSON.stringify` serializes —
|
|
64
|
+
* so what this finds is exactly what `JSON.stringify` would fail to preserve.
|
|
65
|
+
* It fails in TWO different ways, and neither says a word. Both are visible in
|
|
66
|
+
* a single document:
|
|
67
|
+
*
|
|
68
|
+
* JSON.stringify({ columns: [{ name: 'c', cell: fn }, fn] })
|
|
69
|
+
* // => {"columns":[{"name":"c"},null]}
|
|
70
|
+
* // the object key `cell` is DROPPED; the array element is COERCED to null
|
|
71
|
+
*
|
|
72
|
+
* The array case is arguably the worse of the two. A dropped key at least
|
|
73
|
+
* disappears, so a reader of the stored document can see something is missing;
|
|
74
|
+
* a coerced `null` survives as a plausible-looking value and reads as real data
|
|
75
|
+
* on reload. That is why arrays are walked by index (`columns[2].cell`) rather
|
|
76
|
+
* than skipped — and it is the realistic shape besides, since handlers live on
|
|
77
|
+
* column/field entries, not only at the top level.
|
|
78
|
+
*
|
|
79
|
+
* A `seen` set keeps a cyclic schema from recursing forever (`JSON.stringify`
|
|
80
|
+
* throws on those; this guard runs first and must not hang before it can). It
|
|
81
|
+
* also means a subtree reachable by two paths is reported at the first one
|
|
82
|
+
* only — the refusal is still correct, the path list is just not exhaustive for
|
|
83
|
+
* shared references.
|
|
84
|
+
*/
|
|
85
|
+
function collectCallablePaths(value, path, out, seen) {
|
|
86
|
+
if (typeof value === 'function') {
|
|
87
|
+
out.push(path || '(root)');
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (value === null || typeof value !== 'object')
|
|
91
|
+
return;
|
|
92
|
+
if (seen.has(value))
|
|
93
|
+
return;
|
|
94
|
+
seen.add(value);
|
|
95
|
+
if (Array.isArray(value)) {
|
|
96
|
+
value.forEach((item, index) => collectCallablePaths(item, `${path}[${index}]`, out, seen));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
for (const [key, child] of Object.entries(value)) {
|
|
100
|
+
collectCallablePaths(child, path ? `${path}.${key}` : key, out, seen);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The paths of the function-valued keys anywhere in a schema, in walk order.
|
|
105
|
+
* Empty means the schema is fully declarative and serializes without loss.
|
|
106
|
+
*
|
|
107
|
+
* Two corners worth knowing, both measured, neither a reason to loosen this:
|
|
108
|
+
*
|
|
109
|
+
* - `Object.entries` INVOKES getters. A schema with a throwing getter therefore
|
|
110
|
+
* throws inside this guard rather than inside the adapter's `JSON.stringify`.
|
|
111
|
+
* Different call site, same outcome: `save()` refuses and surfaces the
|
|
112
|
+
* getter's own error.
|
|
113
|
+
* - A schema carrying its own `toJSON` method is refused, even though
|
|
114
|
+
* `JSON.stringify` would have serialized it THROUGH that method. That is a
|
|
115
|
+
* deliberate false positive: the method itself cannot survive the round trip,
|
|
116
|
+
* so what `load()` hands back would not be the object that was saved.
|
|
117
|
+
*/
|
|
118
|
+
function findCallablePaths(schema) {
|
|
119
|
+
const out = [];
|
|
120
|
+
collectCallablePaths(schema, '', out, new Set());
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The refusal an author sees instead of a save that quietly lost their handler.
|
|
125
|
+
*
|
|
126
|
+
* It names the exact offending paths and both escapes, because an error that
|
|
127
|
+
* only says "no" leaves the author guessing at a defect whose symptom would
|
|
128
|
+
* otherwise have surfaced at render or click time, in another component, with
|
|
129
|
+
* no link back to the save that caused it.
|
|
130
|
+
*/
|
|
131
|
+
function callableRefusalMessage(id, paths) {
|
|
132
|
+
const shown = paths.slice(0, MAX_REPORTED_CALLABLE_PATHS);
|
|
133
|
+
const listed = shown.join(', ');
|
|
134
|
+
const elided = paths.length > shown.length ? ` (and ${paths.length - shown.length} more)` : '';
|
|
135
|
+
const plural = paths.length === 1 ? 'key is' : 'keys are';
|
|
136
|
+
return (`useSchemaPersistence: refusing to save schema "${id}" — ` +
|
|
137
|
+
`${paths.length} function-valued ${plural} not serializable: JSON.stringify ` +
|
|
138
|
+
`silently drops an object key, or coerces an array element to null. ` +
|
|
139
|
+
`Offending paths: ${listed}${elided}. ` +
|
|
140
|
+
'Two ways forward: strip the callables before saving (persist only the serializable ' +
|
|
141
|
+
'data and re-attach the handlers after load), or express the behaviour in the ' +
|
|
142
|
+
'declarative form of these keys so it survives the round trip.');
|
|
143
|
+
}
|
|
54
144
|
/**
|
|
55
145
|
* Hook for persisting designer schemas (save/load/list/delete).
|
|
56
146
|
* Implements schema persistence for @object-ui/plugin-designer.
|
|
@@ -58,6 +148,13 @@ export function createLocalStorageAdapter(prefix = 'objectui-schema') {
|
|
|
58
148
|
* Accepts a pluggable adapter for connecting to any backend.
|
|
59
149
|
* Falls back to a localStorage adapter for development use.
|
|
60
150
|
*
|
|
151
|
+
* `save()` refuses a schema carrying function-valued keys anywhere in it —
|
|
152
|
+
* every adapter ends in a `JSON.stringify`, which drops callables silently, so
|
|
153
|
+
* the save would report success and the reload would hand back a schema that
|
|
154
|
+
* lost them. The refusal sets `error` (naming the offending key paths and both
|
|
155
|
+
* escapes), returns `null`, leaves `lastSavedAt` untouched, and never reaches
|
|
156
|
+
* the adapter. Fully declarative schemas are unaffected.
|
|
157
|
+
*
|
|
61
158
|
* @example
|
|
62
159
|
* ```tsx
|
|
63
160
|
* // With default localStorage adapter
|
|
@@ -80,10 +177,45 @@ export function createLocalStorageAdapter(prefix = 'objectui-schema') {
|
|
|
80
177
|
* ```
|
|
81
178
|
*/
|
|
82
179
|
export function useSchemaPersistence(adapter) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
//
|
|
86
|
-
|
|
180
|
+
// Built once per hook instance. `useRef(createLocalStorageAdapter())` ran the
|
|
181
|
+
// factory on EVERY render and discarded all but the first result; `useMemo`
|
|
182
|
+
// runs it once and — unlike a ref — needs no `.current` read during render.
|
|
183
|
+
// Re-creating it would be harmless either way: the adapter is a stateless
|
|
184
|
+
// facade over `localStorage`, so a fresh one behaves identically to the one
|
|
185
|
+
// it replaces, and nothing outside this hook ever sees its identity.
|
|
186
|
+
const defaultAdapter = useMemo(() => createLocalStorageAdapter(), []);
|
|
187
|
+
const resolvedAdapter = adapter ?? defaultAdapter;
|
|
188
|
+
// The four callbacks below are created once (`[]` deps) and read the adapter
|
|
189
|
+
// at CALL time, so the latest adapter must reach them without changing their
|
|
190
|
+
// identity. That is the whole job of this ref.
|
|
191
|
+
//
|
|
192
|
+
// The write lives in `useInsertionEffect` — not in the render body, where it
|
|
193
|
+
// used to be, and not in `useEffect`/`useLayoutEffect`:
|
|
194
|
+
//
|
|
195
|
+
// - Render body: a render React discards or replays (StrictMode, a
|
|
196
|
+
// Suspense retry, a concurrent interruption) still performed the write,
|
|
197
|
+
// so a save could be routed through an adapter from a render that never
|
|
198
|
+
// committed. That is what `react-hooks/refs` flags.
|
|
199
|
+
// - `useEffect`: runs after paint, so a call made from any layout effect in
|
|
200
|
+
// the same commit would still reach the PREVIOUS adapter.
|
|
201
|
+
// - `useLayoutEffect`: a child's layout effects run BEFORE its parent's, so
|
|
202
|
+
// a child calling `save()` from its own layout effect would still see the
|
|
203
|
+
// previous adapter.
|
|
204
|
+
//
|
|
205
|
+
// Insertion effects run in the mutation phase — before every layout effect in
|
|
206
|
+
// the tree, before paint, and before any event handler can fire — so every
|
|
207
|
+
// call site that may legally invoke `save`/`load`/`list`/`remove` observes
|
|
208
|
+
// exactly what the old render-body write gave it. The single window that did
|
|
209
|
+
// change is a read during the render phase itself, which no legal consumer
|
|
210
|
+
// has: these calls are side effects and are never allowed during render.
|
|
211
|
+
//
|
|
212
|
+
// `useSchemaPersistence.adapterTracking.test.tsx` pins both halves — that a
|
|
213
|
+
// changed `adapter` prop is tracked, and that it is already in place by the
|
|
214
|
+
// time a child's layout effect runs in that same commit.
|
|
215
|
+
const adapterRef = useRef(resolvedAdapter);
|
|
216
|
+
useInsertionEffect(() => {
|
|
217
|
+
adapterRef.current = resolvedAdapter;
|
|
218
|
+
}, [resolvedAdapter]);
|
|
87
219
|
const [loading, setLoading] = useState(false);
|
|
88
220
|
const [error, setError] = useState(null);
|
|
89
221
|
const [isDirty, setIsDirty] = useState(false);
|
|
@@ -92,6 +224,13 @@ export function useSchemaPersistence(adapter) {
|
|
|
92
224
|
setLoading(true);
|
|
93
225
|
setError(null);
|
|
94
226
|
try {
|
|
227
|
+
// Refuse BEFORE the adapter runs, so the guard covers a host-injected or
|
|
228
|
+
// REST adapter too — every one of them ends in a `JSON.stringify` that
|
|
229
|
+
// drops callables without a word (objectui#6658).
|
|
230
|
+
const callablePaths = findCallablePaths(schema);
|
|
231
|
+
if (callablePaths.length > 0) {
|
|
232
|
+
throw new Error(callableRefusalMessage(id, callablePaths));
|
|
233
|
+
}
|
|
95
234
|
const result = await adapterRef.current.save(id, schema);
|
|
96
235
|
setIsDirty(false);
|
|
97
236
|
setLastSavedAt(new Date());
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
import type { DataSource } from '@object-ui/types';
|
|
9
|
+
/**
|
|
10
|
+
* The settled result of `{@link useSettledSchema}`: whether the object
|
|
11
|
+
* definition for the CURRENT key has finished resolving, and the definition
|
|
12
|
+
* itself once it has.
|
|
13
|
+
*
|
|
14
|
+
* `ready` is never a fact about "a fetch completed" in isolation — it is
|
|
15
|
+
* "a fetch completed FOR THE OBJECT THIS RENDER IS ASKING ABOUT". See the
|
|
16
|
+
* hook's own doc comment for why that distinction is the entire point.
|
|
17
|
+
*/
|
|
18
|
+
export interface SettledSchema<TDef = unknown> {
|
|
19
|
+
/**
|
|
20
|
+
* `true` once the object definition for the key passed to this render has
|
|
21
|
+
* settled — successfully, with a thrown read, or because there was no
|
|
22
|
+
* source to read from. `false` while it is still in flight, and also
|
|
23
|
+
* `false` for exactly one render right after `key` changes, even if a
|
|
24
|
+
* PREVIOUS key's resolution is sitting in state (objectui#6481's defect,
|
|
25
|
+
* made unwritable — see the hook doc comment).
|
|
26
|
+
*/
|
|
27
|
+
ready: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* The resolved definition once `ready` is `true`, otherwise `null`.
|
|
30
|
+
* `null` while `ready` is `true` is a legitimate, DISTINCT outcome from
|
|
31
|
+
* "not ready yet" — it means the resolution settled with nothing (no
|
|
32
|
+
* `dataSource`, no `getObjectSchema`, no key, or a read that threw), not
|
|
33
|
+
* that the read never happened.
|
|
34
|
+
*/
|
|
35
|
+
def: TDef | null;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Resolves an object's schema/definition and tracks whether that resolution
|
|
39
|
+
* has SETTLED for the object CURRENTLY being asked about — one piece of
|
|
40
|
+
* state a caller cannot observe half of.
|
|
41
|
+
*
|
|
42
|
+
* ## The shape, and why it is exactly this one
|
|
43
|
+
*
|
|
44
|
+
* This is the RESOLUTION half of the settled-schema gate four views
|
|
45
|
+
* (`ObjectKanban`, `ObjectView`, `ObjectCalendar`, and — pre-fix —
|
|
46
|
+
* `ObjectTree`) each hand-wrote (objectui#6271, #6419, #6453, #6014). Ruled
|
|
47
|
+
* objectui#6482 (maintainer, 2026-08-27, Option A): extract the resolution
|
|
48
|
+
* half as a shared, published hook; leave GATE PLACEMENT — deciding which
|
|
49
|
+
* effect branch actually waits on `ready` — to each component, because that
|
|
50
|
+
* part is genuinely component-private (`ObjectCalendar` gates only its
|
|
51
|
+
* `object`-provider branch and keys on the object the block RESOLVES rather
|
|
52
|
+
* than on `schema.objectName`, because an inline `value` data set issues no
|
|
53
|
+
* metadata read at all — a whole-effect gate would hold its query open on a
|
|
54
|
+
* resolution nothing was ever going to produce).
|
|
55
|
+
*
|
|
56
|
+
* `ready` is DERIVED at render time — `resolution !== null && resolution.key
|
|
57
|
+
* === key` — from a SINGLE piece of state, `{ key, def } | null`, rather than
|
|
58
|
+
* stored as a second, independent boolean. That is not a style choice; it is
|
|
59
|
+
* the fix for the defect this card exists to close. objectui#6481's
|
|
60
|
+
* `ObjectTree` carried the definition (`objectSchema`) and "has it settled"
|
|
61
|
+
* (`schemaSettled`) as two SEPARATE `useState`s. `schemaSettled` was a
|
|
62
|
+
* one-way latch — set `true` on first settle and never reset — so when the
|
|
63
|
+
* host swapped `objectName` mid-life, the fetch effect re-ran and started
|
|
64
|
+
* refetching the NEW object's schema, but `schemaSettled` stayed `true` from
|
|
65
|
+
* the OLD object's settle. The gated effect read `schemaSettled === true`
|
|
66
|
+
* and `objectSchema` still holding the OLD object's fields, and queried with
|
|
67
|
+
* the WRONG `$expand` — a stale key reading as ready.
|
|
68
|
+
*
|
|
69
|
+
* With one state value and a render-time key comparison, that failure mode
|
|
70
|
+
* is not merely fixed, it is UNREPRESENTABLE: the instant `key` changes,
|
|
71
|
+
* `resolution.key === key` is false in that very render (no effect needs to
|
|
72
|
+
* run first), so `ready` flips to `false` in the same commit the key
|
|
73
|
+
* changed — there is no window, and no second piece of state a caller could
|
|
74
|
+
* read out of sync with the first, because there is no second piece of
|
|
75
|
+
* state. A caller cannot spell "ready for the wrong object": `ready` and
|
|
76
|
+
* `def` are two views of one value, never independently settable.
|
|
77
|
+
*
|
|
78
|
+
* Every exit of the internal fetch effect settles the resolution — success,
|
|
79
|
+
* a thrown read, and "there is no source to read from" alike — because a
|
|
80
|
+
* caller's gated effect WAITS on `ready`. An exit that returned without
|
|
81
|
+
* settling would not merely skip the expansion; it would hold that gated
|
|
82
|
+
* query open forever.
|
|
83
|
+
*
|
|
84
|
+
* ## What this hook does NOT do
|
|
85
|
+
*
|
|
86
|
+
* It does not decide when to fetch beyond "when `key` or `dataSource`
|
|
87
|
+
* change", and it does not decide what a caller's OTHER effects should wait
|
|
88
|
+
* on. A caller that should not fetch at all for the current render (e.g.
|
|
89
|
+
* `ObjectCalendar`'s inline `value` provider, which issues no metadata read)
|
|
90
|
+
* passes `dataSource: undefined` for that render rather than a new "should
|
|
91
|
+
* fetch" flag — the hook already settles-with-`null` whenever `dataSource`
|
|
92
|
+
* is absent, which is the exact "no source to read from" outcome that case
|
|
93
|
+
* needs.
|
|
94
|
+
*
|
|
95
|
+
* @param key - The identity of the object THIS render is asking about — for a
|
|
96
|
+
* record-bound view, `resolveRecordSourceObjectName(schema, dataConfig) ?? ''`
|
|
97
|
+
* from `@object-ui/core`. ⛔ Do NOT re-spell that ladder inline here: six view
|
|
98
|
+
* plugins each carried their own copy and had drifted, which is the whole of
|
|
99
|
+
* what `b041b9c0c` collapsed. Choosing the key is still the caller's job — it is the
|
|
100
|
+
* component-private half of the original hand copies, not something this hook
|
|
101
|
+
* can infer — but the ladder behind it is published and shared.
|
|
102
|
+
* @param dataSource - The data source to read the definition from. Pass
|
|
103
|
+
* `undefined`/`null` for a render that should settle immediately with no
|
|
104
|
+
* definition (no source, or a provider that needs none) rather than adding
|
|
105
|
+
* a separate enable flag.
|
|
106
|
+
* @returns `{ ready, def }` — see {@link SettledSchema}.
|
|
107
|
+
*
|
|
108
|
+
* @example
|
|
109
|
+
* ```tsx
|
|
110
|
+
* const schemaKey = resolveRecordSourceObjectName(schema, dataConfig); // @object-ui/core
|
|
111
|
+
* const { ready: objectSchemaReady, def: objectSchema } =
|
|
112
|
+
* useSettledSchema(schemaKey ?? '', hasInlineData ? undefined : dataSource);
|
|
113
|
+
*
|
|
114
|
+
* useEffect(() => {
|
|
115
|
+
* // A PROVIDER test, not an object-name read — it asks "is there metadata to
|
|
116
|
+
* // wait for at all", which the shared reader deliberately does not answer.
|
|
117
|
+
* // Gate placement stays local (objectui#6482); the ladder does not.
|
|
118
|
+
* if (dataConfig?.provider === 'object' && !objectSchemaReady) return;
|
|
119
|
+
* // ...issue the record query, `buildExpandFields(objectSchema?.fields)`
|
|
120
|
+
* }, [objectSchemaReady, objectSchema, /* ... *\/]);
|
|
121
|
+
* ```
|
|
122
|
+
*/
|
|
123
|
+
export declare function useSettledSchema<TDef = unknown>(key: string, dataSource: DataSource<any> | null | undefined): SettledSchema<TDef>;
|
|
@@ -0,0 +1,186 @@
|
|
|
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 { useEffect, useState } from 'react';
|
|
9
|
+
/**
|
|
10
|
+
* Is this refetch's answer the SAME answer, by value, as the one already
|
|
11
|
+
* published?
|
|
12
|
+
*
|
|
13
|
+
* Used for exactly one decision — whether a settle republishes or keeps the
|
|
14
|
+
* object consumers already hold — and its failure direction is chosen for
|
|
15
|
+
* that decision. A false NEGATIVE (two encodings of the same metadata that
|
|
16
|
+
* serialise differently, e.g. with the keys in another order) republishes,
|
|
17
|
+
* which is precisely what this hook did for every answer before
|
|
18
|
+
* objectui#10106: no consumer can be worse off than it already was. A false
|
|
19
|
+
* POSITIVE would withhold a genuinely new definition from consumers, so the
|
|
20
|
+
* comparison is deliberately a whole-payload one and never a sampled or
|
|
21
|
+
* shallow test.
|
|
22
|
+
*
|
|
23
|
+
* ⚠️ An answer JSON cannot express — a cycle, a `BigInt` — makes
|
|
24
|
+
* `JSON.stringify` throw; that is answered as "not equal" rather than allowed
|
|
25
|
+
* to escape into a state updater, which lands on the safe side above.
|
|
26
|
+
*/
|
|
27
|
+
function isEqualPayload(a, b) {
|
|
28
|
+
if (Object.is(a, b))
|
|
29
|
+
return true;
|
|
30
|
+
try {
|
|
31
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Resolves an object's schema/definition and tracks whether that resolution
|
|
39
|
+
* has SETTLED for the object CURRENTLY being asked about — one piece of
|
|
40
|
+
* state a caller cannot observe half of.
|
|
41
|
+
*
|
|
42
|
+
* ## The shape, and why it is exactly this one
|
|
43
|
+
*
|
|
44
|
+
* This is the RESOLUTION half of the settled-schema gate four views
|
|
45
|
+
* (`ObjectKanban`, `ObjectView`, `ObjectCalendar`, and — pre-fix —
|
|
46
|
+
* `ObjectTree`) each hand-wrote (objectui#6271, #6419, #6453, #6014). Ruled
|
|
47
|
+
* objectui#6482 (maintainer, 2026-08-27, Option A): extract the resolution
|
|
48
|
+
* half as a shared, published hook; leave GATE PLACEMENT — deciding which
|
|
49
|
+
* effect branch actually waits on `ready` — to each component, because that
|
|
50
|
+
* part is genuinely component-private (`ObjectCalendar` gates only its
|
|
51
|
+
* `object`-provider branch and keys on the object the block RESOLVES rather
|
|
52
|
+
* than on `schema.objectName`, because an inline `value` data set issues no
|
|
53
|
+
* metadata read at all — a whole-effect gate would hold its query open on a
|
|
54
|
+
* resolution nothing was ever going to produce).
|
|
55
|
+
*
|
|
56
|
+
* `ready` is DERIVED at render time — `resolution !== null && resolution.key
|
|
57
|
+
* === key` — from a SINGLE piece of state, `{ key, def } | null`, rather than
|
|
58
|
+
* stored as a second, independent boolean. That is not a style choice; it is
|
|
59
|
+
* the fix for the defect this card exists to close. objectui#6481's
|
|
60
|
+
* `ObjectTree` carried the definition (`objectSchema`) and "has it settled"
|
|
61
|
+
* (`schemaSettled`) as two SEPARATE `useState`s. `schemaSettled` was a
|
|
62
|
+
* one-way latch — set `true` on first settle and never reset — so when the
|
|
63
|
+
* host swapped `objectName` mid-life, the fetch effect re-ran and started
|
|
64
|
+
* refetching the NEW object's schema, but `schemaSettled` stayed `true` from
|
|
65
|
+
* the OLD object's settle. The gated effect read `schemaSettled === true`
|
|
66
|
+
* and `objectSchema` still holding the OLD object's fields, and queried with
|
|
67
|
+
* the WRONG `$expand` — a stale key reading as ready.
|
|
68
|
+
*
|
|
69
|
+
* With one state value and a render-time key comparison, that failure mode
|
|
70
|
+
* is not merely fixed, it is UNREPRESENTABLE: the instant `key` changes,
|
|
71
|
+
* `resolution.key === key` is false in that very render (no effect needs to
|
|
72
|
+
* run first), so `ready` flips to `false` in the same commit the key
|
|
73
|
+
* changed — there is no window, and no second piece of state a caller could
|
|
74
|
+
* read out of sync with the first, because there is no second piece of
|
|
75
|
+
* state. A caller cannot spell "ready for the wrong object": `ready` and
|
|
76
|
+
* `def` are two views of one value, never independently settable.
|
|
77
|
+
*
|
|
78
|
+
* Every exit of the internal fetch effect settles the resolution — success,
|
|
79
|
+
* a thrown read, and "there is no source to read from" alike — because a
|
|
80
|
+
* caller's gated effect WAITS on `ready`. An exit that returned without
|
|
81
|
+
* settling would not merely skip the expansion; it would hold that gated
|
|
82
|
+
* query open forever.
|
|
83
|
+
*
|
|
84
|
+
* ## What this hook does NOT do
|
|
85
|
+
*
|
|
86
|
+
* It does not decide when to fetch beyond "when `key` or `dataSource`
|
|
87
|
+
* change", and it does not decide what a caller's OTHER effects should wait
|
|
88
|
+
* on. A caller that should not fetch at all for the current render (e.g.
|
|
89
|
+
* `ObjectCalendar`'s inline `value` provider, which issues no metadata read)
|
|
90
|
+
* passes `dataSource: undefined` for that render rather than a new "should
|
|
91
|
+
* fetch" flag — the hook already settles-with-`null` whenever `dataSource`
|
|
92
|
+
* is absent, which is the exact "no source to read from" outcome that case
|
|
93
|
+
* needs.
|
|
94
|
+
*
|
|
95
|
+
* @param key - The identity of the object THIS render is asking about — for a
|
|
96
|
+
* record-bound view, `resolveRecordSourceObjectName(schema, dataConfig) ?? ''`
|
|
97
|
+
* from `@object-ui/core`. ⛔ Do NOT re-spell that ladder inline here: six view
|
|
98
|
+
* plugins each carried their own copy and had drifted, which is the whole of
|
|
99
|
+
* what `b041b9c0c` collapsed. Choosing the key is still the caller's job — it is the
|
|
100
|
+
* component-private half of the original hand copies, not something this hook
|
|
101
|
+
* can infer — but the ladder behind it is published and shared.
|
|
102
|
+
* @param dataSource - The data source to read the definition from. Pass
|
|
103
|
+
* `undefined`/`null` for a render that should settle immediately with no
|
|
104
|
+
* definition (no source, or a provider that needs none) rather than adding
|
|
105
|
+
* a separate enable flag.
|
|
106
|
+
* @returns `{ ready, def }` — see {@link SettledSchema}.
|
|
107
|
+
*
|
|
108
|
+
* @example
|
|
109
|
+
* ```tsx
|
|
110
|
+
* const schemaKey = resolveRecordSourceObjectName(schema, dataConfig); // @object-ui/core
|
|
111
|
+
* const { ready: objectSchemaReady, def: objectSchema } =
|
|
112
|
+
* useSettledSchema(schemaKey ?? '', hasInlineData ? undefined : dataSource);
|
|
113
|
+
*
|
|
114
|
+
* useEffect(() => {
|
|
115
|
+
* // A PROVIDER test, not an object-name read — it asks "is there metadata to
|
|
116
|
+
* // wait for at all", which the shared reader deliberately does not answer.
|
|
117
|
+
* // Gate placement stays local (objectui#6482); the ladder does not.
|
|
118
|
+
* if (dataConfig?.provider === 'object' && !objectSchemaReady) return;
|
|
119
|
+
* // ...issue the record query, `buildExpandFields(objectSchema?.fields)`
|
|
120
|
+
* }, [objectSchemaReady, objectSchema, /* ... *\/]);
|
|
121
|
+
* ```
|
|
122
|
+
*/
|
|
123
|
+
export function useSettledSchema(key, dataSource) {
|
|
124
|
+
const [resolution, setResolution] = useState(null);
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
let isMounted = true;
|
|
127
|
+
const settleKey = key;
|
|
128
|
+
/**
|
|
129
|
+
* Settle THIS effect run's resolution — and, when the answer is one
|
|
130
|
+
* consumers already hold, settle it as the object they already hold.
|
|
131
|
+
*
|
|
132
|
+
* This is the PRODUCER half of `AGENTS.md` §5 commandment #10: *"a
|
|
133
|
+
* provider that refetches may not republish an equal payload as a new
|
|
134
|
+
* object, and a consumer that needs stability keys on the data, not on a
|
|
135
|
+
* cache"*. This hook refetches whenever the adapter identity changes, and
|
|
136
|
+
* the consumers its own `@example` instructs do exactly what the other
|
|
137
|
+
* half of that sentence asks — they key their record fetch on `def`, the
|
|
138
|
+
* payload, never on a memo identity. So a fresh object for an unchanged
|
|
139
|
+
* answer is not a wasted allocation; it is a DUPLICATE RECORD QUERY at
|
|
140
|
+
* every one of them, carrying the same expand set as the one before it.
|
|
141
|
+
* Pinned with its control in
|
|
142
|
+
* `useSettledSchema.equalPayload-10106.test.tsx` (objectui#10106).
|
|
143
|
+
*
|
|
144
|
+
* ⛔ The cure may NOT be moved to the consumer — the same sentence names
|
|
145
|
+
* keying on a cache as the wrong one, and a consumer that stopped keying
|
|
146
|
+
* on `def` would also stop re-querying when the definition genuinely
|
|
147
|
+
* changes, which the control case in that pin holds open.
|
|
148
|
+
*
|
|
149
|
+
* Bailing out is only ever safe for the SAME key: `resolution` is one
|
|
150
|
+
* piece of state whose `key` decides `ready`, so a settle for a different
|
|
151
|
+
* key must always publish (see the hook doc comment above).
|
|
152
|
+
*/
|
|
153
|
+
const settle = (settledDef) => {
|
|
154
|
+
setResolution((prev) => prev && prev.key === settleKey && isEqualPayload(prev.def, settledDef)
|
|
155
|
+
? prev
|
|
156
|
+
: { key: settleKey, def: settledDef });
|
|
157
|
+
};
|
|
158
|
+
const resolve = async () => {
|
|
159
|
+
if (!dataSource || !settleKey || typeof dataSource.getObjectSchema !== 'function') {
|
|
160
|
+
// No source for a definition: settle with none, so anything gated on
|
|
161
|
+
// `ready` still runs (unexpanded — with no schema there is no expand
|
|
162
|
+
// set to derive).
|
|
163
|
+
if (isMounted)
|
|
164
|
+
settle(null);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
const def = await dataSource.getObjectSchema(settleKey);
|
|
169
|
+
if (isMounted)
|
|
170
|
+
settle(def);
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
console.error('[useSettledSchema] getObjectSchema failed for', settleKey, err);
|
|
174
|
+
if (isMounted)
|
|
175
|
+
settle(null);
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
resolve();
|
|
179
|
+
return () => {
|
|
180
|
+
isMounted = false;
|
|
181
|
+
};
|
|
182
|
+
}, [key, dataSource]);
|
|
183
|
+
const ready = resolution !== null && resolution.key === key;
|
|
184
|
+
const def = ready ? resolution.def : null;
|
|
185
|
+
return { ready, def };
|
|
186
|
+
}
|
|
@@ -37,8 +37,13 @@ export interface UseViewDataResult<T = any> {
|
|
|
37
37
|
dataSource: DataSource<T> | null;
|
|
38
38
|
/** Re-fetch data with current or new params */
|
|
39
39
|
refresh: (newParams?: QueryParams) => Promise<void>;
|
|
40
|
-
/**
|
|
41
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Fetch a single record by ID. The id is a `string`, as every record door on
|
|
42
|
+
* `DataSource` declares it — this hook hands the value straight to
|
|
43
|
+
* `DataSource.findOne`, so the union it used to admit was a claim the
|
|
44
|
+
* protocol never made (objectui#9511).
|
|
45
|
+
*/
|
|
46
|
+
fetchOne: (id: string) => Promise<T | null>;
|
|
42
47
|
/** Whether more records are available */
|
|
43
48
|
hasMore: boolean;
|
|
44
49
|
}
|
|
@@ -50,8 +55,10 @@ export interface UseViewDataResult<T = any> {
|
|
|
50
55
|
*
|
|
51
56
|
* 1. Resolves the ViewData config to the right DataSource adapter
|
|
52
57
|
* - `provider: 'object'` → context DataSource (ObjectStackAdapter)
|
|
53
|
-
* - `provider: 'api'` → ApiDataSource (raw HTTP)
|
|
54
58
|
* - `provider: 'value'` → ValueDataSource (in-memory)
|
|
59
|
+
* The view-level `provider: 'api'` is retired in `@objectstack/spec`
|
|
60
|
+
* (objectstack#23082) and has no arm in `resolveDataSource`: a block that
|
|
61
|
+
* still names it is an unknown provider there (objectui#12325).
|
|
55
62
|
* 2. Fetches data automatically on mount
|
|
56
63
|
* 3. Provides loading/error states and refresh capability
|
|
57
64
|
*
|
|
@@ -22,8 +22,10 @@ import { SchemaRendererContext } from '../context/SchemaRendererContext.js';
|
|
|
22
22
|
*
|
|
23
23
|
* 1. Resolves the ViewData config to the right DataSource adapter
|
|
24
24
|
* - `provider: 'object'` → context DataSource (ObjectStackAdapter)
|
|
25
|
-
* - `provider: 'api'` → ApiDataSource (raw HTTP)
|
|
26
25
|
* - `provider: 'value'` → ValueDataSource (in-memory)
|
|
26
|
+
* The view-level `provider: 'api'` is retired in `@objectstack/spec`
|
|
27
|
+
* (objectstack#23082) and has no arm in `resolveDataSource`: a block that
|
|
28
|
+
* still names it is an unknown provider there (objectui#12325).
|
|
27
29
|
* 2. Fetches data automatically on mount
|
|
28
30
|
* 3. Provides loading/error states and refresh capability
|
|
29
31
|
*
|
|
@@ -44,30 +46,19 @@ import { SchemaRendererContext } from '../context/SchemaRendererContext.js';
|
|
|
44
46
|
*/
|
|
45
47
|
export function useViewData(options) {
|
|
46
48
|
const { viewData, resource = '', params, dataSource: explicitDataSource, adapterOptions, skip = false, } = options;
|
|
47
|
-
// Get context DataSource
|
|
49
|
+
// Get context DataSource
|
|
48
50
|
const context = useContext(SchemaRendererContext);
|
|
49
51
|
const contextDataSource = context?.dataSource ?? null;
|
|
50
|
-
const contextApiFetch = context?.apiFetch;
|
|
51
52
|
// Resolve the DataSource — memoize to prevent re-creation on every render.
|
|
52
53
|
// We key on the viewData provider + config identity.
|
|
53
54
|
const resolvedDataSource = useMemo(() => {
|
|
54
55
|
if (explicitDataSource)
|
|
55
56
|
return explicitDataSource;
|
|
56
|
-
|
|
57
|
-
// (SchemaRendererContext.apiFetch) so custom endpoints carry the same
|
|
58
|
-
// credentials as native requests; explicit adapterOptions still win.
|
|
59
|
-
return resolveDataSource(viewData, contextDataSource, {
|
|
60
|
-
fetch: contextApiFetch,
|
|
61
|
-
...adapterOptions,
|
|
62
|
-
});
|
|
57
|
+
return resolveDataSource(viewData, contextDataSource, adapterOptions);
|
|
63
58
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
64
59
|
}, [
|
|
65
60
|
explicitDataSource,
|
|
66
|
-
contextApiFetch,
|
|
67
61
|
viewData?.provider,
|
|
68
|
-
// For 'api' provider, key on read/write URLs
|
|
69
|
-
viewData?.provider === 'api' ? viewData.read?.url : undefined,
|
|
70
|
-
viewData?.provider === 'api' ? viewData.write?.url : undefined,
|
|
71
62
|
// For 'value' provider, key on items reference (or length as proxy)
|
|
72
63
|
viewData?.provider === 'value' ? viewData.items?.length : undefined,
|
|
73
64
|
contextDataSource,
|
package/dist/index.d.ts
CHANGED
|
@@ -10,10 +10,18 @@ export * from './schema-input.js';
|
|
|
10
10
|
export * from './hooks/index.js';
|
|
11
11
|
export * from './context/index.js';
|
|
12
12
|
export * from './LazyPluginLoader.js';
|
|
13
|
-
export * from './spec-bridge/index.js';
|
|
14
13
|
export * from './data-invalidation.js';
|
|
15
14
|
export * from './element-data-source/ElementDataSourceGate.js';
|
|
15
|
+
export * from './element-data-source/useElementApiDataSource.js';
|
|
16
16
|
export { resolveKeyedI18nLabel } from './utils/i18n.js';
|
|
17
|
+
export { resolveInlineAriaProps } from './utils/inlineAria.js';
|
|
18
|
+
export { isConfigBag } from './utils/configBag.js';
|
|
19
|
+
export { reportUnresolvableVisibilityPredicate, formatUnresolvableVisibilityMessage, UNRESOLVABLE_VISIBILITY_PREFIX, UNRESOLVABLE_ENABLEMENT_PREFIX, __resetVisibilityPredicateWarnings, } from './utils/visibilityDiagnostic.js';
|
|
20
|
+
export type { PredicateScopeTier } from './utils/visibilityDiagnostic.js';
|
|
21
|
+
export type { PredicateGateKind } from './utils/visibilityDiagnostic.js';
|
|
17
22
|
export { extractWriteErrorMessage, isPermissionError, extractFieldErrors, classifyLoadError, declaredUserMessage } from './utils/error-message.js';
|
|
18
23
|
export type { WriteFieldError, LoadErrorKind } from './utils/error-message.js';
|
|
19
24
|
export { I18nProvider, useObjectTranslation, useSafeTranslate, useObjectLabel, useSafeFieldLabel, useI18nContext, createI18n, getDirection, getAvailableLanguages, formatDate, formatDateTime, formatRelativeTime, formatCurrency, formatNumber, type I18nConfig, type I18nProviderProps, type TranslationKeys, type DateFormatOptions, type CurrencyFormatOptions, type NumberFormatOptions, } from '@object-ui/i18n';
|
|
25
|
+
export { NON_GRID_ROW_CEILING, applyNonGridRowCeiling } from '@object-ui/core';
|
|
26
|
+
export type { NonGridCeilingResult } from '@object-ui/core';
|
|
27
|
+
export { NonGridRowCeilingNote } from './utils/nonGridRowCeiling.js';
|