@object-ui/react 17.5.0 → 17.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4100 -0
- package/README.md +303 -46
- package/dist/LazyPluginLoader.d.ts +14 -7
- package/dist/LazyPluginLoader.js +14 -7
- package/dist/SchemaRenderer.d.ts +38 -7
- package/dist/SchemaRenderer.js +1603 -60
- package/dist/context/ActionContext.js +40 -1
- package/dist/context/DiscussionContext.d.ts +12 -0
- package/dist/context/DiscussionContext.js +2 -0
- package/dist/context/DrillNavigationContext.d.ts +13 -0
- package/dist/context/HostNavigationContext.d.ts +82 -0
- package/dist/context/HostNavigationContext.js +65 -0
- package/dist/context/InlineEditContext.d.ts +81 -1
- package/dist/context/InlineEditContext.js +8 -2
- package/dist/context/RecordContext.d.ts +48 -5
- package/dist/context/RecordContext.js +10 -1
- package/dist/context/RelatedRecordActionsContext.d.ts +20 -1
- package/dist/context/SchemaRendererContext.d.ts +54 -2
- package/dist/context/SchemaRendererContext.js +38 -5
- 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 +13 -12
- package/dist/context/index.js +13 -12
- package/dist/element-data-source/ElementDataSourceGate.d.ts +163 -3
- package/dist/element-data-source/ElementDataSourceGate.js +407 -15
- package/dist/hooks/index.d.ts +37 -35
- package/dist/hooks/index.js +44 -35
- package/dist/hooks/useActionEngine.js +47 -4
- package/dist/hooks/useActionRunner.js +4 -0
- package/dist/hooks/useActionTextLocalizer.d.ts +21 -8
- package/dist/hooks/useActionTextLocalizer.js +33 -8
- package/dist/hooks/useCapabilityGate.js +6 -5
- package/dist/hooks/useConfigBagEvaluator.d.ts +30 -0
- package/dist/hooks/useConfigBagEvaluator.js +39 -0
- package/dist/hooks/useCrudShortcuts.js +1 -1
- package/dist/hooks/useDatasetDimensionLabels.js +1 -1
- package/dist/hooks/useDensityMode.d.ts +6 -2
- package/dist/hooks/useDensityMode.js +6 -2
- package/dist/hooks/useDiscovery.d.ts +1 -10
- package/dist/hooks/useDiscovery.js +1 -1
- package/dist/hooks/useETagCache.js +25 -4
- package/dist/hooks/useElementDataSource.d.ts +15 -2
- package/dist/hooks/useElementDataSource.js +29 -9
- package/dist/hooks/useExpression.d.ts +52 -38
- package/dist/hooks/useExpression.js +62 -42
- 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 +77 -12
- package/dist/hooks/useNavigationOverlay.js +119 -9
- package/dist/hooks/useOffline.js +65 -11
- package/dist/hooks/usePageAssignment.d.ts +8 -5
- package/dist/hooks/usePageAssignment.js +38 -17
- package/dist/hooks/usePageTransition.js +1 -1
- package/dist/hooks/usePageVariableActionBridge.js +2 -2
- 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 +7 -2
- package/dist/hooks/useViewData.js +1 -1
- package/dist/hooks/useViewTransition.js +1 -1
- package/dist/index.d.ts +18 -11
- package/dist/index.js +54 -10
- package/dist/schema-input.d.ts +55 -14
- package/dist/schema-input.js +55 -12
- package/dist/utils/configBag.d.ts +68 -0
- package/dist/utils/configBag.js +70 -0
- package/dist/utils/error-message.d.ts +59 -0
- package/dist/utils/error-message.js +116 -1
- package/dist/utils/inlineAria.d.ts +61 -0
- package/dist/utils/inlineAria.js +70 -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 +421 -0
- package/dist/utils/refusedDataPropDiagnostic.d.ts +44 -0
- package/dist/utils/refusedDataPropDiagnostic.js +94 -0
- package/dist/utils/unevaluatedExpression.d.ts +110 -0
- package/dist/utils/unevaluatedExpression.js +257 -0
- package/dist/utils/visibilityDiagnostic.d.ts +371 -0
- package/dist/utils/visibilityDiagnostic.js +551 -0
- 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 -82
- package/dist/spec-bridge/bridges/form-view.js +0 -126
- package/dist/spec-bridge/bridges/list-view.d.ts +0 -20
- package/dist/spec-bridge/bridges/list-view.js +0 -241
- 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
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* LICENSE file in the root directory of this source tree.
|
|
7
7
|
*/
|
|
8
8
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
9
|
-
import { getRecordDisplayName as defaultDisplayName } from '@object-ui/core';
|
|
9
|
+
import { getRecordDisplayName as defaultDisplayName, withoutDeniedFields } from '@object-ui/core';
|
|
10
10
|
import { errorCodeIs } from '@object-ui/types';
|
|
11
11
|
// ADR-0079: the global-search default display-name resolver is now the ONE
|
|
12
12
|
// shared `getRecordDisplayName` from `@object-ui/core` (honors titleFormat →
|
|
@@ -74,7 +74,10 @@ function scoreHit(query, display, recordId) {
|
|
|
74
74
|
* `$filter` translation.
|
|
75
75
|
*/
|
|
76
76
|
export function useRecordSearch(opts) {
|
|
77
|
-
const { query, objects, dataSource, objectNames, maxObjectsQueried = 8, topPerObject = 3, minLength = 2, debounceMs = 250, enabled = true, getDisplayName = defaultDisplayName, } = opts;
|
|
77
|
+
const { query, objects, dataSource, objectNames, maxObjectsQueried = 8, topPerObject = 3, minLength = 2, debounceMs = 250, enabled = true, getDisplayName = defaultDisplayName, fieldReadPolicy, } = opts;
|
|
78
|
+
// The one member of the read policy that decides whether a run happens: a
|
|
79
|
+
// primitive, so an inline policy object never re-runs the search by identity.
|
|
80
|
+
const policyLoaded = fieldReadPolicy?.isLoaded === true;
|
|
78
81
|
const [results, setResults] = useState([]);
|
|
79
82
|
const [isSearching, setIsSearching] = useState(false);
|
|
80
83
|
const [error, setError] = useState(undefined);
|
|
@@ -111,11 +114,41 @@ export function useRecordSearch(opts) {
|
|
|
111
114
|
}
|
|
112
115
|
return pool;
|
|
113
116
|
}, [objects, objectNames]);
|
|
117
|
+
// The object NAME is the whole signature: it is the only field of an object
|
|
118
|
+
// definition this effect consumes (`dataSource.find(obj.name, …)` and the
|
|
119
|
+
// `objects` whitelist sent to `searchAll`); `label` / `icon` are read per hit
|
|
120
|
+
// at render time and never decide whether the fanout re-runs.
|
|
121
|
+
//
|
|
122
|
+
// An `o?.titleField ?? ''` half used to be appended to each entry. It could
|
|
123
|
+
// never vary: `@objectstack/spec`'s object schema is a `strictObject` that
|
|
124
|
+
// REJECTS `titleField` with `unrecognized_keys` (objectui#6531), so no legal
|
|
125
|
+
// object metadata carries it and the half was permanently `''` — a constant
|
|
126
|
+
// suffix in a cache key, which also read as evidence that some producer ships
|
|
127
|
+
// the key. Removed in objectui#6557; the display name comes from
|
|
128
|
+
// `getRecordDisplayName`, which does not consult it either.
|
|
114
129
|
const candidateSignature = useMemo(() => {
|
|
115
|
-
return candidates.map((o) =>
|
|
130
|
+
return candidates.map((o) => String(o?.name)).join('|');
|
|
116
131
|
}, [candidates]);
|
|
117
132
|
// Run ID for racing-request guarding. Stable across renders.
|
|
118
133
|
const runIdRef = useRef(0);
|
|
134
|
+
// `getDisplayName` is caller-supplied and read only INSIDE a run, to label
|
|
135
|
+
// hits — it never decides whether a run happens. So the search effect reads
|
|
136
|
+
// the latest resolver through this ref and does not key on its identity
|
|
137
|
+
// (AGENTS.md §5 #10, objectui#10044). Keyed on it, an inline resolver (a new
|
|
138
|
+
// function every render) re-ran the effect on each render, and the cleanup
|
|
139
|
+
// cleared the pending debounce timer: renders faster than `debounceMs` never
|
|
140
|
+
// searched, and sparser renders issued a second identical request. Declared
|
|
141
|
+
// before the search effect, so it is current before any timer is armed.
|
|
142
|
+
//
|
|
143
|
+
// `fieldReadPolicy` is read the same way (objectui#10500): the run reads the
|
|
144
|
+
// latest policy through a ref, and only its `isLoaded` primitive
|
|
145
|
+
// (`policyLoaded`) is a dependency of the search effect.
|
|
146
|
+
const getDisplayNameRef = useRef(getDisplayName);
|
|
147
|
+
const fieldReadPolicyRef = useRef(fieldReadPolicy);
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
getDisplayNameRef.current = getDisplayName;
|
|
150
|
+
fieldReadPolicyRef.current = fieldReadPolicy;
|
|
151
|
+
});
|
|
119
152
|
useEffect(() => {
|
|
120
153
|
if (!enabled || !dataSource || candidates.length === 0) {
|
|
121
154
|
setResults([]);
|
|
@@ -142,7 +175,7 @@ export function useRecordSearch(opts) {
|
|
|
142
175
|
// per-object `find({ $search })` fanout below only runs each object's
|
|
143
176
|
// metadata-driven search (ADR-0061) and can miss records the global
|
|
144
177
|
// search index knows about — the command-palette symptom in framework
|
|
145
|
-
// #3371, where `/api/v1/search` returned an account/opportunity the
|
|
178
|
+
// objectstack-ai/objectstack#3371, where `/api/v1/search` returned an account/opportunity the
|
|
146
179
|
// palette never showed. When `searchAll` is absent (mock/test adapters,
|
|
147
180
|
// non-ObjectStack backends) we fall back to the fanout.
|
|
148
181
|
const searchAll = dataSource.searchAll;
|
|
@@ -184,7 +217,9 @@ export function useRecordSearch(opts) {
|
|
|
184
217
|
continue;
|
|
185
218
|
const objDef = byName.get(objectName) ?? { name: objectName };
|
|
186
219
|
const title = typeof h?.title === 'string' && h.title.trim() !== '' ? h.title.trim() : '';
|
|
187
|
-
const display = title ||
|
|
220
|
+
const display = title ||
|
|
221
|
+
getDisplayNameRef.current(objDef, withoutDeniedFields(h?.record ?? {}, fieldReadPolicyRef.current, objectName)) ||
|
|
222
|
+
`Record #${recordId}`;
|
|
188
223
|
const snippet = typeof h?.snippet === 'string' ? h.snippet.trim() : '';
|
|
189
224
|
hits.push({
|
|
190
225
|
objectName,
|
|
@@ -256,7 +291,7 @@ export function useRecordSearch(opts) {
|
|
|
256
291
|
const recordId = record?.id ?? record?._id;
|
|
257
292
|
if (recordId == null)
|
|
258
293
|
continue;
|
|
259
|
-
const display =
|
|
294
|
+
const display = getDisplayNameRef.current(obj, withoutDeniedFields(record, fieldReadPolicyRef.current, obj.name));
|
|
260
295
|
hits.push({
|
|
261
296
|
objectName: obj.name,
|
|
262
297
|
objectLabel: typeof obj.label === 'string' && obj.label.length > 0
|
|
@@ -290,7 +325,7 @@ export function useRecordSearch(opts) {
|
|
|
290
325
|
maxObjectsQueried,
|
|
291
326
|
minLength,
|
|
292
327
|
debounceMs,
|
|
293
|
-
|
|
328
|
+
policyLoaded,
|
|
294
329
|
]);
|
|
295
330
|
return { results, isSearching, error };
|
|
296
331
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
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 FilterTokenScope } from '@object-ui/core';
|
|
9
|
+
/**
|
|
10
|
+
* Resolve a data node's own authored `filter` ONCE, and hold the result.
|
|
11
|
+
*
|
|
12
|
+
* Every context token the spec defines for filters (`{current_user_id}`,
|
|
13
|
+
* `{current_org_id}`, `{record_id}` where a record is in view, and the date
|
|
14
|
+
* macros such as `{today}`) is expanded by `@object-ui/core`'s shared
|
|
15
|
+
* `resolveFilterPlaceholders`, against the scope `useFilterScope()` reads: the
|
|
16
|
+
* session scope the host provides plus the mounted record's id
|
|
17
|
+
* (objectui#7297). Pass `useFilterScope()` as `scope`:
|
|
18
|
+
*
|
|
19
|
+
* ```tsx
|
|
20
|
+
* const filterScope = useFilterScope();
|
|
21
|
+
* const filter = useResolvedFilter(schema.filter, filterScope);
|
|
22
|
+
* // query with `filter`, and key the fetch effect on `filter`, never on schema.filter
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* ⛔ Not a second resolver, and no fallback: a token the scope cannot resolve
|
|
26
|
+
* is whatever `resolveFilterPlaceholders` makes of it (left intact, with one
|
|
27
|
+
* warning naming it).
|
|
28
|
+
*
|
|
29
|
+
* The result is HELD against its inputs, the shape `plugin-view`'s
|
|
30
|
+
* `useResolvedFilterSegments` uses. A fetch effect keys on this filter, so a
|
|
31
|
+
* resolved copy minted on every render would refetch on every render; and a
|
|
32
|
+
* date macro such as `{now}` resolves to a new value at every call, so
|
|
33
|
+
* comparing OUTPUTS cannot stop that. The key is the authored filter, compared
|
|
34
|
+
* by structure (a host that rebuilds an equal filter inline must not
|
|
35
|
+
* re-query), plus each scope member read one by one (`recordId` among them,
|
|
36
|
+
* so moving to another record resolves again), never the scope object's
|
|
37
|
+
* identity (AGENTS.md #10). The held pair lives in state, so the
|
|
38
|
+
* value handed out is always the one React committed. A structurally different
|
|
39
|
+
* filter, or a changed scope member, resolves again and hands out a new value.
|
|
40
|
+
*
|
|
41
|
+
* A filter the resolver leaves unchanged (no token, or only tokens the scope
|
|
42
|
+
* cannot resolve) is handed out as the authored value itself, not as the
|
|
43
|
+
* resolver's equal copy, so a token-free filter reaches the query exactly as
|
|
44
|
+
* it did before any resolution.
|
|
45
|
+
*/
|
|
46
|
+
export declare function useResolvedFilter<T>(authored: T, scope: FilterTokenScope): T;
|
|
@@ -0,0 +1,130 @@
|
|
|
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 ONE hold every data node uses to resolve its OWN authored `filter`
|
|
10
|
+
* (objectui#10666). PR objectui#10656 wrote it for `list-view`
|
|
11
|
+
* (`useResolvedAuthoredFilter`, local to `ListView.tsx`); PR objectui#10705
|
|
12
|
+
* moved it into a module of `plugin-list` for `object-gallery`; the sweep that
|
|
13
|
+
* closed objectui#10666 moved it here, beside `useFilterScope`, so the nine
|
|
14
|
+
* packages whose nodes send their own filter share one copy instead of growing
|
|
15
|
+
* nine. The hold, its key and its comparator moved unchanged; the one addition
|
|
16
|
+
* is that a filter the resolver leaves unchanged is handed out as authored (see
|
|
17
|
+
* `resolveHeldFilter`).
|
|
18
|
+
*/
|
|
19
|
+
import * as React from 'react';
|
|
20
|
+
import { resolveFilterPlaceholders } from '@object-ui/core';
|
|
21
|
+
function resolveHeldFilter(authored, scope) {
|
|
22
|
+
const resolved = resolveFilterPlaceholders(authored, scope);
|
|
23
|
+
return {
|
|
24
|
+
authored,
|
|
25
|
+
currentUserId: scope.currentUserId,
|
|
26
|
+
currentOrgId: scope.currentOrgId,
|
|
27
|
+
recordId: scope.recordId,
|
|
28
|
+
onUnresolved: scope.onUnresolved,
|
|
29
|
+
// `resolveFilterPlaceholders` copies every array and plain object it walks,
|
|
30
|
+
// so a filter with nothing to resolve comes back EQUAL but not the same
|
|
31
|
+
// reference. Such a filter is handed out as authored: a node whose filter
|
|
32
|
+
// carries no token sends the very value it was given, as it did before it
|
|
33
|
+
// resolved anything (the `object-kanban` / `object-calendar` / `object-tree`
|
|
34
|
+
// member pins read `$filter` by identity). Only a filter the resolver
|
|
35
|
+
// actually changed is handed out as the resolved copy.
|
|
36
|
+
resolved: isSameAuthoredFilter(resolved, authored) ? authored : resolved,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** Depth past which {@link isSameAuthoredFilter} gives up and answers "changed". */
|
|
40
|
+
const AUTHORED_FILTER_MAX_DEPTH = 12;
|
|
41
|
+
function isPlainFilterObject(value) {
|
|
42
|
+
if (value === null || typeof value !== 'object')
|
|
43
|
+
return false;
|
|
44
|
+
const proto = Object.getPrototypeOf(value);
|
|
45
|
+
return proto === Object.prototype || proto === null;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Compare two authored filters by structure, never by serialising them.
|
|
49
|
+
*
|
|
50
|
+
* The same rules as `plugin-view`'s `isStructurallyEqual` (objectui#6460):
|
|
51
|
+
* primitives by `Object.is`, a `Date` by its instant, arrays element by
|
|
52
|
+
* element and in order, plain objects by key set and value. Anything else is
|
|
53
|
+
* equal only as the same reference, and a structure deeper than the bound is
|
|
54
|
+
* "changed". Every uncertainty answers "changed", which re-resolves and
|
|
55
|
+
* re-queries; the comparison can drop a redundant query, never a needed one.
|
|
56
|
+
*/
|
|
57
|
+
function isSameAuthoredFilter(a, b, depth = 0) {
|
|
58
|
+
if (Object.is(a, b))
|
|
59
|
+
return true;
|
|
60
|
+
if (depth >= AUTHORED_FILTER_MAX_DEPTH)
|
|
61
|
+
return false;
|
|
62
|
+
if (a instanceof Date || b instanceof Date) {
|
|
63
|
+
return a instanceof Date && b instanceof Date && a.getTime() === b.getTime();
|
|
64
|
+
}
|
|
65
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
66
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
|
|
67
|
+
return false;
|
|
68
|
+
return a.every((item, i) => isSameAuthoredFilter(item, b[i], depth + 1));
|
|
69
|
+
}
|
|
70
|
+
if (isPlainFilterObject(a) && isPlainFilterObject(b)) {
|
|
71
|
+
const keys = Object.keys(a);
|
|
72
|
+
if (keys.length !== Object.keys(b).length)
|
|
73
|
+
return false;
|
|
74
|
+
return keys.every((key) => (Object.prototype.hasOwnProperty.call(b, key) && isSameAuthoredFilter(a[key], b[key], depth + 1)));
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Resolve a data node's own authored `filter` ONCE, and hold the result.
|
|
80
|
+
*
|
|
81
|
+
* Every context token the spec defines for filters (`{current_user_id}`,
|
|
82
|
+
* `{current_org_id}`, `{record_id}` where a record is in view, and the date
|
|
83
|
+
* macros such as `{today}`) is expanded by `@object-ui/core`'s shared
|
|
84
|
+
* `resolveFilterPlaceholders`, against the scope `useFilterScope()` reads: the
|
|
85
|
+
* session scope the host provides plus the mounted record's id
|
|
86
|
+
* (objectui#7297). Pass `useFilterScope()` as `scope`:
|
|
87
|
+
*
|
|
88
|
+
* ```tsx
|
|
89
|
+
* const filterScope = useFilterScope();
|
|
90
|
+
* const filter = useResolvedFilter(schema.filter, filterScope);
|
|
91
|
+
* // query with `filter`, and key the fetch effect on `filter`, never on schema.filter
|
|
92
|
+
* ```
|
|
93
|
+
*
|
|
94
|
+
* ⛔ Not a second resolver, and no fallback: a token the scope cannot resolve
|
|
95
|
+
* is whatever `resolveFilterPlaceholders` makes of it (left intact, with one
|
|
96
|
+
* warning naming it).
|
|
97
|
+
*
|
|
98
|
+
* The result is HELD against its inputs, the shape `plugin-view`'s
|
|
99
|
+
* `useResolvedFilterSegments` uses. A fetch effect keys on this filter, so a
|
|
100
|
+
* resolved copy minted on every render would refetch on every render; and a
|
|
101
|
+
* date macro such as `{now}` resolves to a new value at every call, so
|
|
102
|
+
* comparing OUTPUTS cannot stop that. The key is the authored filter, compared
|
|
103
|
+
* by structure (a host that rebuilds an equal filter inline must not
|
|
104
|
+
* re-query), plus each scope member read one by one (`recordId` among them,
|
|
105
|
+
* so moving to another record resolves again), never the scope object's
|
|
106
|
+
* identity (AGENTS.md #10). The held pair lives in state, so the
|
|
107
|
+
* value handed out is always the one React committed. A structurally different
|
|
108
|
+
* filter, or a changed scope member, resolves again and hands out a new value.
|
|
109
|
+
*
|
|
110
|
+
* A filter the resolver leaves unchanged (no token, or only tokens the scope
|
|
111
|
+
* cannot resolve) is handed out as the authored value itself, not as the
|
|
112
|
+
* resolver's equal copy, so a token-free filter reaches the query exactly as
|
|
113
|
+
* it did before any resolution.
|
|
114
|
+
*/
|
|
115
|
+
export function useResolvedFilter(authored, scope) {
|
|
116
|
+
const [held, setHeld] = React.useState(() => resolveHeldFilter(authored, scope));
|
|
117
|
+
if (held.currentUserId !== scope.currentUserId
|
|
118
|
+
|| held.currentOrgId !== scope.currentOrgId
|
|
119
|
+
|| held.recordId !== scope.recordId
|
|
120
|
+
|| held.onUnresolved !== scope.onUnresolved
|
|
121
|
+
|| !isSameAuthoredFilter(held.authored, authored)) {
|
|
122
|
+
// React's documented "information from previous renders" shape: a set
|
|
123
|
+
// during render re-renders this component at once, before any child sees
|
|
124
|
+
// the discarded pass, and the re-render finds the inputs equal.
|
|
125
|
+
const next = resolveHeldFilter(authored, scope);
|
|
126
|
+
setHeld(next);
|
|
127
|
+
return next.resolved;
|
|
128
|
+
}
|
|
129
|
+
return held.resolved;
|
|
130
|
+
}
|
|
@@ -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>;
|