@object-ui/react 16.1.0 → 17.2.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 +790 -1
- package/README.md +41 -0
- package/dist/SchemaRenderer.js +12 -2
- package/dist/context/AppShellContext.js +51 -23
- package/dist/context/InlineEditContext.d.ts +64 -0
- package/dist/context/InlineEditContext.js +8 -2
- package/dist/context/NotificationContext.d.ts +245 -17
- package/dist/context/NotificationContext.js +222 -20
- package/dist/context/ThemeContext.js +4 -8
- package/dist/hooks/index.d.ts +2 -1
- package/dist/hooks/index.js +3 -1
- package/dist/hooks/useAnimation.d.ts +40 -4
- package/dist/hooks/useAnimation.js +41 -10
- package/dist/hooks/useCapabilityGate.d.ts +16 -0
- package/dist/hooks/useCapabilityGate.js +60 -0
- package/dist/hooks/useClientNotifications.d.ts +0 -6
- package/dist/hooks/useClientNotifications.js +7 -53
- package/dist/hooks/useFilterScope.d.ts +30 -0
- package/dist/hooks/useFilterScope.js +55 -0
- package/dist/hooks/useNavigationOverlay.d.ts +34 -10
- package/dist/hooks/useNavigationOverlay.js +35 -1
- package/dist/hooks/useOffline.d.ts +51 -33
- package/dist/hooks/useOffline.js +3 -3
- package/dist/hooks/usePageVariables.js +23 -11
- package/dist/hooks/usePerformance.d.ts +3 -3
- package/dist/hooks/usePerformance.js +2 -2
- package/dist/hooks/useRecordSearch.js +98 -4
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/spec-bridge/SpecBridge.d.ts +0 -4
- package/dist/spec-bridge/SpecBridge.js +0 -12
- package/dist/spec-bridge/bridges/list-view.js +5 -11
- package/dist/spec-bridge/index.d.ts +0 -2
- package/dist/spec-bridge/index.js +0 -2
- package/dist/spec-input.d.ts +37 -0
- package/dist/spec-input.js +8 -0
- package/dist/utils/error-message.d.ts +31 -0
- package/dist/utils/error-message.js +49 -0
- package/package.json +8 -8
- package/dist/hooks/useColumnSummary.d.ts +0 -38
- package/dist/hooks/useColumnSummary.js +0 -54
- package/dist/spec-bridge/bridges/dashboard.d.ts +0 -81
- package/dist/spec-bridge/bridges/dashboard.js +0 -121
- package/dist/spec-bridge/bridges/page.d.ts +0 -58
- package/dist/spec-bridge/bridges/page.js +0 -101
|
@@ -13,55 +13,192 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
13
13
|
* Implements NotificationSchema, NotificationConfigSchema, and
|
|
14
14
|
* NotificationActionSchema from @objectstack/spec v2.0.7.
|
|
15
15
|
*/
|
|
16
|
-
import { createContext, useCallback, useContext, useMemo, useState } from 'react';
|
|
16
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react';
|
|
17
|
+
/**
|
|
18
|
+
* The presentation table — one entry per spec `NotificationTypeSchema` member.
|
|
19
|
+
*
|
|
20
|
+
* Typed as `Record<NotificationPresentation, …>`, so adding a member to the
|
|
21
|
+
* spec enum fails type-check here until its presentation is decided, rather
|
|
22
|
+
* than silently falling back to a toast.
|
|
23
|
+
*/
|
|
24
|
+
export const NOTIFICATION_PRESENTATIONS = {
|
|
25
|
+
toast: { surface: 'delegate', transient: true },
|
|
26
|
+
snackbar: { surface: 'snackbar', transient: true },
|
|
27
|
+
banner: { surface: 'banner', transient: false },
|
|
28
|
+
alert: { surface: 'alert', transient: false },
|
|
29
|
+
inline: { surface: 'inline', transient: false },
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Resolve an authored `displayType` to the presentation that renders it:
|
|
33
|
+
* the spec default (`toast`) when absent, and `alert` for the deprecated
|
|
34
|
+
* `modal` dialect.
|
|
35
|
+
*/
|
|
36
|
+
export function resolveNotificationPresentation(displayType) {
|
|
37
|
+
if (displayType === 'modal')
|
|
38
|
+
return 'alert';
|
|
39
|
+
if (displayType && displayType in NOTIFICATION_PRESENTATIONS)
|
|
40
|
+
return displayType;
|
|
41
|
+
return 'toast';
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The spec vocabularies this context implements — exported for the parity
|
|
45
|
+
* tests (#2942), which fail the moment `NotificationTypeSchema` /
|
|
46
|
+
* `NotificationPositionSchema` and these sets drift in either direction.
|
|
47
|
+
*/
|
|
48
|
+
export const SUPPORTED_NOTIFICATION_DISPLAY_TYPES = new Set(Object.keys(NOTIFICATION_PRESENTATIONS));
|
|
49
|
+
export const SUPPORTED_NOTIFICATION_POSITIONS = new Set([
|
|
50
|
+
'top_left', 'top_center', 'top_right', 'bottom_left', 'bottom_center', 'bottom_right',
|
|
51
|
+
]);
|
|
52
|
+
/** The spec vocabulary this context implements — exported for the parity test. */
|
|
53
|
+
export const SUPPORTED_NOTIFICATION_ACTION_VARIANTS = new Set([
|
|
54
|
+
'primary', 'secondary', 'link',
|
|
55
|
+
]);
|
|
56
|
+
/**
|
|
57
|
+
* Fold the deprecated spellings into the spec ones and apply the spec defaults
|
|
58
|
+
* (`NotificationConfigSchema`: 5000ms / 5 visible / stack down / pause on
|
|
59
|
+
* hover). `defaultPosition` gets no fabricated default — see the field docs.
|
|
60
|
+
*/
|
|
61
|
+
export function resolveNotificationConfig(config = {}) {
|
|
62
|
+
return {
|
|
63
|
+
defaultPosition: config.defaultPosition ?? config.position,
|
|
64
|
+
defaultDuration: config.defaultDuration ?? 5000,
|
|
65
|
+
// `stacking: false` means "don't stack" — one visible item.
|
|
66
|
+
maxVisible: config.stacking === false ? 1 : config.maxVisible ?? 5,
|
|
67
|
+
stackDirection: config.stackDirection ?? 'down',
|
|
68
|
+
pauseOnHover: config.pauseOnHover ?? true,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
17
71
|
let notificationCounter = 0;
|
|
72
|
+
/** Dev-build check — same idiom as `SchemaRenderer`'s `__DEV__`. */
|
|
73
|
+
const __DEV__ = (() => {
|
|
74
|
+
try {
|
|
75
|
+
return globalThis.process?.env?.NODE_ENV
|
|
76
|
+
!== 'production';
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
})();
|
|
82
|
+
/** Dev-only guidance for a presentation raised with no surface mounted. */
|
|
83
|
+
const SURFACE_HINTS = {
|
|
84
|
+
delegate: 'pass `onToast` to <NotificationProvider>',
|
|
85
|
+
snackbar: 'mount <NotificationSnackbar /> from @object-ui/components',
|
|
86
|
+
banner: 'mount <NotificationBanners /> from @object-ui/components',
|
|
87
|
+
alert: 'mount <NotificationAlerts /> from @object-ui/components',
|
|
88
|
+
inline: 'mount <NotificationInline /> from @object-ui/components at the raising surface',
|
|
89
|
+
};
|
|
18
90
|
const NotificationCtx = createContext(null);
|
|
19
91
|
/**
|
|
20
92
|
* NotificationProvider — Provides a spec-driven notification system.
|
|
21
93
|
*
|
|
94
|
+
* Each spec display type is presented by its own surface. `toast` is delegated
|
|
95
|
+
* to the host (`onToast`); the other four are rendered by surface components
|
|
96
|
+
* that subscribe to this context — mount them where they belong:
|
|
97
|
+
* `NotificationSnackbar` / `NotificationAlerts` anywhere inside the provider,
|
|
98
|
+
* `NotificationBanners` at the top of the content area, and
|
|
99
|
+
* `NotificationInline` in the surface that raises the notification.
|
|
100
|
+
*
|
|
22
101
|
* @example
|
|
23
102
|
* ```tsx
|
|
24
103
|
* <NotificationProvider
|
|
25
|
-
* config={{ position: '
|
|
104
|
+
* config={{ position: 'top_right', defaultDuration: 5000, maxVisible: 5 }}
|
|
26
105
|
* onToast={(n) => toast[n.severity](n.title, { description: n.message })}
|
|
27
106
|
* >
|
|
107
|
+
* <NotificationBanners />
|
|
28
108
|
* <App />
|
|
109
|
+
* <NotificationSnackbar />
|
|
110
|
+
* <NotificationAlerts />
|
|
29
111
|
* </NotificationProvider>
|
|
30
112
|
* ```
|
|
31
113
|
*/
|
|
32
114
|
export const NotificationProvider = ({ children, config: userConfig = {}, onToast, }) => {
|
|
33
|
-
const config = useMemo(() => (
|
|
34
|
-
position: 'top-right',
|
|
35
|
-
defaultDuration: 5000,
|
|
36
|
-
maxVisible: 5,
|
|
37
|
-
stacking: true,
|
|
38
|
-
...userConfig,
|
|
39
|
-
}),
|
|
115
|
+
const config = useMemo(() => resolveNotificationConfig(userConfig),
|
|
40
116
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
41
117
|
[JSON.stringify(userConfig)]);
|
|
42
118
|
const [notifications, setNotifications] = useState([]);
|
|
119
|
+
// Surfaces currently mounted, by presentation surface. A ref (not state) so
|
|
120
|
+
// `notify` can read the live set without re-creating itself on every mount.
|
|
121
|
+
const mountedSurfaces = useRef(new Map());
|
|
122
|
+
// Live auto-dismiss timers, so `pauseOnHover` can hold one and resume it with
|
|
123
|
+
// the time it had left. A plain `setTimeout` could only ever be cancelled,
|
|
124
|
+
// which is why the spec's `pauseOnHover` had nothing to attach to.
|
|
125
|
+
const timers = useRef(new Map());
|
|
126
|
+
const scheduleAutoDismiss = useCallback((id, ms) => {
|
|
127
|
+
const handle = setTimeout(() => {
|
|
128
|
+
timers.current.delete(id);
|
|
129
|
+
setNotifications((prev) => prev.filter((n) => n.id !== id));
|
|
130
|
+
}, ms);
|
|
131
|
+
timers.current.set(id, { handle, endsAt: Date.now() + ms, left: ms });
|
|
132
|
+
}, []);
|
|
133
|
+
const pauseAutoDismiss = useCallback((id) => {
|
|
134
|
+
const timer = timers.current.get(id);
|
|
135
|
+
if (!timer)
|
|
136
|
+
return;
|
|
137
|
+
clearTimeout(timer.handle);
|
|
138
|
+
timers.current.set(id, { ...timer, left: Math.max(0, timer.endsAt - Date.now()) });
|
|
139
|
+
}, []);
|
|
140
|
+
const resumeAutoDismiss = useCallback((id) => {
|
|
141
|
+
const timer = timers.current.get(id);
|
|
142
|
+
if (!timer)
|
|
143
|
+
return;
|
|
144
|
+
clearTimeout(timer.handle);
|
|
145
|
+
scheduleAutoDismiss(id, timer.left);
|
|
146
|
+
}, [scheduleAutoDismiss]);
|
|
147
|
+
// Never leave a timer running past unmount.
|
|
148
|
+
useEffect(() => () => {
|
|
149
|
+
for (const { handle } of timers.current.values())
|
|
150
|
+
clearTimeout(handle);
|
|
151
|
+
timers.current.clear();
|
|
152
|
+
}, []);
|
|
153
|
+
const registerSurface = useCallback((surface) => {
|
|
154
|
+
const counts = mountedSurfaces.current;
|
|
155
|
+
counts.set(surface, (counts.get(surface) ?? 0) + 1);
|
|
156
|
+
return () => {
|
|
157
|
+
const next = (counts.get(surface) ?? 1) - 1;
|
|
158
|
+
if (next > 0)
|
|
159
|
+
counts.set(surface, next);
|
|
160
|
+
else
|
|
161
|
+
counts.delete(surface);
|
|
162
|
+
};
|
|
163
|
+
}, []);
|
|
43
164
|
const notify = useCallback((input) => {
|
|
44
165
|
const id = `notification-${++notificationCounter}`;
|
|
166
|
+
// Materialize the declared presentation (spec default: toast; the legacy
|
|
167
|
+
// `modal` dialect presents as its nearest spec family, alert) so every
|
|
168
|
+
// stored item carries the presentation that will render it (#2942).
|
|
169
|
+
const presentation = resolveNotificationPresentation(input.displayType);
|
|
170
|
+
const { surface, transient } = NOTIFICATION_PRESENTATIONS[presentation];
|
|
45
171
|
const notification = {
|
|
46
172
|
...input,
|
|
173
|
+
displayType: presentation,
|
|
47
174
|
id,
|
|
48
175
|
createdAt: new Date(),
|
|
49
176
|
read: false,
|
|
50
177
|
};
|
|
51
178
|
setNotifications((prev) => [notification, ...prev]);
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
179
|
+
// Route to the presentation's surface. `onToast` is the `toast` surface —
|
|
180
|
+
// it used to receive EVERY type, so a banner/alert/inline notification
|
|
181
|
+
// presented as a toast (#3014). The other four are rendered by the
|
|
182
|
+
// surface components subscribing through the context below.
|
|
183
|
+
if (surface === 'delegate')
|
|
184
|
+
onToast?.(notification, config);
|
|
185
|
+
// A surface-rendered presentation with no surface mounted is invisible —
|
|
186
|
+
// the "silently absent" shape this whole issue is about. Say so loudly in
|
|
187
|
+
// dev. `toast` is exempt: a provider with no `onToast` is the supported
|
|
188
|
+
// store-only mode (a notification centre with no overlay).
|
|
189
|
+
if (__DEV__ && surface !== 'delegate' && (mountedSurfaces.current.get(surface) ?? 0) === 0) {
|
|
190
|
+
console.warn(`[NotificationProvider] "${notification.title}" declares displayType ` +
|
|
191
|
+
`'${presentation}' but no ${presentation} surface is mounted, so it will ` +
|
|
192
|
+
`not be shown. To present it, ${SURFACE_HINTS[surface]}.`);
|
|
62
193
|
}
|
|
194
|
+
// Auto-dismiss transient presentations. A banner/alert/inline stays until
|
|
195
|
+
// it is dismissed unless the raiser asked for a duration explicitly —
|
|
196
|
+
// sharing the toast timer made "persistent" presentations evaporate.
|
|
197
|
+
const duration = input.duration ?? (transient ? config.defaultDuration : 0);
|
|
198
|
+
if (duration > 0)
|
|
199
|
+
scheduleAutoDismiss(id, duration);
|
|
63
200
|
return id;
|
|
64
|
-
}, [config
|
|
201
|
+
}, [config, onToast, scheduleAutoDismiss]);
|
|
65
202
|
const info = useCallback((title, message) => notify({ title, message, severity: 'info' }), [notify]);
|
|
66
203
|
const success = useCallback((title, message) => notify({ title, message, severity: 'success' }), [notify]);
|
|
67
204
|
const warning = useCallback((title, message) => notify({ title, message, severity: 'warning' }), [notify]);
|
|
@@ -73,6 +210,13 @@ export const NotificationProvider = ({ children, config: userConfig = {}, onToas
|
|
|
73
210
|
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
|
|
74
211
|
}, []);
|
|
75
212
|
const dismiss = useCallback((id) => {
|
|
213
|
+
// Drop any pending auto-dismiss with it — a held (hovered) timer would
|
|
214
|
+
// otherwise sit in the map until unmount.
|
|
215
|
+
const timer = timers.current.get(id);
|
|
216
|
+
if (timer) {
|
|
217
|
+
clearTimeout(timer.handle);
|
|
218
|
+
timers.current.delete(id);
|
|
219
|
+
}
|
|
76
220
|
setNotifications((prev) => prev.filter((n) => n.id !== id));
|
|
77
221
|
}, []);
|
|
78
222
|
const clearAll = useCallback(() => {
|
|
@@ -92,6 +236,9 @@ export const NotificationProvider = ({ children, config: userConfig = {}, onToas
|
|
|
92
236
|
dismiss,
|
|
93
237
|
clearAll,
|
|
94
238
|
config,
|
|
239
|
+
pauseAutoDismiss,
|
|
240
|
+
resumeAutoDismiss,
|
|
241
|
+
registerSurface,
|
|
95
242
|
}), [
|
|
96
243
|
notifications,
|
|
97
244
|
unreadCount,
|
|
@@ -105,6 +252,9 @@ export const NotificationProvider = ({ children, config: userConfig = {}, onToas
|
|
|
105
252
|
dismiss,
|
|
106
253
|
clearAll,
|
|
107
254
|
config,
|
|
255
|
+
pauseAutoDismiss,
|
|
256
|
+
resumeAutoDismiss,
|
|
257
|
+
registerSurface,
|
|
108
258
|
]);
|
|
109
259
|
return (_jsx(NotificationCtx.Provider, { value: value, children: children }));
|
|
110
260
|
};
|
|
@@ -128,3 +278,55 @@ export function useNotifications() {
|
|
|
128
278
|
export function useHasNotificationProvider() {
|
|
129
279
|
return useContext(NotificationCtx) !== null;
|
|
130
280
|
}
|
|
281
|
+
/**
|
|
282
|
+
* Apply the config's stack rules to a surface's items: keep the newest
|
|
283
|
+
* `maxVisible`, then order them so the stack GROWS in `stackDirection`
|
|
284
|
+
* (`down`, the spec default, puts the newest below the older ones).
|
|
285
|
+
*
|
|
286
|
+
* Shared by the stacking surfaces (banner, inline) so `maxVisible` means one
|
|
287
|
+
* thing. It used to mean nothing at all — the config carried it and no surface
|
|
288
|
+
* read it, while `NotificationBanners` capped at a hard-coded 3 of its own.
|
|
289
|
+
*
|
|
290
|
+
* @param items the surface's notifications, newest-first
|
|
291
|
+
*/
|
|
292
|
+
export function visibleNotificationStack(items, config) {
|
|
293
|
+
// Cap BEFORE ordering — an over-long stack drops its oldest, never its newest.
|
|
294
|
+
const capped = config.maxVisible > 0 ? items.slice(0, config.maxVisible) : items;
|
|
295
|
+
return config.stackDirection === 'up' ? capped : [...capped].reverse();
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* The screen position a FLOATING notification (toast, snackbar) should take:
|
|
299
|
+
* its own `position`, else the configured `defaultPosition`, else `undefined`.
|
|
300
|
+
*
|
|
301
|
+
* `undefined` is a meaningful answer, not a missing one — it means "nothing was
|
|
302
|
+
* declared", and the surface should keep its own anchor (a snackbar's bottom
|
|
303
|
+
* edge) or defer to the host's toast chrome, rather than being moved by a
|
|
304
|
+
* default nobody asked for. Always returns the spec (underscore) spelling.
|
|
305
|
+
*/
|
|
306
|
+
export function resolveNotificationPosition(notification, config) {
|
|
307
|
+
const declared = notification.position ?? config?.defaultPosition;
|
|
308
|
+
if (!declared)
|
|
309
|
+
return undefined;
|
|
310
|
+
const underscored = declared.replace(/-/g, '_');
|
|
311
|
+
return SUPPORTED_NOTIFICATION_POSITIONS.has(underscored) ? underscored : undefined;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Subscribe to the live notifications for ONE presentation — the contract a
|
|
315
|
+
* surface component implements (`@object-ui/components` ships one per spec
|
|
316
|
+
* type). Registering also tells the provider the surface exists, so raising a
|
|
317
|
+
* `banner` with no banner surface mounted warns in dev instead of vanishing.
|
|
318
|
+
*
|
|
319
|
+
* Items come back newest-first, matching `notifications`. A surface that wants
|
|
320
|
+
* FIFO order (an `alert` queue) reverses them itself.
|
|
321
|
+
*
|
|
322
|
+
* @param presentation the spec display type this surface renders
|
|
323
|
+
* @param scope optional `inline` routing key — when set, only items raised with
|
|
324
|
+
* the same `scope` are returned; when omitted, only unscoped items are.
|
|
325
|
+
*/
|
|
326
|
+
export function useNotificationsByPresentation(presentation, scope) {
|
|
327
|
+
const { notifications, registerSurface } = useNotifications();
|
|
328
|
+
const { surface } = NOTIFICATION_PRESENTATIONS[presentation];
|
|
329
|
+
useEffect(() => registerSurface(surface), [registerSurface, surface]);
|
|
330
|
+
return useMemo(() => notifications.filter((n) => resolveNotificationPresentation(n.displayType) === presentation &&
|
|
331
|
+
(presentation !== 'inline' || (n.scope ?? undefined) === scope)), [notifications, presentation, scope]);
|
|
332
|
+
}
|
|
@@ -131,14 +131,10 @@ export function ThemeProvider({ themes: themesInput = [], defaultTheme, defaultM
|
|
|
131
131
|
// Apply mode class
|
|
132
132
|
el.classList.remove('light', 'dark');
|
|
133
133
|
el.classList.add(resolvedMode);
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if (link) {
|
|
139
|
-
link.href = resolvedTheme.logo.favicon;
|
|
140
|
-
}
|
|
141
|
-
}
|
|
134
|
+
// No favicon here: spec v17 (#3494) pruned `Theme.logo`, so the key is
|
|
135
|
+
// stripped at parse and could never arrive. The live favicon comes from
|
|
136
|
+
// operator branding (`getFaviconUrl()`), applied in the console's
|
|
137
|
+
// index.html, main.tsx, and on route change.
|
|
142
138
|
return () => {
|
|
143
139
|
// Cleanup: remove injected vars
|
|
144
140
|
for (const prop of Object.keys(cssVars)) {
|
package/dist/hooks/index.d.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* LICENSE file in the root directory of this source tree.
|
|
7
7
|
*/
|
|
8
8
|
export * from './useExpression';
|
|
9
|
+
export * from './useFilterScope';
|
|
9
10
|
export * from './useActionRunner';
|
|
10
11
|
export * from './useNavigationOverlay';
|
|
11
12
|
export * from './usePageVariables';
|
|
@@ -19,7 +20,6 @@ export * from './useKeyboardShortcuts';
|
|
|
19
20
|
export * from './useCrudShortcuts';
|
|
20
21
|
export * from './useReducedMotion';
|
|
21
22
|
export * from './useAnimation';
|
|
22
|
-
export * from './useColumnSummary';
|
|
23
23
|
export * from './useDensityMode';
|
|
24
24
|
export * from './useViewSharing';
|
|
25
25
|
export * from './useClientNotifications';
|
|
@@ -33,6 +33,7 @@ export * from './useSchemaPersistence';
|
|
|
33
33
|
export * from './useGlobalUndo';
|
|
34
34
|
export * from './useDebugMode';
|
|
35
35
|
export * from './useActionEngine';
|
|
36
|
+
export * from './useCapabilityGate';
|
|
36
37
|
export * from './useDataRefresh';
|
|
37
38
|
export * from './usePageAssignment';
|
|
38
39
|
export * from './useRecordSearch';
|
package/dist/hooks/index.js
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* LICENSE file in the root directory of this source tree.
|
|
7
7
|
*/
|
|
8
8
|
export * from './useExpression';
|
|
9
|
+
// Session scope for filter placeholders ({current_user_id}/{current_org_id}).
|
|
10
|
+
export * from './useFilterScope';
|
|
9
11
|
export * from './useActionRunner';
|
|
10
12
|
export * from './useNavigationOverlay';
|
|
11
13
|
export * from './usePageVariables';
|
|
@@ -19,7 +21,6 @@ export * from './useKeyboardShortcuts';
|
|
|
19
21
|
export * from './useCrudShortcuts';
|
|
20
22
|
export * from './useReducedMotion';
|
|
21
23
|
export * from './useAnimation';
|
|
22
|
-
export * from './useColumnSummary';
|
|
23
24
|
export * from './useDensityMode';
|
|
24
25
|
export * from './useViewSharing';
|
|
25
26
|
export * from './useClientNotifications';
|
|
@@ -33,6 +34,7 @@ export * from './useSchemaPersistence';
|
|
|
33
34
|
export * from './useGlobalUndo';
|
|
34
35
|
export * from './useDebugMode';
|
|
35
36
|
export * from './useActionEngine';
|
|
37
|
+
export * from './useCapabilityGate';
|
|
36
38
|
export * from './useDataRefresh';
|
|
37
39
|
export * from './usePageAssignment';
|
|
38
40
|
export * from './useRecordSearch';
|
|
@@ -7,10 +7,39 @@
|
|
|
7
7
|
*/
|
|
8
8
|
/** Animation trigger type */
|
|
9
9
|
export type AnimationTriggerType = 'enter' | 'exit' | 'hover' | 'focus' | 'click';
|
|
10
|
-
/**
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Easing presets aligned with the spec `EasingFunctionSchema`
|
|
12
|
+
* (`ui/animation.zod.ts` — underscore spellings). The hyphen spellings are
|
|
13
|
+
* this hook's historical dialect, still accepted at runtime (normalized
|
|
14
|
+
* before lookup) so existing configs keep animating.
|
|
15
|
+
*/
|
|
16
|
+
export type EasingPreset = 'linear' | 'ease' | 'ease_in' | 'ease_out' | 'ease_in_out' | 'spring'
|
|
17
|
+
/** @deprecated legacy spelling — use `ease_in` */
|
|
18
|
+
| 'ease-in'
|
|
19
|
+
/** @deprecated legacy spelling — use `ease_out` */
|
|
20
|
+
| 'ease-out'
|
|
21
|
+
/** @deprecated legacy spelling — use `ease_in_out` */
|
|
22
|
+
| 'ease-in-out';
|
|
23
|
+
/**
|
|
24
|
+
* Transition presets aligned with the spec `TransitionPresetSchema`
|
|
25
|
+
* (`ui/animation.zod.ts` — underscore spellings, including `rotate` and
|
|
26
|
+
* `flip`). The hyphen spellings and `scale-fade` are this hook's historical
|
|
27
|
+
* dialect, still accepted at runtime so existing configs keep animating —
|
|
28
|
+
* but the spec's own spellings used to look up NOTHING here (#2942): the
|
|
29
|
+
* map was keyed in hyphens, so `preset: 'slide_up'` validated and rendered
|
|
30
|
+
* no animation at all.
|
|
31
|
+
*/
|
|
32
|
+
export type TransitionPresetType = 'fade' | 'slide_up' | 'slide_down' | 'slide_left' | 'slide_right' | 'scale' | 'rotate' | 'flip' | 'none'
|
|
33
|
+
/** @deprecated legacy spelling — use `slide_up` */
|
|
34
|
+
| 'slide-up'
|
|
35
|
+
/** @deprecated legacy spelling — use `slide_down` */
|
|
36
|
+
| 'slide-down'
|
|
37
|
+
/** @deprecated legacy spelling — use `slide_left` */
|
|
38
|
+
| 'slide-left'
|
|
39
|
+
/** @deprecated legacy spelling — use `slide_right` */
|
|
40
|
+
| 'slide-right'
|
|
41
|
+
/** @deprecated legacy dialect (never in the spec) — use `flip` */
|
|
42
|
+
| 'scale-fade';
|
|
14
43
|
export interface AnimationConfig {
|
|
15
44
|
/** The animation/transition preset */
|
|
16
45
|
preset?: TransitionPresetType;
|
|
@@ -30,6 +59,13 @@ export interface AnimationResult {
|
|
|
30
59
|
/** Inline style for custom durations/delays (only when needed) */
|
|
31
60
|
style: React.CSSProperties;
|
|
32
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* The spec vocabularies this hook implements — exported for the parity tests,
|
|
64
|
+
* which fail the moment `TransitionPresetSchema` / `EasingFunctionSchema` and
|
|
65
|
+
* these sets drift in either direction (#2942).
|
|
66
|
+
*/
|
|
67
|
+
export declare const SUPPORTED_TRANSITION_PRESETS: ReadonlySet<string>;
|
|
68
|
+
export declare const SUPPORTED_EASING_FUNCTIONS: ReadonlySet<string>;
|
|
33
69
|
/**
|
|
34
70
|
* Hook for applying spec-driven animations to components.
|
|
35
71
|
* Implements ComponentAnimationSchema and TransitionPresetSchema
|
|
@@ -6,22 +6,48 @@
|
|
|
6
6
|
* LICENSE file in the root directory of this source tree.
|
|
7
7
|
*/
|
|
8
8
|
import { useMemo } from 'react';
|
|
9
|
+
/**
|
|
10
|
+
* The spec vocabularies this hook implements — exported for the parity tests,
|
|
11
|
+
* which fail the moment `TransitionPresetSchema` / `EasingFunctionSchema` and
|
|
12
|
+
* these sets drift in either direction (#2942).
|
|
13
|
+
*/
|
|
14
|
+
export const SUPPORTED_TRANSITION_PRESETS = new Set([
|
|
15
|
+
'fade', 'slide_up', 'slide_down', 'slide_left', 'slide_right', 'scale', 'rotate', 'flip', 'none',
|
|
16
|
+
]);
|
|
17
|
+
export const SUPPORTED_EASING_FUNCTIONS = new Set([
|
|
18
|
+
'linear', 'ease', 'ease_in', 'ease_out', 'ease_in_out', 'spring',
|
|
19
|
+
]);
|
|
20
|
+
/**
|
|
21
|
+
* Normalize a legacy hyphen spelling (this hook's pre-#2942 dialect) onto the
|
|
22
|
+
* spec's underscore vocabulary. `scale-fade` never existed in the spec; its
|
|
23
|
+
* classes were identical to `flip`'s, so it maps there.
|
|
24
|
+
*/
|
|
25
|
+
function normalizePreset(preset) {
|
|
26
|
+
if (preset === 'scale-fade')
|
|
27
|
+
return 'flip';
|
|
28
|
+
return preset.replace(/-/g, '_');
|
|
29
|
+
}
|
|
30
|
+
// Keyed by the SPEC spellings (`ui/animation.zod.ts`). Class values for the
|
|
31
|
+
// shared presets are unchanged; `rotate`/`flip` follow `usePageTransition`'s
|
|
32
|
+
// enter classes (spin-in / fade+zoom), the sibling hook that always carried
|
|
33
|
+
// the full spec vocabulary.
|
|
9
34
|
const EASING_MAP = {
|
|
10
35
|
linear: 'linear',
|
|
11
36
|
ease: 'ease',
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
37
|
+
ease_in: 'ease-in',
|
|
38
|
+
ease_out: 'ease-out',
|
|
39
|
+
ease_in_out: 'ease-in-out',
|
|
15
40
|
spring: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
|
|
16
41
|
};
|
|
17
42
|
const PRESET_CLASSES = {
|
|
18
43
|
fade: 'animate-in fade-in',
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
44
|
+
slide_up: 'animate-in slide-in-from-bottom-2',
|
|
45
|
+
slide_down: 'animate-in slide-in-from-top-2',
|
|
46
|
+
slide_left: 'animate-in slide-in-from-right-2',
|
|
47
|
+
slide_right: 'animate-in slide-in-from-left-2',
|
|
23
48
|
scale: 'animate-in zoom-in-95',
|
|
24
|
-
|
|
49
|
+
rotate: 'animate-in spin-in-90',
|
|
50
|
+
flip: 'animate-in fade-in zoom-in-95',
|
|
25
51
|
none: '',
|
|
26
52
|
};
|
|
27
53
|
/**
|
|
@@ -43,7 +69,7 @@ export function useAnimation(config = {}) {
|
|
|
43
69
|
if (!enabled || preset === 'none') {
|
|
44
70
|
return { className: '', style: {} };
|
|
45
71
|
}
|
|
46
|
-
const className = PRESET_CLASSES[preset] || '';
|
|
72
|
+
const className = PRESET_CLASSES[normalizePreset(preset)] || '';
|
|
47
73
|
const style = {};
|
|
48
74
|
if (duration !== undefined) {
|
|
49
75
|
style.animationDuration = `${duration}ms`;
|
|
@@ -52,7 +78,12 @@ export function useAnimation(config = {}) {
|
|
|
52
78
|
style.animationDelay = `${delay}ms`;
|
|
53
79
|
}
|
|
54
80
|
if (easing) {
|
|
55
|
-
|
|
81
|
+
// Spec spellings (and legacy hyphen ones) resolve through the map; an
|
|
82
|
+
// out-of-vocabulary string only passes through when it is plausible raw
|
|
83
|
+
// CSS (`cubic-bezier(…)`, `steps(…)`) — `ease_in_out` reaching the DOM
|
|
84
|
+
// verbatim was an invalid declaration the browser silently dropped.
|
|
85
|
+
style.animationTimingFunction =
|
|
86
|
+
EASING_MAP[normalizePreset(easing)] ?? (easing.includes('(') ? easing : undefined);
|
|
56
87
|
}
|
|
57
88
|
return { className, style };
|
|
58
89
|
}, [preset, duration, delay, easing, enabled]);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* The caller's system capabilities, or `undefined` when the host never supplied
|
|
10
|
+
* them (→ callers must fail open).
|
|
11
|
+
*/
|
|
12
|
+
export declare function useHeldCapabilities(): string[] | undefined;
|
|
13
|
+
/**
|
|
14
|
+
* `(requiredPermissions) => mayInvoke`. Actions declaring nothing always pass.
|
|
15
|
+
*/
|
|
16
|
+
export declare function useCapabilityGate(): (required: unknown) => boolean;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* useCapabilityGate — the UI half of ADR-0066 D4's `requiredPermissions`
|
|
10
|
+
* contract, for surfaces that filter actions THEMSELVES.
|
|
11
|
+
*
|
|
12
|
+
* `ActionEngine.getActionsForLocation` applies this gate for everything routed
|
|
13
|
+
* through the engine. But several renderers filter their own action lists —
|
|
14
|
+
* `page:header` (record_header / record_more) and the grid's row-action menu
|
|
15
|
+
* (list_item) — and each of those bypassed the engine entirely, so a button
|
|
16
|
+
* declaring a capability nobody holds still rendered (framework#3923). One hook
|
|
17
|
+
* so all three surfaces gate identically and can't drift apart.
|
|
18
|
+
*
|
|
19
|
+
* Source of truth for what the caller holds: the ActionProvider's runner
|
|
20
|
+
* context (`user.systemPermissions`) — the same object the engine reads — with
|
|
21
|
+
* the ambient predicate scope as fallback for hosts that mount an
|
|
22
|
+
* ExpressionProvider but no action runtime.
|
|
23
|
+
*
|
|
24
|
+
* **Fail-OPEN when unknown.** No runner, no user, no `systemPermissions` array:
|
|
25
|
+
* the action shows. Unknown is not denied, the server is the authority
|
|
26
|
+
* (ADR-0057 D10), and hiding a permitted user's button on missing client data
|
|
27
|
+
* is the worse failure. An EMPTY array is not unknown — it means "holds
|
|
28
|
+
* nothing" and gates normally.
|
|
29
|
+
*/
|
|
30
|
+
import { useCallback, useContext } from 'react';
|
|
31
|
+
import { ActionCtxReact } from '../context/ActionContext';
|
|
32
|
+
import { usePredicateScope } from './useExpression';
|
|
33
|
+
/**
|
|
34
|
+
* The caller's system capabilities, or `undefined` when the host never supplied
|
|
35
|
+
* them (→ callers must fail open).
|
|
36
|
+
*/
|
|
37
|
+
export function useHeldCapabilities() {
|
|
38
|
+
const actionCtx = useContext(ActionCtxReact);
|
|
39
|
+
const predicateScope = usePredicateScope();
|
|
40
|
+
const runnerUser = actionCtx?.runner?.getContext?.()?.user;
|
|
41
|
+
if (Array.isArray(runnerUser?.systemPermissions))
|
|
42
|
+
return runnerUser.systemPermissions;
|
|
43
|
+
const scopeUser = predicateScope?.user;
|
|
44
|
+
if (Array.isArray(scopeUser?.systemPermissions))
|
|
45
|
+
return scopeUser.systemPermissions;
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* `(requiredPermissions) => mayInvoke`. Actions declaring nothing always pass.
|
|
50
|
+
*/
|
|
51
|
+
export function useCapabilityGate() {
|
|
52
|
+
const held = useHeldCapabilities();
|
|
53
|
+
return useCallback((required) => {
|
|
54
|
+
if (!Array.isArray(required) || required.length === 0)
|
|
55
|
+
return true;
|
|
56
|
+
if (!Array.isArray(held))
|
|
57
|
+
return true; // unknown → fail open
|
|
58
|
+
return required.every((p) => typeof p === 'string' && held.includes(p));
|
|
59
|
+
}, [held]);
|
|
60
|
+
}
|
|
@@ -16,12 +16,6 @@ export interface UseClientNotificationsOptions {
|
|
|
16
16
|
export interface UseClientNotificationsResult {
|
|
17
17
|
/** Fetch notifications from server */
|
|
18
18
|
fetchNotifications: () => Promise<void>;
|
|
19
|
-
/** Register device for push notifications */
|
|
20
|
-
registerDevice: (token: string, platform: string) => Promise<void>;
|
|
21
|
-
/** Get notification preferences */
|
|
22
|
-
getPreferences: () => Promise<any>;
|
|
23
|
-
/** Update notification preferences */
|
|
24
|
-
updatePreferences: (prefs: any) => Promise<void>;
|
|
25
19
|
/** Mark a notification as read on the server */
|
|
26
20
|
markAsRead: (id: string) => Promise<void>;
|
|
27
21
|
/** Loading state */
|