@object-ui/react 17.6.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 +3766 -0
- package/README.md +294 -38
- package/dist/SchemaRenderer.d.ts +38 -7
- package/dist/SchemaRenderer.js +1218 -87
- 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/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 +5 -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/element-data-source/ElementDataSourceGate.d.ts +88 -1
- package/dist/element-data-source/ElementDataSourceGate.js +326 -13
- 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 +21 -8
- package/dist/hooks/useActionTextLocalizer.js +33 -8
- package/dist/hooks/useConfigBagEvaluator.d.ts +30 -0
- package/dist/hooks/useConfigBagEvaluator.js +39 -0
- 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/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 +8 -5
- package/dist/hooks/usePageAssignment.js +37 -16
- 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/index.d.ts +8 -1
- package/dist/index.js +45 -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/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 +42 -1
- package/dist/utils/unevaluatedExpression.js +74 -11
- package/dist/utils/visibilityDiagnostic.d.ts +271 -19
- package/dist/utils/visibilityDiagnostic.js +311 -44
- 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
|
@@ -6,12 +6,14 @@
|
|
|
6
6
|
* LICENSE file in the root directory of this source tree.
|
|
7
7
|
*/
|
|
8
8
|
import { createContext, createElement, useContext, useMemo } from 'react';
|
|
9
|
-
import { ExpressionEvaluator, evalRowPredicate
|
|
9
|
+
import { ExpressionEvaluator, evalRowPredicate } from '@object-ui/core';
|
|
10
10
|
/**
|
|
11
11
|
* Global predicate scope — populated by host shells (e.g. app-shell's
|
|
12
12
|
* `ExpressionProvider`) so renderers like action buttons can resolve
|
|
13
|
-
* deployment-level flags (`features`, `user`,
|
|
14
|
-
* to import the host shell directly.
|
|
13
|
+
* deployment-level flags (`features`, `user`, …) without needing
|
|
14
|
+
* to import the host shell directly. (⛔ Not `app`: objectui#8155 removed that
|
|
15
|
+
* root from `buildExpressionScope`, so no host shell in this repo publishes
|
|
16
|
+
* one.)
|
|
15
17
|
*
|
|
16
18
|
* `useCondition` / `useExpression` merge this scope under the
|
|
17
19
|
* locally-passed `context`, so per-row `record` overrides still win.
|
|
@@ -74,56 +76,49 @@ export { toPredicateInput } from '@object-ui/core';
|
|
|
74
76
|
* Build the predicate context for a **row record** — the bag to hand
|
|
75
77
|
* {@link useCondition} when the thing being gated is scoped to one record.
|
|
76
78
|
*
|
|
77
|
-
* The row is bound
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
79
|
+
* The row is bound ONE way: `record.status` — the canon (objectui#5330, ruled
|
|
80
|
+
* 2026-08-20; Phase 2 executed by `83fe6e741`). This is not a dialect choice;
|
|
81
|
+
* it is one rule, and it is `evalRowPredicate`'s rule
|
|
82
|
+
* (`core/evaluator/listConditional.ts` — the record header, list rows, the row
|
|
83
|
+
* kebab and conditional formatting all evaluate through it), restated here for
|
|
84
|
+
* the `useCondition` tier so both tiers answer an author's `visible:` the same
|
|
85
|
+
* way.
|
|
84
86
|
*
|
|
85
|
-
* ##
|
|
87
|
+
* ## The two retired spellings (`83fe6e741`)
|
|
86
88
|
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
89
|
+
* Until Phase 2 this bag also carried the row spread flat (bare `status`, the
|
|
90
|
+
* row-action shorthand) and as `data` (legacy), and `useCondition` below warned
|
|
91
|
+
* once per non-canonical spelling (Phase 1, `d1ab06f0f`). Both bindings and the
|
|
92
|
+
* warning are gone. A bare-field or `data.*` predicate against this bag now
|
|
93
|
+
* faults exactly as it always did on the server (`buildScope({ record })`
|
|
94
|
+
* mounts exactly `['record']`: `Unknown variable: status` / `Unknown variable:
|
|
95
|
+
* data`), and each `useCondition` leg applies its EXISTING fault policy — the
|
|
96
|
+
* throwing legs (`throwOnError`: every action `visible` — `action:button`,
|
|
97
|
+
* `action:menu`, `action:bar`, `DeclaredActionsBar`, and since objectui#11212
|
|
98
|
+
* `action:group`, `action:icon` and the related-list toolbar) hide and report
|
|
99
|
+
* `was hidden/disabled: its predicate threw`, naming the variable; the
|
|
100
|
+
* non-throwing legs fail soft to `true`. The same holds for a legacy `${data.x}` / `${x}` string: one bag
|
|
101
|
+
* shape, both dialects. Nothing detects a retired spelling here; it is simply
|
|
102
|
+
* unbound. `@object-ui/core`'s `evaluator/rowPredicateCanon.ts` carries the
|
|
103
|
+
* canon statement, the server measurement, the layer scoping (`data` stays
|
|
104
|
+
* canonical one layer over, in a metadata-editing form — ADR-0089 D3) and the
|
|
105
|
+
* offline detector.
|
|
96
106
|
*
|
|
97
|
-
*
|
|
98
|
-
* `sys_approval_request` gates on `record.viewer.*` (framework#3310 / #3424),
|
|
99
|
-
* so the whole server-declared approval decision set was invisible wherever
|
|
100
|
-
* `DeclaredActionsBar` rendered until objectui#4077 bound the row this way —
|
|
101
|
-
* and the four generic action renderers carried the same root-only binding
|
|
102
|
-
* until objectui#4075.
|
|
107
|
+
* ## Why a helper and not "just `{ record }`" (objectui#4075 / #4080)
|
|
103
108
|
*
|
|
104
|
-
*
|
|
109
|
+
* Because the rule has two halves and every predicate face has to get both:
|
|
110
|
+
* the SHAPE — `record` is the row's one name, so `record.viewer.can_act`, what
|
|
111
|
+
* every declared action on framework's `sys_approval_request` gates on
|
|
112
|
+
* (framework#3310 / objectstack-ai/objectstack#3424), reaches the row — and the NO-ROW case below.
|
|
113
|
+
* `DeclaredActionsBar` and the four generic action renderers once carried
|
|
114
|
+
* inline copies of this bag and drifted (objectui#4077 / #4079); one named
|
|
115
|
+
* helper is what keeps a fifth copy from drifting again.
|
|
105
116
|
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
* (⛔ no removal before the survey — part of the same ruling).
|
|
112
|
-
*
|
|
113
|
-
* The canon states the SERVER's accept set, which is narrower than this bag:
|
|
114
|
-
* measured on `@objectstack/formula@17.1.0`, `buildScope({ record })` mounts
|
|
115
|
-
* exactly `['record']`, so of the three spellings this helper binds, only
|
|
116
|
-
* `record.*` evaluates server-side — a bare field faults `Unknown variable:
|
|
117
|
-
* status` and `data.*` faults `Unknown variable: data`. This three-way bag has
|
|
118
|
-
* no server counterpart at all, which is exactly why the warning belongs on
|
|
119
|
-
* this side. `@object-ui/core`'s `evaluator/rowPredicateCanon.ts` carries the
|
|
120
|
-
* full measurement and the layer scoping (`data` stays canonical one layer
|
|
121
|
-
* over, in a metadata-editing form — ADR-0089 D3).
|
|
122
|
-
*
|
|
123
|
-
* `record` and `data` are written AFTER the spread deliberately: a row that
|
|
124
|
-
* happens to carry a field literally named `record` or `data` must not shadow
|
|
125
|
-
* the root every predicate is written against. Same precedence as
|
|
126
|
-
* `evalRowPredicate`.
|
|
117
|
+
* Note the direction of the merge: `useCondition` evaluates on
|
|
118
|
+
* `new ExpressionEvaluator({ ...scope, ...context })`, so this bag's `record`
|
|
119
|
+
* shadows an ambient `record` a host put in the predicate scope — the row is
|
|
120
|
+
* the subject — while every OTHER ambient key (`features`, `user`, a host's own
|
|
121
|
+
* `data`) survives to the predicate as the host's own.
|
|
127
122
|
*
|
|
128
123
|
* This is the BINDING rule only. It deliberately does not touch the evaluation
|
|
129
124
|
* entry — `useCondition` / `toPredicateInput` / `hasDeclaredVisibilityGate`
|
|
@@ -135,10 +130,10 @@ export { toPredicateInput } from '@object-ui/core';
|
|
|
135
130
|
* ## No row → bind NOTHING, do not bind an empty one
|
|
136
131
|
*
|
|
137
132
|
* `null` / `undefined` / a non-object returns an EMPTY bag, not
|
|
138
|
-
* `{ record: {}
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
133
|
+
* `{ record: {} }`. The difference is load-bearing: `useCondition` merges this
|
|
134
|
+
* context OVER the ambient predicate scope, so binding an empty record would
|
|
135
|
+
* blank out a `record` that a host had put in the scope itself — which is
|
|
136
|
+
* exactly how `action:group`'s dropdown leaf is driven in
|
|
142
137
|
* `action-group-dropdown-visible.test.tsx`, and it is a legitimate way for a
|
|
143
138
|
* host to supply the row. "This surface has no row of its own" must stay
|
|
144
139
|
* distinct from "this surface's row is empty"; only the latter is entitled to
|
|
@@ -150,8 +145,7 @@ export function usePredicateRecordContext(record) {
|
|
|
150
145
|
return useMemo(() => {
|
|
151
146
|
if (record == null || typeof record !== 'object' || Array.isArray(record))
|
|
152
147
|
return {};
|
|
153
|
-
|
|
154
|
-
return { ...row, record: row, data: row };
|
|
148
|
+
return { record: record };
|
|
155
149
|
}, [record]);
|
|
156
150
|
}
|
|
157
151
|
/**
|
|
@@ -188,37 +182,11 @@ export function useCondition(condition, context = {}, options) {
|
|
|
188
182
|
const scope = usePredicateScope();
|
|
189
183
|
// We evaluate directly without caching the evaluator to avoid issues with context changes
|
|
190
184
|
return useMemo(() => {
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
// `usePredicateRecordContext`
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
// Two guards, both of which can only remove a report:
|
|
198
|
-
// - the bag must carry the `usePredicateRecordContext` signature, `data`
|
|
199
|
-
// and `record` being the SAME object by identity. A host scope that
|
|
200
|
-
// merely happens to carry a `data` key cannot satisfy that, so a
|
|
201
|
-
// non-row `useCondition` call is never judged as a row predicate.
|
|
202
|
-
// - the source must be CEL. In this tier's legacy `${…}` dialect
|
|
203
|
-
// (`'${data.status === "active"}'` — this hook's own doc example)
|
|
204
|
-
// `data.*` is the NORMAL spelling, and reporting it would be a false
|
|
205
|
-
// positive on every legacy predicate.
|
|
206
|
-
const bag = context;
|
|
207
|
-
const boundRow = bag.record != null && typeof bag.record === 'object' && bag.record === bag.data
|
|
208
|
-
? bag.record
|
|
209
|
-
: undefined;
|
|
210
|
-
if (boundRow !== undefined) {
|
|
211
|
-
const celSource = typeof condition === 'string'
|
|
212
|
-
? isLegacyDialectSource(condition)
|
|
213
|
-
? undefined
|
|
214
|
-
: condition
|
|
215
|
-
: condition && typeof condition === 'object' && condition.dialect === 'cel'
|
|
216
|
-
? condition.source
|
|
217
|
-
: undefined;
|
|
218
|
-
if (typeof celSource === 'string') {
|
|
219
|
-
warnNonCanonicalRowSpelling(celSource, boundRow, true, options?.label);
|
|
220
|
-
}
|
|
221
|
-
}
|
|
185
|
+
// No row-spelling detector on this tier (`83fe6e741` removed the
|
|
186
|
+
// Phase-1 warning with the bindings): a retired bare-field / `data.*`
|
|
187
|
+
// spelling against a `usePredicateRecordContext` bag is simply unbound
|
|
188
|
+
// and faults like any other unknown variable, and each leg's existing
|
|
189
|
+
// fault report below is what names it.
|
|
222
190
|
const evaluator = new ExpressionEvaluator({ ...scope, ...context });
|
|
223
191
|
if (options?.throwOnError) {
|
|
224
192
|
// Fail-closed: a predicate that can't be evaluated hides/disables
|
|
@@ -256,11 +224,11 @@ export function useCondition(condition, context = {}, options) {
|
|
|
256
224
|
* routes to `@object-ui/core`'s `evalRowPredicate`: a bare string is CEL (the
|
|
257
225
|
* spec contract for `ActionSchema.visible`), a `{ dialect: 'cel', source }`
|
|
258
226
|
* envelope is always CEL, and only a legacy-dialect string falls back to the
|
|
259
|
-
* old engine (with a deprecation warning). The row is bound as `record.*`
|
|
260
|
-
*
|
|
261
|
-
* `
|
|
262
|
-
* (`features` / `user` / …) is merged alongside so
|
|
263
|
-
* resolving.
|
|
227
|
+
* old engine (with a deprecation warning). The row is bound as `record.*` only
|
|
228
|
+
* — the canon (objectui#5330; the bare-field and `data.*` spellings retired in
|
|
229
|
+
* `83fe6e741` and fault here like any unknown variable); the ambient
|
|
230
|
+
* predicate scope (`features` / `user` / …) is merged alongside so
|
|
231
|
+
* deployment-level gates keep resolving.
|
|
264
232
|
*
|
|
265
233
|
* @param pred The raw predicate: `boolean` (returned as-is), a CEL string,
|
|
266
234
|
* an `{ dialect, source }` envelope, or `null`/`undefined`/`''`.
|
|
@@ -20,11 +20,31 @@ export declare function FilterScopeProvider({ currentUserId, currentOrgId, child
|
|
|
20
20
|
children: ReactNode;
|
|
21
21
|
}): import("react").FunctionComponentElement<import("react").ProviderProps<FilterTokenScope>>;
|
|
22
22
|
/**
|
|
23
|
-
* Read the
|
|
23
|
+
* Read the scope for filter placeholder resolution: the session scope the host
|
|
24
|
+
* mounted, plus the id of the record in view when there is one.
|
|
24
25
|
*
|
|
25
26
|
* Pass the result straight to `resolveFilterPlaceholders(filter, scope)` from
|
|
26
27
|
* `@object-ui/core` — that helper expands every placeholder vocabulary in one
|
|
27
28
|
* call, which is the point: resolving only some of them is the defect behind
|
|
28
|
-
* #3574.
|
|
29
|
+
* objectstack-ai/objectstack#3574.
|
|
30
|
+
*
|
|
31
|
+
* ## `{record_id}` reads the MOUNTED record (objectui#7297)
|
|
32
|
+
*
|
|
33
|
+
* `recordId` comes from the nearest `RecordContextProvider` — the record a
|
|
34
|
+
* `type: 'record'` page is showing, the same provider whose row a component's
|
|
35
|
+
* `visibleWhen` binds as `record` — and from nowhere else: ⛔ not a URL
|
|
36
|
+
* parameter, not a page variable, and not a prop of `FilterScopeProvider`,
|
|
37
|
+
* which stays session-only. Every data node that already resolves its filter
|
|
38
|
+
* through this hook therefore resolves `{record_id}` on a record page without
|
|
39
|
+
* a line of its own, and refuses it by name everywhere else (a list view, a
|
|
40
|
+
* dashboard, a page that is not a record page), because there the member is
|
|
41
|
+
* simply absent.
|
|
42
|
+
*
|
|
43
|
+
* Outside a record context the session scope is handed back as the very
|
|
44
|
+
* object the provider published, so nothing changes for a surface that shows
|
|
45
|
+
* no record. Inside one, the combined scope is rebuilt only when the session
|
|
46
|
+
* scope or the record id changes. A consumer that holds a resolution compares
|
|
47
|
+
* the members one by one — `recordId` among them — never this object's
|
|
48
|
+
* identity (AGENTS.md #10).
|
|
29
49
|
*/
|
|
30
50
|
export declare function useFilterScope(): FilterTokenScope;
|
|
@@ -6,9 +6,12 @@
|
|
|
6
6
|
* LICENSE file in the root directory of this source tree.
|
|
7
7
|
*/
|
|
8
8
|
import { createContext, createElement, useContext, useMemo } from 'react';
|
|
9
|
+
import { useRecordContext } from '../context/RecordContext.js';
|
|
9
10
|
/**
|
|
10
11
|
* The session values filter placeholders resolve against — `{current_user_id}`
|
|
11
|
-
* and `{current_org_id}` (
|
|
12
|
+
* and `{current_org_id}` (objectstack-ai/objectstack#3574). The record-scoped
|
|
13
|
+
* `{record_id}` is not a session value and is not provided here: see
|
|
14
|
+
* {@link useFilterScope}.
|
|
12
15
|
*
|
|
13
16
|
* ## Why a dedicated context
|
|
14
17
|
*
|
|
@@ -43,13 +46,35 @@ export function FilterScopeProvider({ currentUserId, currentOrgId, children, })
|
|
|
43
46
|
return createElement(FilterScopeContext.Provider, { value }, children);
|
|
44
47
|
}
|
|
45
48
|
/**
|
|
46
|
-
* Read the
|
|
49
|
+
* Read the scope for filter placeholder resolution: the session scope the host
|
|
50
|
+
* mounted, plus the id of the record in view when there is one.
|
|
47
51
|
*
|
|
48
52
|
* Pass the result straight to `resolveFilterPlaceholders(filter, scope)` from
|
|
49
53
|
* `@object-ui/core` — that helper expands every placeholder vocabulary in one
|
|
50
54
|
* call, which is the point: resolving only some of them is the defect behind
|
|
51
|
-
* #3574.
|
|
55
|
+
* objectstack-ai/objectstack#3574.
|
|
56
|
+
*
|
|
57
|
+
* ## `{record_id}` reads the MOUNTED record (objectui#7297)
|
|
58
|
+
*
|
|
59
|
+
* `recordId` comes from the nearest `RecordContextProvider` — the record a
|
|
60
|
+
* `type: 'record'` page is showing, the same provider whose row a component's
|
|
61
|
+
* `visibleWhen` binds as `record` — and from nowhere else: ⛔ not a URL
|
|
62
|
+
* parameter, not a page variable, and not a prop of `FilterScopeProvider`,
|
|
63
|
+
* which stays session-only. Every data node that already resolves its filter
|
|
64
|
+
* through this hook therefore resolves `{record_id}` on a record page without
|
|
65
|
+
* a line of its own, and refuses it by name everywhere else (a list view, a
|
|
66
|
+
* dashboard, a page that is not a record page), because there the member is
|
|
67
|
+
* simply absent.
|
|
68
|
+
*
|
|
69
|
+
* Outside a record context the session scope is handed back as the very
|
|
70
|
+
* object the provider published, so nothing changes for a surface that shows
|
|
71
|
+
* no record. Inside one, the combined scope is rebuilt only when the session
|
|
72
|
+
* scope or the record id changes. A consumer that holds a resolution compares
|
|
73
|
+
* the members one by one — `recordId` among them — never this object's
|
|
74
|
+
* identity (AGENTS.md #10).
|
|
52
75
|
*/
|
|
53
76
|
export function useFilterScope() {
|
|
54
|
-
|
|
77
|
+
const session = useContext(FilterScopeContext);
|
|
78
|
+
const recordId = useRecordContext()?.recordId;
|
|
79
|
+
return useMemo(() => (recordId == null ? session : { ...session, recordId }), [session, recordId]);
|
|
55
80
|
}
|
|
@@ -23,6 +23,16 @@ export interface UseGlobalUndoOptions {
|
|
|
23
23
|
*
|
|
24
24
|
* Provides reactive undo/redo state, executes data operations through the
|
|
25
25
|
* supplied dataSource, and registers Ctrl+Z / Ctrl+Shift+Z keyboard shortcuts.
|
|
26
|
+
*
|
|
27
|
+
* Keyboard: however many instances are mounted, one `window` `keydown`
|
|
28
|
+
* listener serves them all and one keypress pops at most one operation. It is
|
|
29
|
+
* answered by the longest-mounted live instance — its `dataSource` runs the
|
|
30
|
+
* write and its `onUndo` / `onRedo` fires — and by no other. A keypress whose
|
|
31
|
+
* target is an `input`, `textarea`, `select` or contenteditable region is left
|
|
32
|
+
* to that field: nothing is popped and the default is not prevented.
|
|
33
|
+
*
|
|
34
|
+
* The returned `undo` / `redo` (e.g. a toast's Undo button) always run through
|
|
35
|
+
* THIS instance's `dataSource` and callbacks.
|
|
26
36
|
*/
|
|
27
37
|
export declare function useGlobalUndo(options?: UseGlobalUndoOptions): {
|
|
28
38
|
undo: () => Promise<void>;
|
|
@@ -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 { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
|
|
8
|
+
import { useCallback, useEffect, useInsertionEffect, useMemo, useRef, useSyncExternalStore, } from 'react';
|
|
9
9
|
import { globalUndoManager } from '@object-ui/core';
|
|
10
10
|
function getSnapshot() {
|
|
11
11
|
return {
|
|
@@ -28,59 +28,125 @@ function subscribe(callback) {
|
|
|
28
28
|
});
|
|
29
29
|
}
|
|
30
30
|
function getCachedSnapshot() { return cachedSnapshot; }
|
|
31
|
+
async function executeOp(optionsRef, op, data, mode) {
|
|
32
|
+
const ds = optionsRef.current.dataSource;
|
|
33
|
+
if (!ds)
|
|
34
|
+
return;
|
|
35
|
+
const action = mode === 'undo'
|
|
36
|
+
? { create: 'delete', update: 'update', delete: 'create' }[op.type]
|
|
37
|
+
: op.type;
|
|
38
|
+
if (action === 'delete')
|
|
39
|
+
await ds.delete(op.objectName, op.recordId);
|
|
40
|
+
else if (action === 'update')
|
|
41
|
+
await ds.update(op.objectName, op.recordId, data);
|
|
42
|
+
else
|
|
43
|
+
await ds.create(op.objectName, data);
|
|
44
|
+
}
|
|
45
|
+
async function runUndo(optionsRef) {
|
|
46
|
+
const op = globalUndoManager.popUndo();
|
|
47
|
+
if (!op)
|
|
48
|
+
return;
|
|
49
|
+
await executeOp(optionsRef, op, op.undoData, 'undo');
|
|
50
|
+
optionsRef.current.onUndo?.(op);
|
|
51
|
+
}
|
|
52
|
+
async function runRedo(optionsRef) {
|
|
53
|
+
const op = globalUndoManager.popRedo();
|
|
54
|
+
if (!op)
|
|
55
|
+
return;
|
|
56
|
+
await executeOp(optionsRef, op, op.redoData, 'redo');
|
|
57
|
+
optionsRef.current.onRedo?.(op);
|
|
58
|
+
}
|
|
59
|
+
// ── Keyboard shortcuts: ONE listener for the one shared stack (objectui#11081) ──
|
|
60
|
+
//
|
|
61
|
+
// Every instance used to add its own `window` `keydown` listener, and each of
|
|
62
|
+
// them popped `globalUndoManager`, so one Ctrl+Z undid one operation PER
|
|
63
|
+
// MOUNTED INSTANCE (the console mounts several at once). Now the instances
|
|
64
|
+
// register here and a single module-level listener answers each keypress,
|
|
65
|
+
// popping at most one operation.
|
|
66
|
+
//
|
|
67
|
+
// Which instance answers is decided once: the longest-mounted live one, the
|
|
68
|
+
// first entry of this insertion-ordered set. An `UndoableOperation` carries
|
|
69
|
+
// nothing that names the instance whose runner pushed it, so "the owner of the
|
|
70
|
+
// operation" is not readable here. The oldest live instance is also exactly
|
|
71
|
+
// the one that answered a keypress before this change whenever a single
|
|
72
|
+
// operation was stacked (its listener was first on `window` and popped it; the
|
|
73
|
+
// rest found the stack empty), so the one-operation keypress behaves as it
|
|
74
|
+
// always did. When it unmounts, the next-oldest takes over.
|
|
75
|
+
const keyboardInstances = new Set();
|
|
76
|
+
/** A field whose own Ctrl+Z (the browser's text undo) must not be taken over. */
|
|
77
|
+
function isEditableTarget(target) {
|
|
78
|
+
const el = target;
|
|
79
|
+
if (!el || typeof el.tagName !== 'string')
|
|
80
|
+
return false;
|
|
81
|
+
if (el.isContentEditable)
|
|
82
|
+
return true;
|
|
83
|
+
const tag = el.tagName.toUpperCase();
|
|
84
|
+
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
|
|
85
|
+
}
|
|
86
|
+
function onUndoShortcut(e) {
|
|
87
|
+
if (!(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== 'z')
|
|
88
|
+
return;
|
|
89
|
+
// Inside a text field Ctrl+Z is the field's own undo: no pop, no preventDefault.
|
|
90
|
+
if (isEditableTarget(e.target))
|
|
91
|
+
return;
|
|
92
|
+
const owner = keyboardInstances.values().next().value;
|
|
93
|
+
if (!owner)
|
|
94
|
+
return;
|
|
95
|
+
e.preventDefault();
|
|
96
|
+
if (e.shiftKey) {
|
|
97
|
+
void runRedo(owner);
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
void runUndo(owner);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function registerKeyboardInstance(optionsRef) {
|
|
104
|
+
keyboardInstances.add(optionsRef);
|
|
105
|
+
if (keyboardInstances.size === 1)
|
|
106
|
+
window.addEventListener('keydown', onUndoShortcut);
|
|
107
|
+
return () => {
|
|
108
|
+
keyboardInstances.delete(optionsRef);
|
|
109
|
+
if (keyboardInstances.size === 0)
|
|
110
|
+
window.removeEventListener('keydown', onUndoShortcut);
|
|
111
|
+
};
|
|
112
|
+
}
|
|
31
113
|
/**
|
|
32
114
|
* React hook that wraps the global UndoManager for use in console components.
|
|
33
115
|
*
|
|
34
116
|
* Provides reactive undo/redo state, executes data operations through the
|
|
35
117
|
* supplied dataSource, and registers Ctrl+Z / Ctrl+Shift+Z keyboard shortcuts.
|
|
118
|
+
*
|
|
119
|
+
* Keyboard: however many instances are mounted, one `window` `keydown`
|
|
120
|
+
* listener serves them all and one keypress pops at most one operation. It is
|
|
121
|
+
* answered by the longest-mounted live instance — its `dataSource` runs the
|
|
122
|
+
* write and its `onUndo` / `onRedo` fires — and by no other. A keypress whose
|
|
123
|
+
* target is an `input`, `textarea`, `select` or contenteditable region is left
|
|
124
|
+
* to that field: nothing is popped and the default is not prevented.
|
|
125
|
+
*
|
|
126
|
+
* The returned `undo` / `redo` (e.g. a toast's Undo button) always run through
|
|
127
|
+
* THIS instance's `dataSource` and callbacks.
|
|
36
128
|
*/
|
|
37
129
|
export function useGlobalUndo(options = {}) {
|
|
38
130
|
const optionsRef = useRef(options);
|
|
39
|
-
|
|
131
|
+
// Refreshed in the MUTATION phase, never in the render body. Every caller
|
|
132
|
+
// passes an inline object literal with inline `onUndo` / `onRedo` closures,
|
|
133
|
+
// so `options` is a new object on every render; `undo` / `redo` and the
|
|
134
|
+
// keyboard registration hold the ref (whose identity React does keep) and
|
|
135
|
+
// still reach the newest callbacks. Writing it during render published the
|
|
136
|
+
// options of renders React discards or replays.
|
|
137
|
+
// `useInsertionEffect` runs ahead of every layout effect, ref attachment and
|
|
138
|
+
// paint in the commit, so the only window this moves is the render phase
|
|
139
|
+
// itself — where `undo` / `redo`, being async data mutations, are not
|
|
140
|
+
// callable. `useEffectEvent` would be idiomatic but is React 19.2+, and this
|
|
141
|
+
// package's peer range starts at React 18.
|
|
142
|
+
useInsertionEffect(() => {
|
|
143
|
+
optionsRef.current = options;
|
|
144
|
+
});
|
|
40
145
|
const state = useSyncExternalStore(subscribe, getCachedSnapshot, getServerSnapshot);
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
? { create: 'delete', update: 'update', delete: 'create' }[op.type]
|
|
47
|
-
: op.type;
|
|
48
|
-
if (action === 'delete')
|
|
49
|
-
await ds.delete(op.objectName, op.recordId);
|
|
50
|
-
else if (action === 'update')
|
|
51
|
-
await ds.update(op.objectName, op.recordId, data);
|
|
52
|
-
else
|
|
53
|
-
await ds.create(op.objectName, data);
|
|
54
|
-
}, []);
|
|
55
|
-
const undo = useCallback(async () => {
|
|
56
|
-
const op = globalUndoManager.popUndo();
|
|
57
|
-
if (!op)
|
|
58
|
-
return;
|
|
59
|
-
await executeOp(op, op.undoData, 'undo');
|
|
60
|
-
optionsRef.current.onUndo?.(op);
|
|
61
|
-
}, [executeOp]);
|
|
62
|
-
const redo = useCallback(async () => {
|
|
63
|
-
const op = globalUndoManager.popRedo();
|
|
64
|
-
if (!op)
|
|
65
|
-
return;
|
|
66
|
-
await executeOp(op, op.redoData, 'redo');
|
|
67
|
-
optionsRef.current.onRedo?.(op);
|
|
68
|
-
}, [executeOp]);
|
|
69
|
-
// Keyboard shortcuts: Ctrl+Z (undo), Ctrl+Shift+Z (redo)
|
|
70
|
-
useEffect(() => {
|
|
71
|
-
const handler = (e) => {
|
|
72
|
-
if (!(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== 'z')
|
|
73
|
-
return;
|
|
74
|
-
e.preventDefault();
|
|
75
|
-
if (e.shiftKey) {
|
|
76
|
-
void redo();
|
|
77
|
-
}
|
|
78
|
-
else {
|
|
79
|
-
void undo();
|
|
80
|
-
}
|
|
81
|
-
};
|
|
82
|
-
window.addEventListener('keydown', handler);
|
|
83
|
-
return () => window.removeEventListener('keydown', handler);
|
|
84
|
-
}, [undo, redo]);
|
|
146
|
+
const undo = useCallback(() => runUndo(optionsRef), []);
|
|
147
|
+
const redo = useCallback(() => runRedo(optionsRef), []);
|
|
148
|
+
// Registered once per mount, keyed on the ref and on no memoised identity, so
|
|
149
|
+
// this instance's place in the keyboard order is its mount order.
|
|
150
|
+
useEffect(() => registerKeyboardInstance(optionsRef), []);
|
|
85
151
|
return useMemo(() => ({ ...state, undo, redo }), [state, undo, redo]);
|
|
86
152
|
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* LICENSE file in the root directory of this source tree.
|
|
7
7
|
*/
|
|
8
8
|
import type { NavigationConfigSchema, NavigationMode as SpecNavigationMode } from '@objectstack/spec/ui';
|
|
9
|
+
import type { RecordNavigateAction } from '@object-ui/types';
|
|
9
10
|
import type { SpecAuthoredInput } from '../spec-input.js';
|
|
10
11
|
/**
|
|
11
12
|
* The spec's `NavigationConfigSchema`, authoring side — by reference, with no
|
|
@@ -22,8 +23,10 @@ import type { SpecAuthoredInput } from '../spec-input.js';
|
|
|
22
23
|
* `mode` is OPTIONAL, because the spec says so and this hook agrees.
|
|
23
24
|
* `packages/spec/src/ui/view.zod.ts` declares
|
|
24
25
|
* `mode: NavigationModeSchema.default('page')`, and a `.default()` lands on the
|
|
25
|
-
* AUTHORING side as `| undefined` — so `navigation: {
|
|
26
|
-
*
|
|
26
|
+
* AUTHORING side as `| undefined` — so `navigation: { size: 'lg' }` is legal
|
|
27
|
+
* authored metadata that lets the mode default. ⛔ This sentence used to make
|
|
28
|
+
* the same point with a `view` member; see "The RETIRED `view` key" below for
|
|
29
|
+
* why no example in this file may spell that key again.
|
|
27
30
|
*
|
|
28
31
|
* Until objectui#4550 this alias `Omit`ted `mode` and re-added it as
|
|
29
32
|
* `NonNullable<…>`, on the stated reasoning that "this hook dispatches on
|
|
@@ -51,9 +54,9 @@ import type { SpecAuthoredInput } from '../spec-input.js';
|
|
|
51
54
|
*
|
|
52
55
|
* Two per-key notes the hand copy carried, kept here because a derived alias
|
|
53
56
|
* has no members to hang them on:
|
|
54
|
-
* - `size` is the coarse overlay bucket added by #2578; `resolveOverlayWidth`
|
|
57
|
+
* - `size` is the coarse overlay bucket added by objectstack-ai/objectstack#2578; `resolveOverlayWidth`
|
|
55
58
|
* below maps it through {@link OVERLAY_SIZE_WIDTHS}.
|
|
56
|
-
* - `width` is DEPRECATED by #2578 in favour of `size`. It still wins when
|
|
59
|
+
* - `width` is DEPRECATED by objectstack-ai/objectstack#2578 in favour of `size`. It still wins when
|
|
57
60
|
* present, because app-shell pre-resolves `size` into it.
|
|
58
61
|
*/
|
|
59
62
|
export type NavigationConfig = SpecAuthoredInput<typeof NavigationConfigSchema>;
|
|
@@ -95,10 +98,50 @@ export interface UseNavigationOverlayOptions {
|
|
|
95
98
|
navigation?: NavigationConfig;
|
|
96
99
|
/** Object name — used to build default URLs for page/new_window modes */
|
|
97
100
|
objectName?: string;
|
|
98
|
-
/**
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
101
|
+
/**
|
|
102
|
+
* Called for page-level navigation with the record id and a
|
|
103
|
+
* {@link RecordNavigateAction} — a navigation-MODE token from a closed
|
|
104
|
+
* vocabulary: `'view'` opens the record page, `'new_window'` opens it in a
|
|
105
|
+
* new browser tab.
|
|
106
|
+
*
|
|
107
|
+
* `handleClick` below is the one producer of that token, and every call it
|
|
108
|
+
* makes is typed against this declaration — so a third token is a compile
|
|
109
|
+
* error here, not a branch a host's handler silently never matches
|
|
110
|
+
* (objectui#9547). Supplied by the host that renders the list (a
|
|
111
|
+
* `schema.onNavigate`, a component prop, or the host's own router closure).
|
|
112
|
+
*
|
|
113
|
+
* When it is absent, an authored `page` click goes to the record navigator
|
|
114
|
+
* the host publishes on `RelatedRecordActionsContext` instead
|
|
115
|
+
* (objectui#11293; see the module docblock). Supplied, it wins.
|
|
116
|
+
*/
|
|
117
|
+
onNavigate?: (recordId: string | number, action: RecordNavigateAction) => void;
|
|
118
|
+
/**
|
|
119
|
+
* External onRowClick callback — if set, takes full priority.
|
|
120
|
+
*
|
|
121
|
+
* Declares BOTH arguments `handleClick` hands it: the record, and the
|
|
122
|
+
* optional modifier payload (`HandleClickModifiers`, declared just below)
|
|
123
|
+
* that lets a host implement Cmd/Ctrl/middle-click for itself. The second
|
|
124
|
+
* argument was always delivered; until objectui#9357 this line declared only
|
|
125
|
+
* the first and `handleClick` asserted the declaration away in order to make
|
|
126
|
+
* the call — so the payload was invisible on the one line a host reads, and
|
|
127
|
+
* every consumer that passes its own handler through copied the understated
|
|
128
|
+
* spelling outward.
|
|
129
|
+
*
|
|
130
|
+
* A one-parameter handler stays assignable here, and so does one that leaves
|
|
131
|
+
* its second parameter unannotated. The exception, and the one class that has
|
|
132
|
+
* to change: a handler whose second parameter is annotated NARROWER than
|
|
133
|
+
* `HandleClickModifiers` — React's `MouseEvent` is the shape this hits in
|
|
134
|
+
* practice, because the payload used to be discoverable only from the
|
|
135
|
+
* implementation — is refused from this card on with TS2322. The parameter is
|
|
136
|
+
* checked contravariantly, so the annotation has to ADMIT
|
|
137
|
+
* `HandleClickModifiers`. The fix is one line at that call site: annotate the
|
|
138
|
+
* parameter `HandleClickModifiers` (exported from this module, and from
|
|
139
|
+
* `@object-ui/react`), or drop the annotation and let it be inferred; either
|
|
140
|
+
* way the handler keeps receiving exactly what it received before. What the
|
|
141
|
+
* widening buys everyone else is that a caller who WANTS the payload can now
|
|
142
|
+
* see, from the published type, that it exists.
|
|
143
|
+
*/
|
|
144
|
+
onRowClick?: (record: Record<string, unknown>, event?: HandleClickModifiers) => void;
|
|
102
145
|
}
|
|
103
146
|
/**
|
|
104
147
|
* Optional event-like payload accepted by `handleClick`. We don't depend on
|
|
@@ -139,18 +182,40 @@ export interface NavigationOverlayState {
|
|
|
139
182
|
width: string | number | undefined;
|
|
140
183
|
/** Whether navigation is an overlay mode (drawer/modal/split/popover) */
|
|
141
184
|
isOverlay: boolean;
|
|
142
|
-
/** The target view/form name from NavigationConfig */
|
|
143
|
-
view: string | undefined;
|
|
144
185
|
}
|
|
145
186
|
/**
|
|
146
187
|
* Hook for NavigationConfig-driven navigation overlay.
|
|
147
188
|
*
|
|
189
|
+
* ## What to hand `objectName`
|
|
190
|
+
*
|
|
191
|
+
* The block's RECORD SOURCE — the object the clicked rows actually came from —
|
|
192
|
+
* and never a bare top-level `schema.objectName` read in its place. `77cb489b4`
|
|
193
|
+
* published `objectName` as the THIRD RUNG of one record-source ladder (`data`,
|
|
194
|
+
* then `staticData`, then `objectName`), not as a parallel "page object"
|
|
195
|
+
* concept, so a block has exactly ONE record source. `handleClick` below builds
|
|
196
|
+
* the record-page URL `/{objectName}/record/{id}` out of whatever it is handed:
|
|
197
|
+
* a caller that hands it the top-level key while its rows came from
|
|
198
|
+
* `data.object` navigates to a record that the URL's own object does not
|
|
199
|
+
* contain (`2ce2612df`).
|
|
200
|
+
*
|
|
201
|
+
* A caller that resolves a data config reads that ladder through the ONE shared
|
|
202
|
+
* reader — `resolveRecordSourceObjectName` from `@object-ui/core`
|
|
203
|
+
* (`b041b9c0c`) — as the example does. A caller with NO data config has
|
|
204
|
+
* nothing above rung three, and its `schema.objectName` already IS its record
|
|
205
|
+
* source; that spelling is correct there and needs no conversion.
|
|
206
|
+
*
|
|
148
207
|
* @example
|
|
149
208
|
* ```tsx
|
|
209
|
+
* import {
|
|
210
|
+
* resolveRecordSourceConfig,
|
|
211
|
+
* resolveRecordSourceObjectName,
|
|
212
|
+
* } from '@object-ui/core';
|
|
213
|
+
*
|
|
214
|
+
* const dataConfig = useMemo(() => resolveRecordSourceConfig(schema), [schema]);
|
|
150
215
|
* const { handleClick, isOpen, selectedRecord, mode, close, width } =
|
|
151
216
|
* useNavigationOverlay({
|
|
152
217
|
* navigation: schema.navigation,
|
|
153
|
-
* objectName: schema
|
|
218
|
+
* objectName: resolveRecordSourceObjectName(schema, dataConfig),
|
|
154
219
|
* onNavigate: schema.onNavigate,
|
|
155
220
|
* onRowClick: props.onRowClick,
|
|
156
221
|
* });
|