@object-ui/react 0.5.0 → 3.0.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 +32 -0
- package/dist/LazyPluginLoader.d.ts +44 -0
- package/dist/LazyPluginLoader.js +92 -3
- package/dist/SchemaRenderer.d.ts +20 -1
- package/dist/SchemaRenderer.js +76 -13
- package/dist/components/form/FieldFactory.js +44 -40
- package/dist/components/form/FormRenderer.js +45 -3
- package/dist/context/ActionContext.d.ts +76 -0
- package/dist/context/ActionContext.js +139 -0
- package/dist/context/DndContext.d.ts +135 -0
- package/dist/context/DndContext.js +120 -0
- package/dist/context/NotificationContext.d.ts +113 -0
- package/dist/context/NotificationContext.js +130 -0
- package/dist/context/ThemeContext.d.ts +94 -0
- package/dist/context/ThemeContext.js +215 -0
- package/dist/context/index.d.ts +4 -0
- package/dist/context/index.js +4 -0
- package/dist/hooks/index.d.ts +22 -0
- package/dist/hooks/index.js +22 -0
- package/dist/hooks/useActionRunner.d.ts +21 -7
- package/dist/hooks/useActionRunner.js +27 -7
- package/dist/hooks/useAnimation.d.ts +46 -0
- package/dist/hooks/useAnimation.js +59 -0
- package/dist/hooks/useClientNotifications.d.ts +53 -0
- package/dist/hooks/useClientNotifications.js +190 -0
- package/dist/hooks/useColumnSummary.d.ts +38 -0
- package/dist/hooks/useColumnSummary.js +54 -0
- package/dist/hooks/useCrudShortcuts.d.ts +39 -0
- package/dist/hooks/useCrudShortcuts.js +96 -0
- package/dist/hooks/useDensityMode.d.ts +48 -0
- package/dist/hooks/useDensityMode.js +60 -0
- package/dist/hooks/useDiscovery.d.ts +78 -0
- package/dist/hooks/useDiscovery.js +96 -0
- package/dist/hooks/useDynamicApp.d.ts +70 -0
- package/dist/hooks/useDynamicApp.js +92 -0
- package/dist/hooks/useETagCache.d.ts +75 -0
- package/dist/hooks/useETagCache.js +306 -0
- package/dist/hooks/useFocusManagement.d.ts +89 -0
- package/dist/hooks/useFocusManagement.js +188 -0
- package/dist/hooks/useFocusTrap.d.ts +41 -0
- package/dist/hooks/useFocusTrap.js +103 -0
- package/dist/hooks/useKeyboardShortcuts.d.ts +49 -0
- package/dist/hooks/useKeyboardShortcuts.js +86 -0
- package/dist/hooks/useNavigationOverlay.d.ts +78 -0
- package/dist/hooks/useNavigationOverlay.js +112 -0
- package/dist/hooks/useOffline.d.ts +96 -0
- package/dist/hooks/useOffline.js +185 -0
- package/dist/hooks/usePageTransition.d.ts +60 -0
- package/dist/hooks/usePageTransition.js +111 -0
- package/dist/hooks/usePageVariables.d.ts +67 -0
- package/dist/hooks/usePageVariables.js +109 -0
- package/dist/hooks/usePerformance.d.ts +87 -0
- package/dist/hooks/usePerformance.js +165 -0
- package/dist/hooks/usePerformanceBudget.d.ts +78 -0
- package/dist/hooks/usePerformanceBudget.js +209 -0
- package/dist/hooks/useReducedMotion.d.ts +28 -0
- package/dist/hooks/useReducedMotion.js +48 -0
- package/dist/hooks/useSchemaPersistence.d.ts +87 -0
- package/dist/hooks/useSchemaPersistence.js +173 -0
- package/dist/hooks/useViewData.d.ts +73 -0
- package/dist/hooks/useViewData.js +135 -0
- package/dist/hooks/useViewSharing.d.ts +57 -0
- package/dist/hooks/useViewSharing.js +74 -0
- package/dist/hooks/useViewTransition.d.ts +54 -0
- package/dist/hooks/useViewTransition.js +83 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/utils/i18n.d.ts +10 -0
- package/dist/utils/i18n.js +12 -0
- package/package.json +7 -5
- package/src/LazyPluginLoader.tsx +129 -7
- package/src/SchemaRenderer.tsx +122 -13
- package/src/__tests__/LazyPluginLoader.test.tsx +97 -1
- package/src/__tests__/SchemaRenderer.aria.test.tsx +118 -0
- package/src/__tests__/SchemaRenderer.errorBoundary.test.tsx +114 -0
- package/src/__tests__/SchemaRendererProvider.smoke.test.tsx +132 -0
- package/src/__tests__/ThemeProvider.test.tsx +451 -0
- package/src/__tests__/useNavigationOverlay.test.ts +416 -0
- package/src/__tests__/useOffline.test.ts +198 -0
- package/src/__tests__/usePageTransition.test.ts +214 -0
- package/src/__tests__/usePageVariables.test.tsx +334 -0
- package/src/__tests__/usePerformance.test.ts +137 -0
- package/src/__tests__/useViewData.test.tsx +422 -0
- package/src/__tests__/view-switching.test.tsx +456 -0
- package/src/components/form/FieldFactory.tsx +51 -46
- package/src/components/form/FormRenderer.test.tsx +145 -0
- package/src/components/form/FormRenderer.tsx +52 -3
- package/src/context/ActionContext.tsx +213 -0
- package/src/context/DndContext.tsx +260 -0
- package/src/context/NotificationContext.tsx +280 -0
- package/src/context/ThemeContext.tsx +325 -0
- package/src/context/__tests__/ActionContext.test.tsx +174 -0
- package/src/context/__tests__/DndContext.test.tsx +150 -0
- package/src/context/__tests__/NotificationContext.test.tsx +176 -0
- package/src/context/index.ts +4 -0
- package/src/hooks/__tests__/useActionRunner.test.ts +153 -0
- package/src/hooks/__tests__/useAnimation.test.ts +82 -0
- package/src/hooks/__tests__/useColumnSummary.test.ts +93 -0
- package/src/hooks/__tests__/useDensityMode.test.ts +81 -0
- package/src/hooks/__tests__/useDiscovery.test.tsx +149 -0
- package/src/hooks/__tests__/useDynamicApp.test.ts +161 -0
- package/src/hooks/__tests__/useETagCache.test.ts +153 -0
- package/src/hooks/__tests__/useExpression.test.ts +86 -0
- package/src/hooks/__tests__/useFocusTrap.test.ts +47 -0
- package/src/hooks/__tests__/useKeyboardShortcuts.test.ts +144 -0
- package/src/hooks/__tests__/usePerformanceBudget.test.ts +157 -0
- package/src/hooks/__tests__/useSchemaPersistence.test.ts +220 -0
- package/src/hooks/__tests__/useViewSharing.test.ts +121 -0
- package/src/hooks/__tests__/useViewTransition.test.ts +61 -0
- package/src/hooks/index.ts +22 -1
- package/src/hooks/useActionRunner.ts +49 -11
- package/src/hooks/useAnimation.ts +117 -0
- package/src/hooks/useClientNotifications.ts +251 -0
- package/src/hooks/useColumnSummary.ts +87 -0
- package/src/hooks/useCrudShortcuts.ts +126 -0
- package/src/hooks/useDensityMode.ts +102 -0
- package/src/hooks/useDiscovery.ts +141 -0
- package/src/hooks/useDynamicApp.ts +142 -0
- package/src/hooks/useETagCache.ts +404 -0
- package/src/hooks/useFocusManagement.ts +248 -0
- package/src/hooks/useFocusTrap.ts +138 -0
- package/src/hooks/useKeyboardShortcuts.ts +119 -0
- package/src/hooks/useNavigationOverlay.ts +180 -0
- package/src/hooks/useOffline.ts +301 -0
- package/src/hooks/usePageTransition.ts +187 -0
- package/src/hooks/usePageVariables.tsx +157 -0
- package/src/hooks/usePerformance.ts +246 -0
- package/src/hooks/usePerformanceBudget.ts +313 -0
- package/src/hooks/useReducedMotion.ts +53 -0
- package/src/hooks/useSchemaPersistence.ts +222 -0
- package/src/hooks/useViewData.ts +201 -0
- package/src/hooks/useViewSharing.ts +140 -0
- package/src/hooks/useViewTransition.ts +144 -0
- package/src/index.ts +21 -0
- package/src/utils/i18n.ts +10 -0
- package/tsconfig.json +1 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/__tests__/LazyPluginLoader.test.d.ts +0 -8
- package/dist/__tests__/LazyPluginLoader.test.js +0 -49
- package/dist/components/form/FormRenderer.test.d.ts +0 -8
- package/dist/components/form/FormRenderer.test.js +0 -534
- package/dist/index.test.d.ts +0 -8
- package/dist/index.test.js +0 -13
|
@@ -0,0 +1,220 @@
|
|
|
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 { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
10
|
+
import { renderHook, act } from '@testing-library/react';
|
|
11
|
+
import {
|
|
12
|
+
useSchemaPersistence,
|
|
13
|
+
createLocalStorageAdapter,
|
|
14
|
+
} from '../useSchemaPersistence';
|
|
15
|
+
import type { SchemaPersistenceAdapter } from '../useSchemaPersistence';
|
|
16
|
+
|
|
17
|
+
// Mock localStorage
|
|
18
|
+
const localStorageMock = (() => {
|
|
19
|
+
let store: Record<string, string> = {};
|
|
20
|
+
return {
|
|
21
|
+
getItem: (key: string) => store[key] ?? null,
|
|
22
|
+
setItem: (key: string, value: string) => { store[key] = value; },
|
|
23
|
+
removeItem: (key: string) => { delete store[key]; },
|
|
24
|
+
clear: () => { store = {}; },
|
|
25
|
+
};
|
|
26
|
+
})();
|
|
27
|
+
|
|
28
|
+
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
|
|
29
|
+
|
|
30
|
+
describe('useSchemaPersistence', () => {
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
localStorageMock.clear();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should initialize with default state', () => {
|
|
36
|
+
const { result } = renderHook(() => useSchemaPersistence());
|
|
37
|
+
expect(result.current.loading).toBe(false);
|
|
38
|
+
expect(result.current.error).toBeNull();
|
|
39
|
+
expect(result.current.isDirty).toBe(false);
|
|
40
|
+
expect(result.current.lastSavedAt).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should save and load a schema with default localStorage adapter', async () => {
|
|
44
|
+
const { result } = renderHook(() => useSchemaPersistence());
|
|
45
|
+
const testSchema = { type: 'page', title: 'My Page', regions: [] };
|
|
46
|
+
|
|
47
|
+
let savedId: string | null = null;
|
|
48
|
+
await act(async () => {
|
|
49
|
+
savedId = await result.current.save('test-page', testSchema);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
expect(savedId).toBe('test-page');
|
|
53
|
+
expect(result.current.isDirty).toBe(false);
|
|
54
|
+
expect(result.current.lastSavedAt).not.toBeNull();
|
|
55
|
+
|
|
56
|
+
let loaded: Record<string, unknown> | null = null;
|
|
57
|
+
await act(async () => {
|
|
58
|
+
loaded = await result.current.load('test-page');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(loaded).toEqual(testSchema);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should list saved schemas', async () => {
|
|
65
|
+
const { result } = renderHook(() => useSchemaPersistence());
|
|
66
|
+
|
|
67
|
+
await act(async () => {
|
|
68
|
+
await result.current.save('schema-1', { type: 'page' });
|
|
69
|
+
await result.current.save('schema-2', { type: 'form' });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
let items: Array<{ id: string }> = [];
|
|
73
|
+
await act(async () => {
|
|
74
|
+
items = await result.current.list();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(items).toHaveLength(2);
|
|
78
|
+
expect(items.map((i) => i.id)).toEqual(['schema-1', 'schema-2']);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should delete a schema', async () => {
|
|
82
|
+
const { result } = renderHook(() => useSchemaPersistence());
|
|
83
|
+
|
|
84
|
+
await act(async () => {
|
|
85
|
+
await result.current.save('to-delete', { type: 'grid' });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
let success = false;
|
|
89
|
+
await act(async () => {
|
|
90
|
+
success = await result.current.remove('to-delete');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(success).toBe(true);
|
|
94
|
+
|
|
95
|
+
let loaded: Record<string, unknown> | null = null;
|
|
96
|
+
await act(async () => {
|
|
97
|
+
loaded = await result.current.load('to-delete');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expect(loaded).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should track dirty state', () => {
|
|
104
|
+
const { result } = renderHook(() => useSchemaPersistence());
|
|
105
|
+
|
|
106
|
+
expect(result.current.isDirty).toBe(false);
|
|
107
|
+
|
|
108
|
+
act(() => {
|
|
109
|
+
result.current.markDirty();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
expect(result.current.isDirty).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('should clear dirty state after save', async () => {
|
|
116
|
+
const { result } = renderHook(() => useSchemaPersistence());
|
|
117
|
+
|
|
118
|
+
act(() => {
|
|
119
|
+
result.current.markDirty();
|
|
120
|
+
});
|
|
121
|
+
expect(result.current.isDirty).toBe(true);
|
|
122
|
+
|
|
123
|
+
await act(async () => {
|
|
124
|
+
await result.current.save('test', { type: 'page' });
|
|
125
|
+
});
|
|
126
|
+
expect(result.current.isDirty).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('should handle save errors gracefully', async () => {
|
|
130
|
+
const failingAdapter: SchemaPersistenceAdapter = {
|
|
131
|
+
save: () => Promise.reject(new Error('Network error')),
|
|
132
|
+
load: () => Promise.resolve(null),
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const { result } = renderHook(() => useSchemaPersistence(failingAdapter));
|
|
136
|
+
|
|
137
|
+
await act(async () => {
|
|
138
|
+
await result.current.save('test', { type: 'page' });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
expect(result.current.error).not.toBeNull();
|
|
142
|
+
expect(result.current.error?.message).toBe('Network error');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('should clear errors', async () => {
|
|
146
|
+
const failingAdapter: SchemaPersistenceAdapter = {
|
|
147
|
+
save: () => Promise.reject(new Error('fail')),
|
|
148
|
+
load: () => Promise.resolve(null),
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const { result } = renderHook(() => useSchemaPersistence(failingAdapter));
|
|
152
|
+
|
|
153
|
+
await act(async () => {
|
|
154
|
+
await result.current.save('test', { type: 'page' });
|
|
155
|
+
});
|
|
156
|
+
expect(result.current.error).not.toBeNull();
|
|
157
|
+
|
|
158
|
+
act(() => {
|
|
159
|
+
result.current.clearError();
|
|
160
|
+
});
|
|
161
|
+
expect(result.current.error).toBeNull();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('should work with custom adapter', async () => {
|
|
165
|
+
const mockStore: Record<string, Record<string, unknown>> = {};
|
|
166
|
+
const customAdapter: SchemaPersistenceAdapter = {
|
|
167
|
+
save: async (id, schema) => { mockStore[id] = schema; return id; },
|
|
168
|
+
load: async (id) => mockStore[id] ?? null,
|
|
169
|
+
list: async () => Object.keys(mockStore).map((id) => ({ id })),
|
|
170
|
+
delete: async (id) => { delete mockStore[id]; },
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const { result } = renderHook(() => useSchemaPersistence(customAdapter));
|
|
174
|
+
|
|
175
|
+
await act(async () => {
|
|
176
|
+
await result.current.save('custom-1', { type: 'dashboard' });
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
let loaded: Record<string, unknown> | null = null;
|
|
180
|
+
await act(async () => {
|
|
181
|
+
loaded = await result.current.load('custom-1');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
expect(loaded).toEqual({ type: 'dashboard' });
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('createLocalStorageAdapter', () => {
|
|
189
|
+
beforeEach(() => {
|
|
190
|
+
localStorageMock.clear();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('should create an adapter with custom prefix', async () => {
|
|
194
|
+
const adapter = createLocalStorageAdapter('my-prefix');
|
|
195
|
+
|
|
196
|
+
await adapter.save('test', { hello: 'world' });
|
|
197
|
+
const result = await adapter.load('test');
|
|
198
|
+
expect(result).toEqual({ hello: 'world' });
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('should maintain an index of saved schemas', async () => {
|
|
202
|
+
const adapter = createLocalStorageAdapter('test-prefix');
|
|
203
|
+
|
|
204
|
+
await adapter.save('a', { type: 'a' });
|
|
205
|
+
await adapter.save('b', { type: 'b' });
|
|
206
|
+
|
|
207
|
+
const items = await adapter.list!();
|
|
208
|
+
expect(items).toHaveLength(2);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('should not duplicate IDs in index', async () => {
|
|
212
|
+
const adapter = createLocalStorageAdapter('test-prefix');
|
|
213
|
+
|
|
214
|
+
await adapter.save('same-id', { v: 1 });
|
|
215
|
+
await adapter.save('same-id', { v: 2 });
|
|
216
|
+
|
|
217
|
+
const items = await adapter.list!();
|
|
218
|
+
expect(items).toHaveLength(1);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for useViewSharing hook
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect } from 'vitest';
|
|
5
|
+
import { renderHook, act } from '@testing-library/react';
|
|
6
|
+
import { useViewSharing } from '../useViewSharing';
|
|
7
|
+
import type { SavedView } from '../useViewSharing';
|
|
8
|
+
|
|
9
|
+
const createView = (overrides?: Partial<SavedView>): SavedView => ({
|
|
10
|
+
id: 'test-1',
|
|
11
|
+
name: 'Test View',
|
|
12
|
+
config: { filters: [] },
|
|
13
|
+
visibility: 'private',
|
|
14
|
+
updatedAt: new Date(),
|
|
15
|
+
...overrides,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('useViewSharing', () => {
|
|
19
|
+
it('starts with empty views by default', () => {
|
|
20
|
+
const { result } = renderHook(() => useViewSharing());
|
|
21
|
+
expect(result.current.views).toEqual([]);
|
|
22
|
+
expect(result.current.activeView).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('starts with provided initial views', () => {
|
|
26
|
+
const initial = [createView()];
|
|
27
|
+
const { result } = renderHook(() => useViewSharing(initial));
|
|
28
|
+
expect(result.current.views).toHaveLength(1);
|
|
29
|
+
expect(result.current.activeView?.id).toBe('test-1');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('saves a new view', () => {
|
|
33
|
+
const { result } = renderHook(() => useViewSharing());
|
|
34
|
+
|
|
35
|
+
act(() => {
|
|
36
|
+
result.current.saveView({
|
|
37
|
+
name: 'My View',
|
|
38
|
+
config: { sort: 'name' },
|
|
39
|
+
visibility: 'private',
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
expect(result.current.views).toHaveLength(1);
|
|
44
|
+
expect(result.current.views[0].name).toBe('My View');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('returns ID when saving a view', () => {
|
|
48
|
+
const { result } = renderHook(() => useViewSharing());
|
|
49
|
+
|
|
50
|
+
let id: string = '';
|
|
51
|
+
act(() => {
|
|
52
|
+
id = result.current.saveView({
|
|
53
|
+
name: 'My View',
|
|
54
|
+
config: {},
|
|
55
|
+
visibility: 'private',
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
expect(id).toBeTruthy();
|
|
60
|
+
expect(id).toContain('view-');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('deletes a view', () => {
|
|
64
|
+
const initial = [createView()];
|
|
65
|
+
const { result } = renderHook(() => useViewSharing(initial));
|
|
66
|
+
|
|
67
|
+
act(() => {
|
|
68
|
+
result.current.deleteView('test-1');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
expect(result.current.views).toHaveLength(0);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('sets active view', () => {
|
|
75
|
+
const initial = [
|
|
76
|
+
createView({ id: 'v1', name: 'View 1' }),
|
|
77
|
+
createView({ id: 'v2', name: 'View 2' }),
|
|
78
|
+
];
|
|
79
|
+
const { result } = renderHook(() => useViewSharing(initial));
|
|
80
|
+
|
|
81
|
+
act(() => {
|
|
82
|
+
result.current.setActiveView('v2');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
expect(result.current.activeView?.id).toBe('v2');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('duplicates a view', () => {
|
|
89
|
+
const initial = [createView()];
|
|
90
|
+
const { result } = renderHook(() => useViewSharing(initial));
|
|
91
|
+
|
|
92
|
+
act(() => {
|
|
93
|
+
result.current.duplicateView('test-1', 'Copy of Test');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(result.current.views).toHaveLength(2);
|
|
97
|
+
expect(result.current.views[1].name).toBe('Copy of Test');
|
|
98
|
+
expect(result.current.views[1].isDefault).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('updates view visibility', () => {
|
|
102
|
+
const initial = [createView()];
|
|
103
|
+
const { result } = renderHook(() => useViewSharing(initial));
|
|
104
|
+
|
|
105
|
+
act(() => {
|
|
106
|
+
result.current.setVisibility('test-1', 'organization');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(result.current.views[0].visibility).toBe('organization');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('selects default view as active', () => {
|
|
113
|
+
const initial = [
|
|
114
|
+
createView({ id: 'v1', name: 'View 1' }),
|
|
115
|
+
createView({ id: 'v2', name: 'View 2', isDefault: true }),
|
|
116
|
+
];
|
|
117
|
+
const { result } = renderHook(() => useViewSharing(initial));
|
|
118
|
+
|
|
119
|
+
expect(result.current.activeView?.id).toBe('v2');
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for useViewTransition hook
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
5
|
+
import { renderHook, act } from '@testing-library/react';
|
|
6
|
+
import { useViewTransition } from '../useViewTransition';
|
|
7
|
+
|
|
8
|
+
describe('useViewTransition', () => {
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
vi.restoreAllMocks();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
vi.restoreAllMocks();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('returns isSupported as false when document.startViewTransition is not available', () => {
|
|
18
|
+
const { result } = renderHook(() => useViewTransition());
|
|
19
|
+
|
|
20
|
+
// happy-dom does not implement startViewTransition
|
|
21
|
+
expect(result.current.isSupported).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('returns isTransitioning as false initially', () => {
|
|
25
|
+
const { result } = renderHook(() => useViewTransition());
|
|
26
|
+
|
|
27
|
+
expect(result.current.isTransitioning).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('startTransition calls callback without transition API (fallback behavior)', async () => {
|
|
31
|
+
const { result } = renderHook(() => useViewTransition());
|
|
32
|
+
|
|
33
|
+
const callback = vi.fn();
|
|
34
|
+
|
|
35
|
+
await act(async () => {
|
|
36
|
+
await result.current.startTransition(callback);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('respects enabled: false by calling callback directly', async () => {
|
|
43
|
+
const { result } = renderHook(() =>
|
|
44
|
+
useViewTransition({ enabled: false }),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const callback = vi.fn();
|
|
48
|
+
|
|
49
|
+
await act(async () => {
|
|
50
|
+
await result.current.startTransition(callback);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('transitionClass is empty initially', () => {
|
|
57
|
+
const { result } = renderHook(() => useViewTransition());
|
|
58
|
+
|
|
59
|
+
expect(result.current.transitionClass).toBe('');
|
|
60
|
+
});
|
|
61
|
+
});
|
package/src/hooks/index.ts
CHANGED
|
@@ -8,4 +8,25 @@
|
|
|
8
8
|
|
|
9
9
|
export * from './useExpression';
|
|
10
10
|
export * from './useActionRunner';
|
|
11
|
-
|
|
11
|
+
export * from './useNavigationOverlay';
|
|
12
|
+
export * from './usePageVariables';
|
|
13
|
+
export * from './useViewData';
|
|
14
|
+
export * from './useDynamicApp';
|
|
15
|
+
export * from './useDiscovery';
|
|
16
|
+
export * from './useFocusTrap';
|
|
17
|
+
export * from './useFocusManagement';
|
|
18
|
+
export * from './useKeyboardShortcuts';
|
|
19
|
+
export * from './useCrudShortcuts';
|
|
20
|
+
export * from './useReducedMotion';
|
|
21
|
+
export * from './useAnimation';
|
|
22
|
+
export * from './useColumnSummary';
|
|
23
|
+
export * from './useDensityMode';
|
|
24
|
+
export * from './useViewSharing';
|
|
25
|
+
export * from './useClientNotifications';
|
|
26
|
+
export * from './useOffline';
|
|
27
|
+
export * from './usePerformance';
|
|
28
|
+
export * from './usePerformanceBudget';
|
|
29
|
+
export * from './usePageTransition';
|
|
30
|
+
export * from './useViewTransition';
|
|
31
|
+
export * from './useETagCache';
|
|
32
|
+
export * from './useSchemaPersistence';
|
|
@@ -7,36 +7,74 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { useState, useCallback, useMemo } from 'react';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
ActionRunner,
|
|
12
|
+
type ActionContext,
|
|
13
|
+
type ActionDef,
|
|
14
|
+
type ActionResult,
|
|
15
|
+
type ConfirmationHandler,
|
|
16
|
+
type ToastHandler,
|
|
17
|
+
type ModalHandler,
|
|
18
|
+
type NavigationHandler,
|
|
19
|
+
} from '@object-ui/core';
|
|
20
|
+
|
|
21
|
+
export interface UseActionRunnerOptions {
|
|
22
|
+
context?: ActionContext;
|
|
23
|
+
/** Custom confirmation handler — wire to Shadcn AlertDialog or similar */
|
|
24
|
+
onConfirm?: ConfirmationHandler;
|
|
25
|
+
/** Custom toast handler — wire to Sonner or similar */
|
|
26
|
+
onToast?: ToastHandler;
|
|
27
|
+
/** Custom modal handler — render modals in response to modal actions */
|
|
28
|
+
onModal?: ModalHandler;
|
|
29
|
+
/** Custom navigation handler — SPA-aware routing */
|
|
30
|
+
onNavigate?: NavigationHandler;
|
|
31
|
+
}
|
|
11
32
|
|
|
12
33
|
/**
|
|
13
|
-
* Hook for executing actions with loading state
|
|
34
|
+
* Hook for executing actions with loading state, typed with ActionDef.
|
|
14
35
|
*
|
|
15
36
|
* @example
|
|
16
37
|
* ```tsx
|
|
17
|
-
* const { execute, loading, error, result } = useActionRunner({
|
|
38
|
+
* const { execute, loading, error, result } = useActionRunner({
|
|
39
|
+
* context: { data: formData },
|
|
40
|
+
* onToast: (msg, opts) => toast[opts?.type || 'info'](msg),
|
|
41
|
+
* });
|
|
18
42
|
*
|
|
19
43
|
* const handleClick = () => {
|
|
20
44
|
* execute({
|
|
21
|
-
* type: '
|
|
45
|
+
* type: 'api',
|
|
22
46
|
* api: '/api/save',
|
|
23
|
-
* successMessage: 'Saved successfully!'
|
|
47
|
+
* successMessage: 'Saved successfully!',
|
|
24
48
|
* });
|
|
25
49
|
* };
|
|
26
50
|
* ```
|
|
27
51
|
*/
|
|
28
|
-
export function useActionRunner(
|
|
52
|
+
export function useActionRunner(
|
|
53
|
+
optionsOrContext: UseActionRunnerOptions | ActionContext = {},
|
|
54
|
+
) {
|
|
55
|
+
// Backwards compat: if no 'context' key, treat entire object as context
|
|
56
|
+
const options: UseActionRunnerOptions = 'context' in optionsOrContext
|
|
57
|
+
? optionsOrContext as UseActionRunnerOptions
|
|
58
|
+
: { context: optionsOrContext as ActionContext };
|
|
59
|
+
|
|
60
|
+
const { context = {}, onConfirm, onToast, onModal, onNavigate } = options;
|
|
61
|
+
|
|
29
62
|
const [loading, setLoading] = useState(false);
|
|
30
63
|
const [error, setError] = useState<string | null>(null);
|
|
31
64
|
const [result, setResult] = useState<ActionResult | null>(null);
|
|
32
65
|
|
|
33
|
-
const runner = useMemo(
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
66
|
+
const runner = useMemo(() => {
|
|
67
|
+
const r = new ActionRunner(context);
|
|
68
|
+
if (onConfirm) r.setConfirmHandler(onConfirm);
|
|
69
|
+
if (onToast) r.setToastHandler(onToast);
|
|
70
|
+
if (onModal) r.setModalHandler(onModal);
|
|
71
|
+
if (onNavigate) r.setNavigationHandler(onNavigate);
|
|
72
|
+
return r;
|
|
73
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
74
|
+
}, [JSON.stringify(context)]);
|
|
37
75
|
|
|
38
76
|
const execute = useCallback(
|
|
39
|
-
async (action:
|
|
77
|
+
async (action: ActionDef): Promise<ActionResult> => {
|
|
40
78
|
setLoading(true);
|
|
41
79
|
setError(null);
|
|
42
80
|
setResult(null);
|
|
@@ -0,0 +1,117 @@
|
|
|
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 { useMemo } from 'react';
|
|
10
|
+
|
|
11
|
+
/** Animation trigger type */
|
|
12
|
+
export type AnimationTriggerType = 'enter' | 'exit' | 'hover' | 'focus' | 'click';
|
|
13
|
+
|
|
14
|
+
/** Easing presets aligned with EasingFunctionSchema */
|
|
15
|
+
export type EasingPreset =
|
|
16
|
+
| 'linear'
|
|
17
|
+
| 'ease'
|
|
18
|
+
| 'ease-in'
|
|
19
|
+
| 'ease-out'
|
|
20
|
+
| 'ease-in-out'
|
|
21
|
+
| 'spring';
|
|
22
|
+
|
|
23
|
+
/** Transition preset aligned with TransitionPresetSchema */
|
|
24
|
+
export type TransitionPresetType =
|
|
25
|
+
| 'fade'
|
|
26
|
+
| 'slide-up'
|
|
27
|
+
| 'slide-down'
|
|
28
|
+
| 'slide-left'
|
|
29
|
+
| 'slide-right'
|
|
30
|
+
| 'scale'
|
|
31
|
+
| 'scale-fade'
|
|
32
|
+
| 'none';
|
|
33
|
+
|
|
34
|
+
export interface AnimationConfig {
|
|
35
|
+
/** The animation/transition preset */
|
|
36
|
+
preset?: TransitionPresetType;
|
|
37
|
+
/** Duration in milliseconds */
|
|
38
|
+
duration?: number;
|
|
39
|
+
/** Delay in milliseconds */
|
|
40
|
+
delay?: number;
|
|
41
|
+
/** Easing function */
|
|
42
|
+
easing?: EasingPreset;
|
|
43
|
+
/** Whether animation is enabled */
|
|
44
|
+
enabled?: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Resolved CSS classes and styles for animation */
|
|
48
|
+
export interface AnimationResult {
|
|
49
|
+
/** Tailwind CSS classes for the animation */
|
|
50
|
+
className: string;
|
|
51
|
+
/** Inline style for custom durations/delays (only when needed) */
|
|
52
|
+
style: React.CSSProperties;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const EASING_MAP: Record<EasingPreset, string> = {
|
|
56
|
+
linear: 'linear',
|
|
57
|
+
ease: 'ease',
|
|
58
|
+
'ease-in': 'ease-in',
|
|
59
|
+
'ease-out': 'ease-out',
|
|
60
|
+
'ease-in-out': 'ease-in-out',
|
|
61
|
+
spring: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const PRESET_CLASSES: Record<TransitionPresetType, string> = {
|
|
65
|
+
fade: 'animate-in fade-in',
|
|
66
|
+
'slide-up': 'animate-in slide-in-from-bottom-2',
|
|
67
|
+
'slide-down': 'animate-in slide-in-from-top-2',
|
|
68
|
+
'slide-left': 'animate-in slide-in-from-right-2',
|
|
69
|
+
'slide-right': 'animate-in slide-in-from-left-2',
|
|
70
|
+
scale: 'animate-in zoom-in-95',
|
|
71
|
+
'scale-fade': 'animate-in fade-in zoom-in-95',
|
|
72
|
+
none: '',
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Hook for applying spec-driven animations to components.
|
|
77
|
+
* Implements ComponentAnimationSchema and TransitionPresetSchema
|
|
78
|
+
* from @objectstack/spec v2.0.7.
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* ```tsx
|
|
82
|
+
* function Card() {
|
|
83
|
+
* const animation = useAnimation({ preset: 'fade', duration: 200, easing: 'ease-out' });
|
|
84
|
+
* return <div className={animation.className} style={animation.style}>...</div>;
|
|
85
|
+
* }
|
|
86
|
+
* ```
|
|
87
|
+
*/
|
|
88
|
+
export function useAnimation(config: AnimationConfig = {}): AnimationResult {
|
|
89
|
+
const {
|
|
90
|
+
preset = 'none',
|
|
91
|
+
duration,
|
|
92
|
+
delay,
|
|
93
|
+
easing = 'ease-out',
|
|
94
|
+
enabled = true,
|
|
95
|
+
} = config;
|
|
96
|
+
|
|
97
|
+
return useMemo(() => {
|
|
98
|
+
if (!enabled || preset === 'none') {
|
|
99
|
+
return { className: '', style: {} };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const className = PRESET_CLASSES[preset] || '';
|
|
103
|
+
const style: React.CSSProperties = {};
|
|
104
|
+
|
|
105
|
+
if (duration !== undefined) {
|
|
106
|
+
style.animationDuration = `${duration}ms`;
|
|
107
|
+
}
|
|
108
|
+
if (delay !== undefined) {
|
|
109
|
+
style.animationDelay = `${delay}ms`;
|
|
110
|
+
}
|
|
111
|
+
if (easing) {
|
|
112
|
+
style.animationTimingFunction = EASING_MAP[easing] || easing;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { className, style };
|
|
116
|
+
}, [preset, duration, delay, easing, enabled]);
|
|
117
|
+
}
|