@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.
Files changed (103) hide show
  1. package/CHANGELOG.md +4100 -0
  2. package/README.md +303 -46
  3. package/dist/LazyPluginLoader.d.ts +14 -7
  4. package/dist/LazyPluginLoader.js +14 -7
  5. package/dist/SchemaRenderer.d.ts +38 -7
  6. package/dist/SchemaRenderer.js +1603 -60
  7. package/dist/context/ActionContext.js +40 -1
  8. package/dist/context/DiscussionContext.d.ts +12 -0
  9. package/dist/context/DiscussionContext.js +2 -0
  10. package/dist/context/DrillNavigationContext.d.ts +13 -0
  11. package/dist/context/HostNavigationContext.d.ts +82 -0
  12. package/dist/context/HostNavigationContext.js +65 -0
  13. package/dist/context/InlineEditContext.d.ts +81 -1
  14. package/dist/context/InlineEditContext.js +8 -2
  15. package/dist/context/RecordContext.d.ts +48 -5
  16. package/dist/context/RecordContext.js +10 -1
  17. package/dist/context/RelatedRecordActionsContext.d.ts +20 -1
  18. package/dist/context/SchemaRendererContext.d.ts +54 -2
  19. package/dist/context/SchemaRendererContext.js +38 -5
  20. package/dist/context/ThemeContext.js +1 -1
  21. package/dist/context/actionRunnerTranslator.d.ts +27 -0
  22. package/dist/context/actionRunnerTranslator.js +37 -0
  23. package/dist/context/index.d.ts +13 -12
  24. package/dist/context/index.js +13 -12
  25. package/dist/element-data-source/ElementDataSourceGate.d.ts +163 -3
  26. package/dist/element-data-source/ElementDataSourceGate.js +407 -15
  27. package/dist/hooks/index.d.ts +37 -35
  28. package/dist/hooks/index.js +44 -35
  29. package/dist/hooks/useActionEngine.js +47 -4
  30. package/dist/hooks/useActionRunner.js +4 -0
  31. package/dist/hooks/useActionTextLocalizer.d.ts +21 -8
  32. package/dist/hooks/useActionTextLocalizer.js +33 -8
  33. package/dist/hooks/useCapabilityGate.js +6 -5
  34. package/dist/hooks/useConfigBagEvaluator.d.ts +30 -0
  35. package/dist/hooks/useConfigBagEvaluator.js +39 -0
  36. package/dist/hooks/useCrudShortcuts.js +1 -1
  37. package/dist/hooks/useDatasetDimensionLabels.js +1 -1
  38. package/dist/hooks/useDensityMode.d.ts +6 -2
  39. package/dist/hooks/useDensityMode.js +6 -2
  40. package/dist/hooks/useDiscovery.d.ts +1 -10
  41. package/dist/hooks/useDiscovery.js +1 -1
  42. package/dist/hooks/useETagCache.js +25 -4
  43. package/dist/hooks/useElementDataSource.d.ts +15 -2
  44. package/dist/hooks/useElementDataSource.js +29 -9
  45. package/dist/hooks/useExpression.d.ts +52 -38
  46. package/dist/hooks/useExpression.js +62 -42
  47. package/dist/hooks/useFilterScope.d.ts +22 -2
  48. package/dist/hooks/useFilterScope.js +29 -4
  49. package/dist/hooks/useGlobalUndo.d.ts +10 -0
  50. package/dist/hooks/useGlobalUndo.js +112 -46
  51. package/dist/hooks/useNavigationOverlay.d.ts +77 -12
  52. package/dist/hooks/useNavigationOverlay.js +119 -9
  53. package/dist/hooks/useOffline.js +65 -11
  54. package/dist/hooks/usePageAssignment.d.ts +8 -5
  55. package/dist/hooks/usePageAssignment.js +38 -17
  56. package/dist/hooks/usePageTransition.js +1 -1
  57. package/dist/hooks/usePageVariableActionBridge.js +2 -2
  58. package/dist/hooks/useRecordSearch.d.ts +34 -1
  59. package/dist/hooks/useRecordSearch.js +42 -7
  60. package/dist/hooks/useResolvedFilter.d.ts +46 -0
  61. package/dist/hooks/useResolvedFilter.js +130 -0
  62. package/dist/hooks/useSchemaPersistence.d.ts +7 -0
  63. package/dist/hooks/useSchemaPersistence.js +144 -5
  64. package/dist/hooks/useSettledSchema.d.ts +123 -0
  65. package/dist/hooks/useSettledSchema.js +186 -0
  66. package/dist/hooks/useViewData.d.ts +7 -2
  67. package/dist/hooks/useViewData.js +1 -1
  68. package/dist/hooks/useViewTransition.js +1 -1
  69. package/dist/index.d.ts +18 -11
  70. package/dist/index.js +54 -10
  71. package/dist/schema-input.d.ts +55 -14
  72. package/dist/schema-input.js +55 -12
  73. package/dist/utils/configBag.d.ts +68 -0
  74. package/dist/utils/configBag.js +70 -0
  75. package/dist/utils/error-message.d.ts +59 -0
  76. package/dist/utils/error-message.js +116 -1
  77. package/dist/utils/inlineAria.d.ts +61 -0
  78. package/dist/utils/inlineAria.js +70 -0
  79. package/dist/utils/nonGridRowCeiling.d.ts +44 -0
  80. package/dist/utils/nonGridRowCeiling.js +78 -0
  81. package/dist/utils/paramsBag.d.ts +28 -0
  82. package/dist/utils/paramsBag.js +146 -0
  83. package/dist/utils/propsBagDiagnostic.d.ts +196 -0
  84. package/dist/utils/propsBagDiagnostic.js +421 -0
  85. package/dist/utils/refusedDataPropDiagnostic.d.ts +44 -0
  86. package/dist/utils/refusedDataPropDiagnostic.js +94 -0
  87. package/dist/utils/unevaluatedExpression.d.ts +110 -0
  88. package/dist/utils/unevaluatedExpression.js +257 -0
  89. package/dist/utils/visibilityDiagnostic.d.ts +371 -0
  90. package/dist/utils/visibilityDiagnostic.js +551 -0
  91. package/package.json +10 -10
  92. package/dist/hooks/useClientNotifications.d.ts +0 -47
  93. package/dist/hooks/useClientNotifications.js +0 -162
  94. package/dist/spec-bridge/SpecBridge.d.ts +0 -28
  95. package/dist/spec-bridge/SpecBridge.js +0 -56
  96. package/dist/spec-bridge/bridges/form-view.d.ts +0 -82
  97. package/dist/spec-bridge/bridges/form-view.js +0 -126
  98. package/dist/spec-bridge/bridges/list-view.d.ts +0 -20
  99. package/dist/spec-bridge/bridges/list-view.js +0 -241
  100. package/dist/spec-bridge/index.d.ts +0 -11
  101. package/dist/spec-bridge/index.js +0 -10
  102. package/dist/spec-bridge/types.d.ts +0 -55
  103. 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 the THREE ways the platform's row surfaces bind it:
64
- * `record.status` (spec/canonical), bare `status` (row-action shorthand), and
65
- * `data.status` (legacy). This is not three dialects; it is one rule, and it is
66
- * `evalRowPredicate`'s rule (`core/evaluator/listConditional.ts` — the record
67
- * header, list rows, the row kebab and conditional formatting all evaluate
68
- * through it), restated here for the `useCondition` tier so both tiers answer
69
- * an author's `visible:` the same way.
70
- *
71
- * ## Why a helper and not "just spread the row" (objectui#4075)
72
- *
73
- * `useCondition` evaluates on `new ExpressionEvaluator({ ...scope, ...context })`,
74
- * so a caller that passes the row spread flat resolves the shorthand spelling
75
- * and NOTHING else. The canonical spelling is the `record.` root — it is what
76
- * `ExpressionEvaluator`'s CEL path binds (`bag.record` as the record
77
- * namespace), what `evalRowPredicate` binds, and what the server enforces
78
- * with. Under a root-only bag `record.viewer.can_act` does not read as `false`:
79
- * the evaluator throws `record is not defined`, and a fail-closed `visible`
80
- * turns that throw into "hidden". A correctly-authored predicate then deletes
81
- * its own button, indistinguishably from the gate having said no.
82
- *
83
- * That was live, not theoretical: every declared action on framework's
84
- * `sys_approval_request` gates on `record.viewer.*` (framework#3310 / #3424),
85
- * so the whole server-declared approval decision set was invisible wherever
86
- * `DeclaredActionsBar` rendered until objectui#4077 bound the row this way —
87
- * and the four generic action renderers carried the same root-only binding
88
- * until objectui#4075.
89
- *
90
- * `record` and `data` are written AFTER the spread deliberately: a row that
91
- * happens to carry a field literally named `record` or `data` must not shadow
92
- * the root every predicate is written against. Same precedence as
93
- * `evalRowPredicate`.
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: {}, data: {} }`. The difference is load-bearing: `useCondition`
106
- * merges this context OVER the ambient predicate scope, so binding an empty
107
- * record would blank out a `record` that a host had put in the scope itself —
108
- * which is exactly how `action:group`'s dropdown leaf is driven in
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.*` and
144
- * bare fields; the ambient predicate scope (`features` / `user` / …) is merged
145
- * alongside so deployment-level gates keep resolving.
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`, `app`, …) without needing
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 the THREE ways the platform's row surfaces bind it:
78
- * `record.status` (spec/canonical), bare `status` (row-action shorthand), and
79
- * `data.status` (legacy). This is not three dialects; it is one rule, and it is
80
- * `evalRowPredicate`'s rule (`core/evaluator/listConditional.ts` — the record
81
- * header, list rows, the row kebab and conditional formatting all evaluate
82
- * through it), restated here for the `useCondition` tier so both tiers answer
83
- * an author's `visible:` the same way.
84
- *
85
- * ## Why a helper and not "just spread the row" (objectui#4075)
86
- *
87
- * `useCondition` evaluates on `new ExpressionEvaluator({ ...scope, ...context })`,
88
- * so a caller that passes the row spread flat resolves the shorthand spelling
89
- * and NOTHING else. The canonical spelling is the `record.` root — it is what
90
- * `ExpressionEvaluator`'s CEL path binds (`bag.record` as the record
91
- * namespace), what `evalRowPredicate` binds, and what the server enforces
92
- * with. Under a root-only bag `record.viewer.can_act` does not read as `false`:
93
- * the evaluator throws `record is not defined`, and a fail-closed `visible`
94
- * turns that throw into "hidden". A correctly-authored predicate then deletes
95
- * its own button, indistinguishably from the gate having said no.
96
- *
97
- * That was live, not theoretical: every declared action on framework's
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.
103
- *
104
- * `record` and `data` are written AFTER the spread deliberately: a row that
105
- * happens to carry a field literally named `record` or `data` must not shadow
106
- * the root every predicate is written against. Same precedence as
107
- * `evalRowPredicate`.
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: {}, data: {} }`. The difference is load-bearing: `useCondition`
120
- * merges this context OVER the ambient predicate scope, so binding an empty
121
- * record would blank out a `record` that a host had put in the scope itself —
122
- * which is exactly how `action:group`'s dropdown leaf is driven in
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
- const row = record;
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.*` and
210
- * bare fields; the ambient predicate scope (`features` / `user` / …) is merged
211
- * alongside so deployment-level gates keep 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.
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 session scope for filter placeholder resolution.
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}` (framework #3574).
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 session scope for filter placeholder resolution.
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
- return useContext(FilterScopeContext);
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
- optionsRef.current = options;
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 executeOp = useCallback(async (op, data, mode) => {
42
- const ds = optionsRef.current.dataSource;
43
- if (!ds)
44
- return;
45
- const action = mode === 'undo'
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
  }