@object-ui/react 3.0.2 → 3.1.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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +8 -0
- package/dist/SchemaRenderer.js +61 -2
- package/dist/context/SchemaRendererContext.d.ts +4 -1
- package/dist/context/SchemaRendererContext.js +6 -4
- package/dist/hooks/index.d.ts +3 -0
- package/dist/hooks/index.js +3 -0
- package/dist/hooks/useActionEngine.d.ts +27 -0
- package/dist/hooks/useActionEngine.js +39 -0
- package/dist/hooks/useDebugMode.d.ts +43 -0
- package/dist/hooks/useDebugMode.js +71 -0
- package/dist/hooks/useGlobalUndo.d.ts +35 -0
- package/dist/hooks/useGlobalUndo.js +86 -0
- package/dist/hooks/useNavigationOverlay.d.ts +2 -0
- package/dist/hooks/useNavigationOverlay.js +12 -5
- package/dist/index.d.ts +3 -1
- package/dist/index.js +4 -1
- package/dist/spec-bridge/SpecBridge.d.ts +26 -0
- package/dist/spec-bridge/SpecBridge.js +62 -0
- package/dist/spec-bridge/bridges/dashboard.d.ts +81 -0
- package/dist/spec-bridge/bridges/dashboard.js +121 -0
- package/dist/spec-bridge/bridges/form-view.d.ts +44 -0
- package/dist/spec-bridge/bridges/form-view.js +77 -0
- package/dist/spec-bridge/bridges/list-view.d.ts +82 -0
- package/dist/spec-bridge/bridges/list-view.js +154 -0
- package/dist/spec-bridge/bridges/page.d.ts +68 -0
- package/dist/spec-bridge/bridges/page.js +81 -0
- package/dist/spec-bridge/index.d.ts +13 -0
- package/dist/spec-bridge/index.js +12 -0
- package/dist/spec-bridge/types.d.ts +34 -0
- package/{src/index.test.ts → dist/spec-bridge/types.js} +1 -8
- package/package.json +7 -7
- package/src/SchemaRenderer.tsx +66 -0
- package/src/__tests__/LazyPluginLoader.test.tsx +1 -1
- package/src/__tests__/SchemaRenderer.debug.test.tsx +69 -0
- package/src/__tests__/SchemaRenderer.expressions.test.tsx +131 -0
- package/src/__tests__/useNavigationOverlay.test.ts +162 -0
- package/src/context/SchemaRendererContext.tsx +9 -4
- package/src/context/__tests__/useDataScope.test.tsx +91 -0
- package/src/hooks/__tests__/useActionEngine.test.ts +170 -0
- package/src/hooks/__tests__/useCrudShortcuts.test.ts +85 -0
- package/src/hooks/__tests__/useDebugMode.test.ts +83 -0
- package/src/hooks/__tests__/useFocusTrap.test.ts +247 -0
- package/src/hooks/__tests__/usePageTransition.test.ts +158 -0
- package/src/hooks/__tests__/useReducedMotion.test.ts +104 -0
- package/src/hooks/index.ts +3 -0
- package/src/hooks/useActionEngine.ts +84 -0
- package/src/hooks/useDebugMode.ts +95 -0
- package/src/hooks/useGlobalUndo.ts +98 -0
- package/src/hooks/useNavigationOverlay.ts +14 -5
- package/src/index.ts +6 -0
- package/src/spec-bridge/SpecBridge.ts +64 -0
- package/src/spec-bridge/__tests__/P1SpecBridge.test.ts +855 -0
- package/src/spec-bridge/__tests__/SpecBridge.test.ts +550 -0
- package/src/spec-bridge/bridges/dashboard.ts +180 -0
- package/src/spec-bridge/bridges/form-view.ts +109 -0
- package/src/spec-bridge/bridges/list-view.ts +187 -0
- package/src/spec-bridge/bridges/page.ts +136 -0
- package/src/spec-bridge/index.ts +14 -0
- package/src/spec-bridge/types.ts +38 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for usePageTransition hook
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
5
|
+
import { renderHook } from '@testing-library/react';
|
|
6
|
+
import { usePageTransition } from '../usePageTransition';
|
|
7
|
+
|
|
8
|
+
describe('usePageTransition', () => {
|
|
9
|
+
|
|
10
|
+
it('returns inactive result when type is none (default)', () => {
|
|
11
|
+
const { result } = renderHook(() => usePageTransition());
|
|
12
|
+
expect(result.current.isActive).toBe(false);
|
|
13
|
+
expect(result.current.enterClassName).toBe('');
|
|
14
|
+
expect(result.current.exitClassName).toBe('');
|
|
15
|
+
expect(result.current.type).toBe('none');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('returns active result for fade transition', () => {
|
|
19
|
+
const { result } = renderHook(() => usePageTransition({ type: 'fade' }));
|
|
20
|
+
expect(result.current.isActive).toBe(true);
|
|
21
|
+
expect(result.current.enterClassName).toContain('fade-in');
|
|
22
|
+
expect(result.current.exitClassName).toContain('fade-out');
|
|
23
|
+
expect(result.current.type).toBe('fade');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('returns correct classes for slide_up transition', () => {
|
|
27
|
+
const { result } = renderHook(() => usePageTransition({ type: 'slide_up' }));
|
|
28
|
+
expect(result.current.isActive).toBe(true);
|
|
29
|
+
expect(result.current.enterClassName).toContain('slide-in-from-bottom');
|
|
30
|
+
expect(result.current.exitClassName).toContain('slide-out-to-top');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('returns correct classes for slide_down transition', () => {
|
|
34
|
+
const { result } = renderHook(() => usePageTransition({ type: 'slide_down' }));
|
|
35
|
+
expect(result.current.enterClassName).toContain('slide-in-from-top');
|
|
36
|
+
expect(result.current.exitClassName).toContain('slide-out-to-bottom');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('returns correct classes for slide_left transition', () => {
|
|
40
|
+
const { result } = renderHook(() => usePageTransition({ type: 'slide_left' }));
|
|
41
|
+
expect(result.current.enterClassName).toContain('slide-in-from-right');
|
|
42
|
+
expect(result.current.exitClassName).toContain('slide-out-to-left');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('returns correct classes for slide_right transition', () => {
|
|
46
|
+
const { result } = renderHook(() => usePageTransition({ type: 'slide_right' }));
|
|
47
|
+
expect(result.current.enterClassName).toContain('slide-in-from-left');
|
|
48
|
+
expect(result.current.exitClassName).toContain('slide-out-to-right');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('returns correct classes for scale transition', () => {
|
|
52
|
+
const { result } = renderHook(() => usePageTransition({ type: 'scale' }));
|
|
53
|
+
expect(result.current.enterClassName).toContain('zoom-in');
|
|
54
|
+
expect(result.current.exitClassName).toContain('zoom-out');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('returns correct classes for rotate transition', () => {
|
|
58
|
+
const { result } = renderHook(() => usePageTransition({ type: 'rotate' }));
|
|
59
|
+
expect(result.current.enterClassName).toContain('spin-in');
|
|
60
|
+
expect(result.current.exitClassName).toContain('spin-out');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('returns correct classes for flip transition', () => {
|
|
64
|
+
const { result } = renderHook(() => usePageTransition({ type: 'flip' }));
|
|
65
|
+
expect(result.current.enterClassName).toContain('zoom-in');
|
|
66
|
+
expect(result.current.exitClassName).toContain('zoom-out');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('uses default duration of 300ms', () => {
|
|
70
|
+
const { result } = renderHook(() => usePageTransition({ type: 'fade' }));
|
|
71
|
+
expect(result.current.duration).toBe(300);
|
|
72
|
+
expect(result.current.enterStyle.animationDuration).toBe('300ms');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('accepts custom duration', () => {
|
|
76
|
+
const { result } = renderHook(() => usePageTransition({ type: 'fade', duration: 500 }));
|
|
77
|
+
expect(result.current.duration).toBe(500);
|
|
78
|
+
expect(result.current.enterStyle.animationDuration).toBe('500ms');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('uses ease_in_out easing by default', () => {
|
|
82
|
+
const { result } = renderHook(() => usePageTransition({ type: 'fade' }));
|
|
83
|
+
expect(result.current.enterStyle.animationTimingFunction).toBe('ease-in-out');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('accepts custom easing', () => {
|
|
87
|
+
const { result } = renderHook(() => usePageTransition({ type: 'fade', easing: 'spring' }));
|
|
88
|
+
expect(result.current.enterStyle.animationTimingFunction).toBe('cubic-bezier(0.34, 1.56, 0.64, 1)');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('sets animationFillMode to both', () => {
|
|
92
|
+
const { result } = renderHook(() => usePageTransition({ type: 'fade' }));
|
|
93
|
+
expect(result.current.enterStyle.animationFillMode).toBe('both');
|
|
94
|
+
expect(result.current.exitStyle.animationFillMode).toBe('both');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('applies crossFade positioning', () => {
|
|
98
|
+
const { result } = renderHook(() => usePageTransition({ type: 'fade', crossFade: true }));
|
|
99
|
+
expect(result.current.enterStyle.position).toBe('absolute');
|
|
100
|
+
expect(result.current.enterStyle.inset).toBe('0');
|
|
101
|
+
expect(result.current.exitStyle.position).toBe('absolute');
|
|
102
|
+
expect(result.current.exitStyle.inset).toBe('0');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('does not apply crossFade positioning when disabled', () => {
|
|
106
|
+
const { result } = renderHook(() => usePageTransition({ type: 'fade', crossFade: false }));
|
|
107
|
+
expect(result.current.enterStyle.position).toBeUndefined();
|
|
108
|
+
expect(result.current.exitStyle.position).toBeUndefined();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('returns inactive when reduced motion is preferred', () => {
|
|
112
|
+
// Mock window.matchMedia to simulate reduced motion preference
|
|
113
|
+
const original = window.matchMedia;
|
|
114
|
+
window.matchMedia = vi.fn((query: string) => ({
|
|
115
|
+
matches: query === '(prefers-reduced-motion: reduce)',
|
|
116
|
+
media: query,
|
|
117
|
+
onchange: null,
|
|
118
|
+
addEventListener: vi.fn(),
|
|
119
|
+
removeEventListener: vi.fn(),
|
|
120
|
+
addListener: vi.fn(),
|
|
121
|
+
removeListener: vi.fn(),
|
|
122
|
+
dispatchEvent: vi.fn(),
|
|
123
|
+
})) as unknown as typeof window.matchMedia;
|
|
124
|
+
|
|
125
|
+
const { result } = renderHook(() => usePageTransition({ type: 'fade' }));
|
|
126
|
+
expect(result.current.isActive).toBe(false);
|
|
127
|
+
expect(result.current.enterClassName).toBe('');
|
|
128
|
+
expect(result.current.exitClassName).toBe('');
|
|
129
|
+
|
|
130
|
+
window.matchMedia = original;
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('returns empty styles when inactive', () => {
|
|
134
|
+
const { result } = renderHook(() => usePageTransition({ type: 'none' }));
|
|
135
|
+
expect(result.current.enterStyle).toEqual({});
|
|
136
|
+
expect(result.current.exitStyle).toEqual({});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('accepts linear easing', () => {
|
|
140
|
+
const { result } = renderHook(() => usePageTransition({ type: 'fade', easing: 'linear' }));
|
|
141
|
+
expect(result.current.enterStyle.animationTimingFunction).toBe('linear');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('accepts ease easing', () => {
|
|
145
|
+
const { result } = renderHook(() => usePageTransition({ type: 'fade', easing: 'ease' }));
|
|
146
|
+
expect(result.current.enterStyle.animationTimingFunction).toBe('ease');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('accepts ease_in easing', () => {
|
|
150
|
+
const { result } = renderHook(() => usePageTransition({ type: 'fade', easing: 'ease_in' }));
|
|
151
|
+
expect(result.current.enterStyle.animationTimingFunction).toBe('ease-in');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('accepts ease_out easing', () => {
|
|
155
|
+
const { result } = renderHook(() => usePageTransition({ type: 'fade', easing: 'ease_out' }));
|
|
156
|
+
expect(result.current.enterStyle.animationTimingFunction).toBe('ease-out');
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for useReducedMotion hook
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
5
|
+
import { renderHook, act } from '@testing-library/react';
|
|
6
|
+
import { useReducedMotion } from '../useReducedMotion';
|
|
7
|
+
|
|
8
|
+
describe('useReducedMotion', () => {
|
|
9
|
+
let originalMatchMedia: typeof window.matchMedia;
|
|
10
|
+
let listeners: Map<string, ((event: MediaQueryListEvent) => void)[]>;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
originalMatchMedia = window.matchMedia;
|
|
14
|
+
listeners = new Map();
|
|
15
|
+
|
|
16
|
+
window.matchMedia = vi.fn((query: string) => {
|
|
17
|
+
const mediaQueryList = {
|
|
18
|
+
matches: false,
|
|
19
|
+
media: query,
|
|
20
|
+
onchange: null,
|
|
21
|
+
addEventListener: vi.fn((event: string, handler: (event: MediaQueryListEvent) => void) => {
|
|
22
|
+
const key = `${query}:${event}`;
|
|
23
|
+
if (!listeners.has(key)) {
|
|
24
|
+
listeners.set(key, []);
|
|
25
|
+
}
|
|
26
|
+
listeners.get(key)!.push(handler);
|
|
27
|
+
}),
|
|
28
|
+
removeEventListener: vi.fn(),
|
|
29
|
+
addListener: vi.fn(),
|
|
30
|
+
removeListener: vi.fn(),
|
|
31
|
+
dispatchEvent: vi.fn(),
|
|
32
|
+
} as unknown as MediaQueryList;
|
|
33
|
+
return mediaQueryList;
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
window.matchMedia = originalMatchMedia;
|
|
39
|
+
listeners.clear();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('returns false when prefers-reduced-motion is not set', () => {
|
|
43
|
+
const { result } = renderHook(() => useReducedMotion());
|
|
44
|
+
expect(result.current).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('returns true when prefers-reduced-motion is set', () => {
|
|
48
|
+
window.matchMedia = vi.fn((query: string) => ({
|
|
49
|
+
matches: true,
|
|
50
|
+
media: query,
|
|
51
|
+
onchange: null,
|
|
52
|
+
addEventListener: vi.fn(),
|
|
53
|
+
removeEventListener: vi.fn(),
|
|
54
|
+
addListener: vi.fn(),
|
|
55
|
+
removeListener: vi.fn(),
|
|
56
|
+
dispatchEvent: vi.fn(),
|
|
57
|
+
})) as unknown as typeof window.matchMedia;
|
|
58
|
+
|
|
59
|
+
const { result } = renderHook(() => useReducedMotion());
|
|
60
|
+
expect(result.current).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('registers a change event listener', () => {
|
|
64
|
+
renderHook(() => useReducedMotion());
|
|
65
|
+
|
|
66
|
+
const key = '(prefers-reduced-motion: reduce):change';
|
|
67
|
+
expect(listeners.has(key)).toBe(true);
|
|
68
|
+
expect(listeners.get(key)!.length).toBe(1);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('updates when media query changes', () => {
|
|
72
|
+
const { result } = renderHook(() => useReducedMotion());
|
|
73
|
+
|
|
74
|
+
expect(result.current).toBe(false);
|
|
75
|
+
|
|
76
|
+
const key = '(prefers-reduced-motion: reduce):change';
|
|
77
|
+
const handler = listeners.get(key)![0];
|
|
78
|
+
|
|
79
|
+
act(() => {
|
|
80
|
+
handler({ matches: true } as MediaQueryListEvent);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(result.current).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('cleans up event listener on unmount', () => {
|
|
87
|
+
const removeEventListener = vi.fn();
|
|
88
|
+
window.matchMedia = vi.fn((query: string) => ({
|
|
89
|
+
matches: false,
|
|
90
|
+
media: query,
|
|
91
|
+
onchange: null,
|
|
92
|
+
addEventListener: vi.fn(),
|
|
93
|
+
removeEventListener,
|
|
94
|
+
addListener: vi.fn(),
|
|
95
|
+
removeListener: vi.fn(),
|
|
96
|
+
dispatchEvent: vi.fn(),
|
|
97
|
+
})) as unknown as typeof window.matchMedia;
|
|
98
|
+
|
|
99
|
+
const { unmount } = renderHook(() => useReducedMotion());
|
|
100
|
+
unmount();
|
|
101
|
+
|
|
102
|
+
expect(removeEventListener).toHaveBeenCalledWith('change', expect.any(Function));
|
|
103
|
+
});
|
|
104
|
+
});
|
package/src/hooks/index.ts
CHANGED
|
@@ -0,0 +1,84 @@
|
|
|
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
|
+
/**
|
|
10
|
+
* useActionEngine — React hook wrapping ActionEngine for location-based action management.
|
|
11
|
+
*
|
|
12
|
+
* Provides declarative access to the ActionEngine's location filtering, bulk operations,
|
|
13
|
+
* keyboard shortcut handling, and execution pipeline from React components.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```tsx
|
|
17
|
+
* const { getActionsForLocation, executeAction } = useActionEngine({
|
|
18
|
+
* actions: schema.actions,
|
|
19
|
+
* context: { record, user },
|
|
20
|
+
* });
|
|
21
|
+
*
|
|
22
|
+
* const toolbarActions = getActionsForLocation('list_toolbar');
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { useCallback, useMemo } from 'react';
|
|
27
|
+
import {
|
|
28
|
+
ActionEngine,
|
|
29
|
+
type ActionLocation,
|
|
30
|
+
type ActionDef,
|
|
31
|
+
type ActionContext,
|
|
32
|
+
type ActionResult,
|
|
33
|
+
} from '@object-ui/core';
|
|
34
|
+
|
|
35
|
+
export interface UseActionEngineOptions {
|
|
36
|
+
/** Action definitions to register */
|
|
37
|
+
actions?: ActionDef[];
|
|
38
|
+
/** Action context (record, user, etc.) */
|
|
39
|
+
context?: ActionContext;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface UseActionEngineReturn {
|
|
43
|
+
/** Get actions available at a specific location, sorted by priority */
|
|
44
|
+
getActionsForLocation: (location: ActionLocation) => ActionDef[];
|
|
45
|
+
/** Get actions that support bulk operations */
|
|
46
|
+
getBulkActions: () => ActionDef[];
|
|
47
|
+
/** Execute an action by name */
|
|
48
|
+
executeAction: (name: string, contextOverride?: Partial<ActionContext>) => Promise<ActionResult>;
|
|
49
|
+
/** Handle a keyboard shortcut */
|
|
50
|
+
handleShortcut: (keys: string) => Promise<ActionResult | null>;
|
|
51
|
+
/** The underlying ActionEngine instance */
|
|
52
|
+
engine: ActionEngine;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function useActionEngine(options: UseActionEngineOptions = {}): UseActionEngineReturn {
|
|
56
|
+
const { actions = [], context = {} } = options;
|
|
57
|
+
|
|
58
|
+
const engine = useMemo(() => {
|
|
59
|
+
const e = new ActionEngine(context);
|
|
60
|
+
e.registerActions(actions);
|
|
61
|
+
return e;
|
|
62
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
63
|
+
}, [JSON.stringify(actions), JSON.stringify(context)]);
|
|
64
|
+
|
|
65
|
+
const getActionsForLocation = useCallback(
|
|
66
|
+
(location: ActionLocation) => engine.getActionsForLocation(location),
|
|
67
|
+
[engine],
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const getBulkActions = useCallback(() => engine.getBulkActions(), [engine]);
|
|
71
|
+
|
|
72
|
+
const executeAction = useCallback(
|
|
73
|
+
(name: string, contextOverride?: Partial<ActionContext>) =>
|
|
74
|
+
engine.executeAction(name, contextOverride),
|
|
75
|
+
[engine],
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const handleShortcut = useCallback(
|
|
79
|
+
(keys: string) => engine.handleShortcut(keys),
|
|
80
|
+
[engine],
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
return { getActionsForLocation, getBulkActions, executeAction, handleShortcut, engine };
|
|
84
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
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
|
+
import { useState, useEffect, useCallback, useMemo } from 'react';
|
|
10
|
+
import { parseDebugFlags, type DebugFlags } from '@object-ui/core';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Result returned by {@link useDebugMode}.
|
|
14
|
+
*/
|
|
15
|
+
export interface UseDebugModeResult {
|
|
16
|
+
/** Whether debug mode is currently enabled */
|
|
17
|
+
enabled: boolean;
|
|
18
|
+
/** Fine-grained debug flags parsed from the URL */
|
|
19
|
+
flags: DebugFlags;
|
|
20
|
+
/** Toggle debug mode on/off */
|
|
21
|
+
toggle: () => void;
|
|
22
|
+
/** Explicitly set debug mode */
|
|
23
|
+
setEnabled: (value: boolean) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Hook for managing debug mode.
|
|
28
|
+
*
|
|
29
|
+
* Automatically detects `?__debug` (and sub-flags like `?__debug_schema`)
|
|
30
|
+
* from the URL. Supports manual toggle and Ctrl+Shift+D keyboard shortcut.
|
|
31
|
+
*
|
|
32
|
+
* SSR-safe — returns disabled state when `window` is unavailable.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```tsx
|
|
36
|
+
* function App() {
|
|
37
|
+
* const debug = useDebugMode();
|
|
38
|
+
* return (
|
|
39
|
+
* <>
|
|
40
|
+
* {debug.enabled && <DebugPanel flags={debug.flags} />}
|
|
41
|
+
* <button onClick={debug.toggle}>Toggle Debug</button>
|
|
42
|
+
* </>
|
|
43
|
+
* );
|
|
44
|
+
* }
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export function useDebugMode(): UseDebugModeResult {
|
|
48
|
+
const urlFlags = useMemo(() => {
|
|
49
|
+
try {
|
|
50
|
+
return typeof window !== 'undefined'
|
|
51
|
+
? parseDebugFlags(window.location.search)
|
|
52
|
+
: { enabled: false } as DebugFlags;
|
|
53
|
+
} catch {
|
|
54
|
+
return { enabled: false } as DebugFlags;
|
|
55
|
+
}
|
|
56
|
+
}, []);
|
|
57
|
+
|
|
58
|
+
const [manualEnabled, setManualEnabled] = useState<boolean | null>(null);
|
|
59
|
+
|
|
60
|
+
// Effective enabled: manual override wins, then URL flags
|
|
61
|
+
const enabled = manualEnabled !== null ? manualEnabled : urlFlags.enabled;
|
|
62
|
+
|
|
63
|
+
// Flags reflect URL-parsed flags plus effective enabled state
|
|
64
|
+
const flags: DebugFlags = useMemo(
|
|
65
|
+
() => ({ ...urlFlags, enabled }),
|
|
66
|
+
[urlFlags, enabled],
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const toggle = useCallback(() => {
|
|
70
|
+
setManualEnabled((prev) => {
|
|
71
|
+
if (prev === null) return !urlFlags.enabled;
|
|
72
|
+
return !prev;
|
|
73
|
+
});
|
|
74
|
+
}, [urlFlags.enabled]);
|
|
75
|
+
|
|
76
|
+
const setEnabled = useCallback((value: boolean) => {
|
|
77
|
+
setManualEnabled(value);
|
|
78
|
+
}, []);
|
|
79
|
+
|
|
80
|
+
// Ctrl+Shift+D keyboard shortcut
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (typeof window === 'undefined') return;
|
|
83
|
+
|
|
84
|
+
const handler = (e: KeyboardEvent) => {
|
|
85
|
+
if (e.ctrlKey && e.shiftKey && e.key === 'D') {
|
|
86
|
+
e.preventDefault();
|
|
87
|
+
toggle();
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
document.addEventListener('keydown', handler);
|
|
91
|
+
return () => document.removeEventListener('keydown', handler);
|
|
92
|
+
}, [toggle]);
|
|
93
|
+
|
|
94
|
+
return { enabled, flags, toggle, setEnabled };
|
|
95
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
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
|
+
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
|
|
10
|
+
import { globalUndoManager, type UndoableOperation } from '@object-ui/core';
|
|
11
|
+
|
|
12
|
+
export interface UseGlobalUndoOptions {
|
|
13
|
+
/** DataSource to execute undo/redo operations. */
|
|
14
|
+
dataSource?: {
|
|
15
|
+
create(objectName: string, data: Record<string, unknown>): Promise<unknown>;
|
|
16
|
+
update(objectName: string, recordId: string, data: Record<string, unknown>): Promise<unknown>;
|
|
17
|
+
delete(objectName: string, recordId: string): Promise<unknown>;
|
|
18
|
+
};
|
|
19
|
+
/** Callback after successful undo. */
|
|
20
|
+
onUndo?: (op: UndoableOperation) => void;
|
|
21
|
+
/** Callback after successful redo. */
|
|
22
|
+
onRedo?: (op: UndoableOperation) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getSnapshot() {
|
|
26
|
+
return {
|
|
27
|
+
canUndo: globalUndoManager.canUndo,
|
|
28
|
+
canRedo: globalUndoManager.canRedo,
|
|
29
|
+
undoDescription: globalUndoManager.peekUndo()?.description,
|
|
30
|
+
redoDescription: globalUndoManager.peekRedo()?.description,
|
|
31
|
+
history: globalUndoManager.getHistory(),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getServerSnapshot() {
|
|
36
|
+
return { canUndo: false, canRedo: false, undoDescription: undefined, redoDescription: undefined, history: [] as UndoableOperation[] };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Cache reference to avoid re-renders when nothing changed
|
|
40
|
+
let cachedSnapshot = getSnapshot();
|
|
41
|
+
function subscribe(callback: () => void) {
|
|
42
|
+
return globalUndoManager.subscribe(() => {
|
|
43
|
+
cachedSnapshot = getSnapshot();
|
|
44
|
+
callback();
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
function getCachedSnapshot() { return cachedSnapshot; }
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* React hook that wraps the global UndoManager for use in console components.
|
|
51
|
+
*
|
|
52
|
+
* Provides reactive undo/redo state, executes data operations through the
|
|
53
|
+
* supplied dataSource, and registers Ctrl+Z / Ctrl+Shift+Z keyboard shortcuts.
|
|
54
|
+
*/
|
|
55
|
+
export function useGlobalUndo(options: UseGlobalUndoOptions = {}) {
|
|
56
|
+
const optionsRef = useRef(options);
|
|
57
|
+
optionsRef.current = options;
|
|
58
|
+
|
|
59
|
+
const state = useSyncExternalStore(subscribe, getCachedSnapshot, getServerSnapshot);
|
|
60
|
+
|
|
61
|
+
const executeOp = useCallback(async (op: UndoableOperation, data: Record<string, unknown>, mode: 'undo' | 'redo') => {
|
|
62
|
+
const ds = optionsRef.current.dataSource;
|
|
63
|
+
if (!ds) return;
|
|
64
|
+
const action = mode === 'undo'
|
|
65
|
+
? ({ create: 'delete', update: 'update', delete: 'create' } as const)[op.type]
|
|
66
|
+
: op.type;
|
|
67
|
+
if (action === 'delete') await ds.delete(op.objectName, op.recordId);
|
|
68
|
+
else if (action === 'update') await ds.update(op.objectName, op.recordId, data);
|
|
69
|
+
else await ds.create(op.objectName, data);
|
|
70
|
+
}, []);
|
|
71
|
+
|
|
72
|
+
const undo = useCallback(async () => {
|
|
73
|
+
const op = globalUndoManager.popUndo();
|
|
74
|
+
if (!op) return;
|
|
75
|
+
await executeOp(op, op.undoData, 'undo');
|
|
76
|
+
optionsRef.current.onUndo?.(op);
|
|
77
|
+
}, [executeOp]);
|
|
78
|
+
|
|
79
|
+
const redo = useCallback(async () => {
|
|
80
|
+
const op = globalUndoManager.popRedo();
|
|
81
|
+
if (!op) return;
|
|
82
|
+
await executeOp(op, op.redoData, 'redo');
|
|
83
|
+
optionsRef.current.onRedo?.(op);
|
|
84
|
+
}, [executeOp]);
|
|
85
|
+
|
|
86
|
+
// Keyboard shortcuts: Ctrl+Z (undo), Ctrl+Shift+Z (redo)
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
const handler = (e: KeyboardEvent) => {
|
|
89
|
+
if (!(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== 'z') return;
|
|
90
|
+
e.preventDefault();
|
|
91
|
+
if (e.shiftKey) { void redo(); } else { void undo(); }
|
|
92
|
+
};
|
|
93
|
+
window.addEventListener('keydown', handler);
|
|
94
|
+
return () => window.removeEventListener('keydown', handler);
|
|
95
|
+
}, [undo, redo]);
|
|
96
|
+
|
|
97
|
+
return useMemo(() => ({ ...state, undo, redo }), [state, undo, redo]);
|
|
98
|
+
}
|
|
@@ -64,6 +64,8 @@ export interface NavigationOverlayState {
|
|
|
64
64
|
width: string | number | undefined;
|
|
65
65
|
/** Whether navigation is an overlay mode (drawer/modal/split/popover) */
|
|
66
66
|
isOverlay: boolean;
|
|
67
|
+
/** The target view/form name from NavigationConfig */
|
|
68
|
+
view: string | undefined;
|
|
67
69
|
}
|
|
68
70
|
|
|
69
71
|
/**
|
|
@@ -102,6 +104,7 @@ export function useNavigationOverlay(
|
|
|
102
104
|
|
|
103
105
|
const mode: NavigationMode = navigation?.mode ?? 'page';
|
|
104
106
|
const width = navigation?.width;
|
|
107
|
+
const view = navigation?.view;
|
|
105
108
|
const isOverlay = mode === 'drawer' || mode === 'modal' || mode === 'split' || mode === 'popover';
|
|
106
109
|
|
|
107
110
|
const close = useCallback(() => {
|
|
@@ -126,7 +129,7 @@ export function useNavigationOverlay(
|
|
|
126
129
|
if (!navigation) {
|
|
127
130
|
const recordId = record._id || record.id;
|
|
128
131
|
if (onNavigate && recordId != null) {
|
|
129
|
-
onNavigate(recordId as string | number, 'view');
|
|
132
|
+
onNavigate(recordId as string | number, view ?? 'view');
|
|
130
133
|
}
|
|
131
134
|
return;
|
|
132
135
|
}
|
|
@@ -136,10 +139,15 @@ export function useNavigationOverlay(
|
|
|
136
139
|
return;
|
|
137
140
|
}
|
|
138
141
|
|
|
139
|
-
// new_window / openNewTab —
|
|
142
|
+
// new_window / openNewTab — delegate to onNavigate when available, else open directly
|
|
140
143
|
if (mode === 'new_window' || navigation.openNewTab) {
|
|
141
144
|
const recordId = record._id || record.id;
|
|
142
|
-
|
|
145
|
+
if (onNavigate && recordId != null) {
|
|
146
|
+
onNavigate(recordId as string | number, 'new_window');
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const viewPath = view ? `/${view}` : '';
|
|
150
|
+
const url = objectName ? `/${objectName}/${recordId}${viewPath}` : `/${recordId}${viewPath}`;
|
|
143
151
|
window.open(url, '_blank');
|
|
144
152
|
return;
|
|
145
153
|
}
|
|
@@ -148,7 +156,7 @@ export function useNavigationOverlay(
|
|
|
148
156
|
if (mode === 'page') {
|
|
149
157
|
const recordId = record._id || record.id;
|
|
150
158
|
if (onNavigate && recordId != null) {
|
|
151
|
-
onNavigate(recordId as string | number, 'view');
|
|
159
|
+
onNavigate(recordId as string | number, view ?? 'view');
|
|
152
160
|
}
|
|
153
161
|
return;
|
|
154
162
|
}
|
|
@@ -173,8 +181,9 @@ export function useNavigationOverlay(
|
|
|
173
181
|
setIsOpen,
|
|
174
182
|
handleClick,
|
|
175
183
|
width,
|
|
184
|
+
view,
|
|
176
185
|
isOverlay,
|
|
177
186
|
}),
|
|
178
|
-
[isOpen, selectedRecord, mode, close, open, handleClick, width, isOverlay]
|
|
187
|
+
[isOpen, selectedRecord, mode, close, open, handleClick, width, view, isOverlay]
|
|
179
188
|
);
|
|
180
189
|
}
|
package/src/index.ts
CHANGED
|
@@ -11,11 +11,17 @@ export * from './hooks'; // will be empty for now
|
|
|
11
11
|
export * from './context'; // will be empty for now
|
|
12
12
|
export * from './components/form';
|
|
13
13
|
export * from './LazyPluginLoader';
|
|
14
|
+
export * from './spec-bridge';
|
|
15
|
+
|
|
16
|
+
// i18n utilities
|
|
17
|
+
export { resolveI18nLabel } from './utils/i18n';
|
|
14
18
|
|
|
15
19
|
// Built-in i18n support
|
|
16
20
|
export {
|
|
17
21
|
I18nProvider,
|
|
18
22
|
useObjectTranslation,
|
|
23
|
+
useObjectLabel,
|
|
24
|
+
useSafeFieldLabel,
|
|
19
25
|
useI18nContext,
|
|
20
26
|
createI18n,
|
|
21
27
|
getDirection,
|