@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
|
@@ -60,37 +60,49 @@ export { toPredicateInput } from '@object-ui/core';
|
|
|
60
60
|
* Build the predicate context for a **row record** — the bag to hand
|
|
61
61
|
* {@link useCondition} when the thing being gated is scoped to one record.
|
|
62
62
|
*
|
|
63
|
-
* The row is bound
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
* ##
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
* `
|
|
85
|
-
*
|
|
86
|
-
* `
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
63
|
+
* The row is bound ONE way: `record.status` — the canon (objectui#5330, ruled
|
|
64
|
+
* 2026-08-20; Phase 2 executed by `83fe6e741`). This is not a dialect choice;
|
|
65
|
+
* it is one rule, and it is `evalRowPredicate`'s rule
|
|
66
|
+
* (`core/evaluator/listConditional.ts` — the record header, list rows, the row
|
|
67
|
+
* kebab and conditional formatting all evaluate through it), restated here for
|
|
68
|
+
* the `useCondition` tier so both tiers answer an author's `visible:` the same
|
|
69
|
+
* way.
|
|
70
|
+
*
|
|
71
|
+
* ## The two retired spellings (`83fe6e741`)
|
|
72
|
+
*
|
|
73
|
+
* Until Phase 2 this bag also carried the row spread flat (bare `status`, the
|
|
74
|
+
* row-action shorthand) and as `data` (legacy), and `useCondition` below warned
|
|
75
|
+
* once per non-canonical spelling (Phase 1, `d1ab06f0f`). Both bindings and the
|
|
76
|
+
* warning are gone. A bare-field or `data.*` predicate against this bag now
|
|
77
|
+
* faults exactly as it always did on the server (`buildScope({ record })`
|
|
78
|
+
* mounts exactly `['record']`: `Unknown variable: status` / `Unknown variable:
|
|
79
|
+
* data`), and each `useCondition` leg applies its EXISTING fault policy — the
|
|
80
|
+
* throwing legs (`throwOnError`: every action `visible` — `action:button`,
|
|
81
|
+
* `action:menu`, `action:bar`, `DeclaredActionsBar`, and since objectui#11212
|
|
82
|
+
* `action:group`, `action:icon` and the related-list toolbar) hide and report
|
|
83
|
+
* `was hidden/disabled: its predicate threw`, naming the variable; the
|
|
84
|
+
* non-throwing legs fail soft to `true`. The same holds for a legacy `${data.x}` / `${x}` string: one bag
|
|
85
|
+
* shape, both dialects. Nothing detects a retired spelling here; it is simply
|
|
86
|
+
* unbound. `@object-ui/core`'s `evaluator/rowPredicateCanon.ts` carries the
|
|
87
|
+
* canon statement, the server measurement, the layer scoping (`data` stays
|
|
88
|
+
* canonical one layer over, in a metadata-editing form — ADR-0089 D3) and the
|
|
89
|
+
* offline detector.
|
|
90
|
+
*
|
|
91
|
+
* ## Why a helper and not "just `{ record }`" (objectui#4075 / #4080)
|
|
92
|
+
*
|
|
93
|
+
* Because the rule has two halves and every predicate face has to get both:
|
|
94
|
+
* the SHAPE — `record` is the row's one name, so `record.viewer.can_act`, what
|
|
95
|
+
* every declared action on framework's `sys_approval_request` gates on
|
|
96
|
+
* (framework#3310 / objectstack-ai/objectstack#3424), reaches the row — and the NO-ROW case below.
|
|
97
|
+
* `DeclaredActionsBar` and the four generic action renderers once carried
|
|
98
|
+
* inline copies of this bag and drifted (objectui#4077 / #4079); one named
|
|
99
|
+
* helper is what keeps a fifth copy from drifting again.
|
|
100
|
+
*
|
|
101
|
+
* Note the direction of the merge: `useCondition` evaluates on
|
|
102
|
+
* `new ExpressionEvaluator({ ...scope, ...context })`, so this bag's `record`
|
|
103
|
+
* shadows an ambient `record` a host put in the predicate scope — the row is
|
|
104
|
+
* the subject — while every OTHER ambient key (`features`, `user`, a host's own
|
|
105
|
+
* `data`) survives to the predicate as the host's own.
|
|
94
106
|
*
|
|
95
107
|
* This is the BINDING rule only. It deliberately does not touch the evaluation
|
|
96
108
|
* entry — `useCondition` / `toPredicateInput` / `hasDeclaredVisibilityGate`
|
|
@@ -102,10 +114,10 @@ export { toPredicateInput } from '@object-ui/core';
|
|
|
102
114
|
* ## No row → bind NOTHING, do not bind an empty one
|
|
103
115
|
*
|
|
104
116
|
* `null` / `undefined` / a non-object returns an EMPTY bag, not
|
|
105
|
-
* `{ record: {}
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
117
|
+
* `{ record: {} }`. The difference is load-bearing: `useCondition` merges this
|
|
118
|
+
* context OVER the ambient predicate scope, so binding an empty record would
|
|
119
|
+
* blank out a `record` that a host had put in the scope itself — which is
|
|
120
|
+
* exactly how `action:group`'s dropdown leaf is driven in
|
|
109
121
|
* `action-group-dropdown-visible.test.tsx`, and it is a legitimate way for a
|
|
110
122
|
* host to supply the row. "This surface has no row of its own" must stay
|
|
111
123
|
* distinct from "this surface's row is empty"; only the latter is entitled to
|
|
@@ -140,9 +152,11 @@ export declare function useCondition(condition: string | boolean | undefined | {
|
|
|
140
152
|
* routes to `@object-ui/core`'s `evalRowPredicate`: a bare string is CEL (the
|
|
141
153
|
* spec contract for `ActionSchema.visible`), a `{ dialect: 'cel', source }`
|
|
142
154
|
* envelope is always CEL, and only a legacy-dialect string falls back to the
|
|
143
|
-
* old engine (with a deprecation warning). The row is bound as `record.*`
|
|
144
|
-
*
|
|
145
|
-
*
|
|
155
|
+
* old engine (with a deprecation warning). The row is bound as `record.*` only
|
|
156
|
+
* — the canon (objectui#5330; the bare-field and `data.*` spellings retired in
|
|
157
|
+
* `83fe6e741` and fault here like any unknown variable); the ambient
|
|
158
|
+
* predicate scope (`features` / `user` / …) is merged alongside so
|
|
159
|
+
* deployment-level gates keep resolving.
|
|
146
160
|
*
|
|
147
161
|
* @param pred The raw predicate: `boolean` (returned as-is), a CEL string,
|
|
148
162
|
* an `{ dialect, source }` envelope, or `null`/`undefined`/`''`.
|
|
@@ -10,8 +10,10 @@ 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,37 +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
|
-
*
|
|
84
|
-
*
|
|
85
|
-
* ##
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
* `
|
|
99
|
-
*
|
|
100
|
-
* `
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
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.
|
|
86
|
+
*
|
|
87
|
+
* ## The two retired spellings (`83fe6e741`)
|
|
88
|
+
*
|
|
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.
|
|
106
|
+
*
|
|
107
|
+
* ## Why a helper and not "just `{ record }`" (objectui#4075 / #4080)
|
|
108
|
+
*
|
|
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.
|
|
116
|
+
*
|
|
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.
|
|
108
122
|
*
|
|
109
123
|
* This is the BINDING rule only. It deliberately does not touch the evaluation
|
|
110
124
|
* entry — `useCondition` / `toPredicateInput` / `hasDeclaredVisibilityGate`
|
|
@@ -116,10 +130,10 @@ export { toPredicateInput } from '@object-ui/core';
|
|
|
116
130
|
* ## No row → bind NOTHING, do not bind an empty one
|
|
117
131
|
*
|
|
118
132
|
* `null` / `undefined` / a non-object returns an EMPTY bag, not
|
|
119
|
-
* `{ record: {}
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
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
|
|
123
137
|
* `action-group-dropdown-visible.test.tsx`, and it is a legitimate way for a
|
|
124
138
|
* host to supply the row. "This surface has no row of its own" must stay
|
|
125
139
|
* distinct from "this surface's row is empty"; only the latter is entitled to
|
|
@@ -131,8 +145,7 @@ export function usePredicateRecordContext(record) {
|
|
|
131
145
|
return useMemo(() => {
|
|
132
146
|
if (record == null || typeof record !== 'object' || Array.isArray(record))
|
|
133
147
|
return {};
|
|
134
|
-
|
|
135
|
-
return { ...row, record: row, data: row };
|
|
148
|
+
return { record: record };
|
|
136
149
|
}, [record]);
|
|
137
150
|
}
|
|
138
151
|
/**
|
|
@@ -169,6 +182,11 @@ export function useCondition(condition, context = {}, options) {
|
|
|
169
182
|
const scope = usePredicateScope();
|
|
170
183
|
// We evaluate directly without caching the evaluator to avoid issues with context changes
|
|
171
184
|
return useMemo(() => {
|
|
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.
|
|
172
190
|
const evaluator = new ExpressionEvaluator({ ...scope, ...context });
|
|
173
191
|
if (options?.throwOnError) {
|
|
174
192
|
// Fail-closed: a predicate that can't be evaluated hides/disables
|
|
@@ -206,9 +224,11 @@ export function useCondition(condition, context = {}, options) {
|
|
|
206
224
|
* routes to `@object-ui/core`'s `evalRowPredicate`: a bare string is CEL (the
|
|
207
225
|
* spec contract for `ActionSchema.visible`), a `{ dialect: 'cel', source }`
|
|
208
226
|
* envelope is always CEL, and only a legacy-dialect string falls back to the
|
|
209
|
-
* old engine (with a deprecation warning). The row is bound as `record.*`
|
|
210
|
-
*
|
|
211
|
-
*
|
|
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.
|
|
212
232
|
*
|
|
213
233
|
* @param pred The raw predicate: `boolean` (returned as-is), a CEL string,
|
|
214
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
|
}
|