@object-ui/react 0.3.1 → 2.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 +4 -0
- package/CHANGELOG.md +13 -0
- package/dist/LazyPluginLoader.d.ts +82 -0
- package/dist/LazyPluginLoader.js +124 -0
- package/dist/SchemaRenderer.js +44 -12
- 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/SchemaRendererContext.d.ts +2 -1
- package/dist/context/SchemaRendererContext.js +4 -2
- package/dist/context/ThemeContext.d.ts +94 -0
- package/dist/context/ThemeContext.js +215 -0
- package/dist/context/index.d.ts +2 -0
- package/dist/context/index.js +2 -0
- package/dist/hooks/index.d.ts +5 -0
- package/dist/hooks/index.js +5 -0
- package/dist/hooks/useActionRunner.d.ts +21 -7
- package/dist/hooks/useActionRunner.js +27 -7
- 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/useNavigationOverlay.d.ts +78 -0
- package/dist/hooks/useNavigationOverlay.js +112 -0
- package/dist/hooks/usePageVariables.d.ts +67 -0
- package/dist/hooks/usePageVariables.js +109 -0
- package/dist/hooks/useViewData.d.ts +73 -0
- package/dist/hooks/useViewData.js +135 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/utils/i18n.d.ts +10 -0
- package/dist/utils/i18n.js +12 -0
- package/package.json +16 -6
- package/src/LazyPluginLoader.tsx +174 -0
- package/src/SchemaRenderer.tsx +64 -12
- package/src/__tests__/LazyPluginLoader.test.tsx +176 -0
- package/src/__tests__/ThemeProvider.test.tsx +451 -0
- package/src/__tests__/useNavigationOverlay.test.ts +416 -0
- package/src/__tests__/usePageVariables.test.tsx +334 -0
- package/src/__tests__/useViewData.test.tsx +422 -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/SchemaRendererContext.tsx +5 -2
- package/src/context/ThemeContext.tsx +325 -0
- package/src/context/__tests__/ActionContext.test.tsx +174 -0
- package/src/context/index.ts +2 -0
- package/src/hooks/__tests__/useActionRunner.test.ts +153 -0
- package/src/hooks/__tests__/useDiscovery.test.tsx +149 -0
- package/src/hooks/__tests__/useDynamicApp.test.ts +161 -0
- package/src/hooks/__tests__/useExpression.test.ts +86 -0
- package/src/hooks/index.ts +5 -0
- package/src/hooks/useActionRunner.ts +49 -11
- package/src/hooks/useDiscovery.ts +141 -0
- package/src/hooks/useDynamicApp.ts +142 -0
- package/src/hooks/useNavigationOverlay.ts +180 -0
- package/src/hooks/usePageVariables.tsx +157 -0
- package/src/hooks/useViewData.ts +201 -0
- package/src/index.ts +22 -0
- package/src/utils/i18n.ts +10 -0
- package/tsconfig.json +1 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/vitest.config.ts +2 -0
- 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,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for useExpression and useCondition hooks
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
import { renderHook } from '@testing-library/react';
|
|
7
|
+
import { useExpression, useCondition } from '../useExpression';
|
|
8
|
+
|
|
9
|
+
describe('useExpression', () => {
|
|
10
|
+
it('returns string value directly for non-expression strings', () => {
|
|
11
|
+
const { result } = renderHook(() => useExpression('hello'));
|
|
12
|
+
|
|
13
|
+
expect(result.current).toBe('hello');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('returns number value directly', () => {
|
|
17
|
+
const { result } = renderHook(() => useExpression(42));
|
|
18
|
+
|
|
19
|
+
expect(result.current).toBe(42);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('returns boolean value directly', () => {
|
|
23
|
+
const { result } = renderHook(() => useExpression(true));
|
|
24
|
+
|
|
25
|
+
expect(result.current).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('returns null for null expressions', () => {
|
|
29
|
+
const { result } = renderHook(() => useExpression(null));
|
|
30
|
+
|
|
31
|
+
expect(result.current).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('returns undefined for undefined expressions', () => {
|
|
35
|
+
const { result } = renderHook(() => useExpression(undefined));
|
|
36
|
+
|
|
37
|
+
expect(result.current).toBeUndefined();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('evaluates expressions with ${...} syntax', () => {
|
|
41
|
+
const context = { data: { name: 'John' } };
|
|
42
|
+
const { result } = renderHook(() =>
|
|
43
|
+
useExpression('${data.name}', context),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
expect(result.current).toBe('John');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('evaluates expressions with context data', () => {
|
|
50
|
+
const context = { data: { age: 25 } };
|
|
51
|
+
const { result } = renderHook(() =>
|
|
52
|
+
useExpression('${data.age > 18}', context),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
expect(result.current).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('useCondition', () => {
|
|
60
|
+
it('returns true for boolean true', () => {
|
|
61
|
+
const { result } = renderHook(() => useCondition(true));
|
|
62
|
+
|
|
63
|
+
expect(result.current).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('returns false for boolean false', () => {
|
|
67
|
+
const { result } = renderHook(() => useCondition(false));
|
|
68
|
+
|
|
69
|
+
expect(result.current).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('returns true for undefined', () => {
|
|
73
|
+
const { result } = renderHook(() => useCondition(undefined));
|
|
74
|
+
|
|
75
|
+
expect(result.current).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('evaluates string conditions with context', () => {
|
|
79
|
+
const context = { data: { status: 'active' } };
|
|
80
|
+
const { result } = renderHook(() =>
|
|
81
|
+
useCondition('${data.status === "active"}', context),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
expect(result.current).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
});
|
package/src/hooks/index.ts
CHANGED
|
@@ -8,4 +8,9 @@
|
|
|
8
8
|
|
|
9
9
|
export * from './useExpression';
|
|
10
10
|
export * from './useActionRunner';
|
|
11
|
+
export * from './useNavigationOverlay';
|
|
12
|
+
export * from './usePageVariables';
|
|
13
|
+
export * from './useViewData';
|
|
14
|
+
export * from './useDynamicApp';
|
|
15
|
+
export * from './useDiscovery';
|
|
11
16
|
|
|
@@ -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,141 @@
|
|
|
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, useContext } from 'react';
|
|
10
|
+
import { SchemaRendererContext } from '../context/SchemaRendererContext';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Discovery service information structure.
|
|
14
|
+
* Represents server capabilities and service status.
|
|
15
|
+
*/
|
|
16
|
+
export interface DiscoveryInfo {
|
|
17
|
+
/** Server name and version */
|
|
18
|
+
name?: string;
|
|
19
|
+
version?: string;
|
|
20
|
+
|
|
21
|
+
/** Service availability status */
|
|
22
|
+
services?: {
|
|
23
|
+
/** Authentication service status */
|
|
24
|
+
auth?: {
|
|
25
|
+
enabled: boolean;
|
|
26
|
+
status?: 'available' | 'unavailable';
|
|
27
|
+
message?: string;
|
|
28
|
+
};
|
|
29
|
+
/** Data access service status */
|
|
30
|
+
data?: {
|
|
31
|
+
enabled: boolean;
|
|
32
|
+
status?: 'available' | 'unavailable';
|
|
33
|
+
};
|
|
34
|
+
/** Metadata service status */
|
|
35
|
+
metadata?: {
|
|
36
|
+
enabled: boolean;
|
|
37
|
+
status?: 'available' | 'unavailable';
|
|
38
|
+
};
|
|
39
|
+
[key: string]: any;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** API capabilities */
|
|
43
|
+
capabilities?: string[];
|
|
44
|
+
|
|
45
|
+
/** Additional discovery metadata */
|
|
46
|
+
[key: string]: any;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Hook to access discovery information from the ObjectStack server.
|
|
51
|
+
*
|
|
52
|
+
* This hook retrieves server capabilities and service status, which can be used
|
|
53
|
+
* to conditionally enable/disable features based on server configuration.
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```tsx
|
|
57
|
+
* function App() {
|
|
58
|
+
* const { discovery, isLoading } = useDiscovery();
|
|
59
|
+
*
|
|
60
|
+
* if (isLoading) {
|
|
61
|
+
* return <LoadingScreen />;
|
|
62
|
+
* }
|
|
63
|
+
*
|
|
64
|
+
* // Check if auth is enabled on the server
|
|
65
|
+
* const authEnabled = discovery?.services?.auth?.enabled ?? true;
|
|
66
|
+
*
|
|
67
|
+
* return (
|
|
68
|
+
* <div>
|
|
69
|
+
* {authEnabled ? <AuthProvider>...</AuthProvider> : <App />}
|
|
70
|
+
* </div>
|
|
71
|
+
* );
|
|
72
|
+
* }
|
|
73
|
+
* ```
|
|
74
|
+
*
|
|
75
|
+
* @returns Discovery information and loading state
|
|
76
|
+
*/
|
|
77
|
+
export function useDiscovery() {
|
|
78
|
+
const context = useContext(SchemaRendererContext);
|
|
79
|
+
const dataSource = context?.dataSource;
|
|
80
|
+
const [discovery, setDiscovery] = useState<DiscoveryInfo | null>(null);
|
|
81
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
82
|
+
const [error, setError] = useState<Error | null>(null);
|
|
83
|
+
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
let cancelled = false;
|
|
86
|
+
|
|
87
|
+
async function fetchDiscovery() {
|
|
88
|
+
if (!dataSource) {
|
|
89
|
+
setIsLoading(false);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
setIsLoading(true);
|
|
95
|
+
|
|
96
|
+
// Check if dataSource has getDiscovery method
|
|
97
|
+
if (typeof (dataSource as any).getDiscovery === 'function') {
|
|
98
|
+
const discoveryData = await (dataSource as any).getDiscovery();
|
|
99
|
+
|
|
100
|
+
if (!cancelled) {
|
|
101
|
+
setDiscovery(discoveryData);
|
|
102
|
+
setError(null);
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
// DataSource doesn't support discovery
|
|
106
|
+
if (!cancelled) {
|
|
107
|
+
setDiscovery(null);
|
|
108
|
+
setError(null);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} catch (err) {
|
|
112
|
+
if (!cancelled) {
|
|
113
|
+
setError(err instanceof Error ? err : new Error('Failed to fetch discovery'));
|
|
114
|
+
setDiscovery(null);
|
|
115
|
+
}
|
|
116
|
+
} finally {
|
|
117
|
+
if (!cancelled) {
|
|
118
|
+
setIsLoading(false);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
fetchDiscovery();
|
|
124
|
+
|
|
125
|
+
return () => {
|
|
126
|
+
cancelled = true;
|
|
127
|
+
};
|
|
128
|
+
}, [dataSource]);
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
discovery,
|
|
132
|
+
isLoading,
|
|
133
|
+
error,
|
|
134
|
+
/**
|
|
135
|
+
* Check if authentication is enabled on the server.
|
|
136
|
+
* Defaults to true if discovery data is not available.
|
|
137
|
+
*/
|
|
138
|
+
isAuthEnabled: discovery?.services?.auth?.enabled ?? true,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
@@ -0,0 +1,142 @@
|
|
|
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, useRef } from 'react';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Options for dynamic app configuration loading.
|
|
13
|
+
*/
|
|
14
|
+
export interface DynamicAppOptions<TAppConfig = unknown> {
|
|
15
|
+
/** Application identifier to load */
|
|
16
|
+
appId: string;
|
|
17
|
+
/**
|
|
18
|
+
* Static (fallback) configuration used when the server does not
|
|
19
|
+
* provide an app definition or is unreachable.
|
|
20
|
+
*/
|
|
21
|
+
staticConfig?: TAppConfig;
|
|
22
|
+
/**
|
|
23
|
+
* Data source adapter with `getApp` and `invalidateCache` methods.
|
|
24
|
+
* Typically an ObjectStackAdapter instance.
|
|
25
|
+
*/
|
|
26
|
+
adapter?: {
|
|
27
|
+
getApp: (appId: string) => Promise<unknown | null>;
|
|
28
|
+
invalidateCache?: (key?: string) => void;
|
|
29
|
+
getObjectSchema?: (objectName: string) => Promise<unknown>;
|
|
30
|
+
};
|
|
31
|
+
/** Whether to attempt loading from the server (default: true) */
|
|
32
|
+
enabled?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Result returned by useDynamicApp.
|
|
37
|
+
*/
|
|
38
|
+
export interface DynamicAppResult<TAppConfig = unknown> {
|
|
39
|
+
/** The resolved app configuration (server or static fallback) */
|
|
40
|
+
config: TAppConfig | null;
|
|
41
|
+
/** Whether the configuration is currently loading */
|
|
42
|
+
isLoading: boolean;
|
|
43
|
+
/** Error from the most recent load attempt */
|
|
44
|
+
error: Error | null;
|
|
45
|
+
/** Whether the config was loaded from the server (vs static fallback) */
|
|
46
|
+
isServerConfig: boolean;
|
|
47
|
+
/** Re-fetch the app configuration from the server, invalidating cache */
|
|
48
|
+
refresh: () => Promise<void>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* React hook for dynamic app configuration loading.
|
|
53
|
+
*
|
|
54
|
+
* Fetches app configuration from the server via `adapter.getApp(appId)`,
|
|
55
|
+
* falling back to `staticConfig` when the server is unavailable.
|
|
56
|
+
* Supports cache-based hot-reload via the `refresh()` callback.
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* ```tsx
|
|
60
|
+
* import { useDynamicApp } from '@object-ui/react';
|
|
61
|
+
* import staticAppConfig from '../config/app.json';
|
|
62
|
+
*
|
|
63
|
+
* function App() {
|
|
64
|
+
* const { config, isLoading, refresh } = useDynamicApp({
|
|
65
|
+
* appId: 'crm',
|
|
66
|
+
* staticConfig: staticAppConfig,
|
|
67
|
+
* adapter: dataSource,
|
|
68
|
+
* });
|
|
69
|
+
*
|
|
70
|
+
* if (isLoading) return <LoadingScreen />;
|
|
71
|
+
* return <Console config={config} onRefresh={refresh} />;
|
|
72
|
+
* }
|
|
73
|
+
* ```
|
|
74
|
+
*/
|
|
75
|
+
export function useDynamicApp<TAppConfig = unknown>(
|
|
76
|
+
options: DynamicAppOptions<TAppConfig>,
|
|
77
|
+
): DynamicAppResult<TAppConfig> {
|
|
78
|
+
const { appId, staticConfig, adapter, enabled = true } = options;
|
|
79
|
+
|
|
80
|
+
const [config, setConfig] = useState<TAppConfig | null>(staticConfig ?? null);
|
|
81
|
+
const [isLoading, setIsLoading] = useState(enabled && !!adapter);
|
|
82
|
+
const [error, setError] = useState<Error | null>(null);
|
|
83
|
+
const [isServerConfig, setIsServerConfig] = useState(false);
|
|
84
|
+
|
|
85
|
+
// Ref to track unmount / stale requests
|
|
86
|
+
const mountedRef = useRef(true);
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
mountedRef.current = true;
|
|
89
|
+
return () => { mountedRef.current = false; };
|
|
90
|
+
}, []);
|
|
91
|
+
|
|
92
|
+
const loadConfig = useCallback(async () => {
|
|
93
|
+
if (!adapter || !enabled) {
|
|
94
|
+
setConfig(staticConfig ?? null);
|
|
95
|
+
setIsServerConfig(false);
|
|
96
|
+
setIsLoading(false);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
setIsLoading(true);
|
|
101
|
+
setError(null);
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const serverConfig = await adapter.getApp(appId);
|
|
105
|
+
|
|
106
|
+
if (!mountedRef.current) return;
|
|
107
|
+
|
|
108
|
+
if (serverConfig) {
|
|
109
|
+
setConfig(serverConfig as TAppConfig);
|
|
110
|
+
setIsServerConfig(true);
|
|
111
|
+
} else {
|
|
112
|
+
// Server returned null — fall back to static config
|
|
113
|
+
setConfig(staticConfig ?? null);
|
|
114
|
+
setIsServerConfig(false);
|
|
115
|
+
}
|
|
116
|
+
} catch (err) {
|
|
117
|
+
if (!mountedRef.current) return;
|
|
118
|
+
|
|
119
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
120
|
+
// Fall back to static config on error
|
|
121
|
+
setConfig(staticConfig ?? null);
|
|
122
|
+
setIsServerConfig(false);
|
|
123
|
+
} finally {
|
|
124
|
+
if (mountedRef.current) {
|
|
125
|
+
setIsLoading(false);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}, [adapter, appId, staticConfig, enabled]);
|
|
129
|
+
|
|
130
|
+
// Load on mount and when appId changes
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
loadConfig();
|
|
133
|
+
}, [loadConfig]);
|
|
134
|
+
|
|
135
|
+
const refresh = useCallback(async () => {
|
|
136
|
+
// Invalidate cache before re-fetching
|
|
137
|
+
adapter?.invalidateCache?.(`app:${appId}`);
|
|
138
|
+
await loadConfig();
|
|
139
|
+
}, [adapter, appId, loadConfig]);
|
|
140
|
+
|
|
141
|
+
return { config, isLoading, error, isServerConfig, refresh };
|
|
142
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
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
|
+
* useNavigationOverlay
|
|
11
|
+
*
|
|
12
|
+
* A reusable hook for handling NavigationConfig-driven row/item click behavior.
|
|
13
|
+
* Manages overlay state (drawer/modal/split/popover) and generates click handlers
|
|
14
|
+
* that respect the ViewNavigationConfig specification.
|
|
15
|
+
*
|
|
16
|
+
* Used by plugin-grid, plugin-list, plugin-detail and any component that needs
|
|
17
|
+
* NavigationConfig support.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { useState, useCallback, useMemo } from 'react';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Inline ViewNavigationConfig to avoid importing from @object-ui/types
|
|
24
|
+
* (which may not be a direct dependency of @object-ui/react).
|
|
25
|
+
* Mirrors the canonical definition in @object-ui/types/objectql.ts.
|
|
26
|
+
*/
|
|
27
|
+
export interface NavigationConfig {
|
|
28
|
+
mode: 'page' | 'drawer' | 'modal' | 'split' | 'popover' | 'new_window' | 'none';
|
|
29
|
+
view?: string;
|
|
30
|
+
preventNavigation?: boolean;
|
|
31
|
+
openNewTab?: boolean;
|
|
32
|
+
width?: string | number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type NavigationMode = NavigationConfig['mode'];
|
|
36
|
+
|
|
37
|
+
export interface UseNavigationOverlayOptions {
|
|
38
|
+
/** The navigation configuration from the schema */
|
|
39
|
+
navigation?: NavigationConfig;
|
|
40
|
+
/** Object name — used to build default URLs for page/new_window modes */
|
|
41
|
+
objectName?: string;
|
|
42
|
+
/** External onNavigate callback (e.g., from ActionProvider or parent) */
|
|
43
|
+
onNavigate?: (recordId: string | number, action?: string) => void;
|
|
44
|
+
/** External onRowClick callback — if set, takes full priority */
|
|
45
|
+
onRowClick?: (record: Record<string, unknown>) => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface NavigationOverlayState {
|
|
49
|
+
/** Whether the overlay (drawer/modal/split/popover) is open */
|
|
50
|
+
isOpen: boolean;
|
|
51
|
+
/** The record that triggered the navigation */
|
|
52
|
+
selectedRecord: Record<string, unknown> | null;
|
|
53
|
+
/** The resolved navigation mode */
|
|
54
|
+
mode: NavigationMode;
|
|
55
|
+
/** Close the overlay */
|
|
56
|
+
close: () => void;
|
|
57
|
+
/** Open the overlay with a specific record */
|
|
58
|
+
open: (record: Record<string, unknown>) => void;
|
|
59
|
+
/** Set the open state (for controlled Sheet/Dialog `onOpenChange`) */
|
|
60
|
+
setIsOpen: (open: boolean) => void;
|
|
61
|
+
/** The click handler to attach to rows/items */
|
|
62
|
+
handleClick: (record: Record<string, unknown>) => void;
|
|
63
|
+
/** The width from NavigationConfig (for drawer/modal/split sizing) */
|
|
64
|
+
width: string | number | undefined;
|
|
65
|
+
/** Whether navigation is an overlay mode (drawer/modal/split/popover) */
|
|
66
|
+
isOverlay: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Hook for NavigationConfig-driven navigation overlay.
|
|
71
|
+
*
|
|
72
|
+
* @example
|
|
73
|
+
* ```tsx
|
|
74
|
+
* const { handleClick, isOpen, selectedRecord, mode, close, width } =
|
|
75
|
+
* useNavigationOverlay({
|
|
76
|
+
* navigation: schema.navigation,
|
|
77
|
+
* objectName: schema.objectName,
|
|
78
|
+
* onNavigate: schema.onNavigate,
|
|
79
|
+
* onRowClick: props.onRowClick,
|
|
80
|
+
* });
|
|
81
|
+
*
|
|
82
|
+
* return (
|
|
83
|
+
* <>
|
|
84
|
+
* <DataTable onRowClick={handleClick} ... />
|
|
85
|
+
* {isOpen && mode === 'drawer' && (
|
|
86
|
+
* <Sheet open onOpenChange={() => close()}>
|
|
87
|
+
* <SheetContent style={{ maxWidth: width }}>
|
|
88
|
+
* <RecordDetail record={selectedRecord} />
|
|
89
|
+
* </SheetContent>
|
|
90
|
+
* </Sheet>
|
|
91
|
+
* )}
|
|
92
|
+
* </>
|
|
93
|
+
* );
|
|
94
|
+
* ```
|
|
95
|
+
*/
|
|
96
|
+
export function useNavigationOverlay(
|
|
97
|
+
options: UseNavigationOverlayOptions
|
|
98
|
+
): NavigationOverlayState {
|
|
99
|
+
const { navigation, objectName, onNavigate, onRowClick } = options;
|
|
100
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
101
|
+
const [selectedRecord, setSelectedRecord] = useState<Record<string, unknown> | null>(null);
|
|
102
|
+
|
|
103
|
+
const mode: NavigationMode = navigation?.mode ?? 'page';
|
|
104
|
+
const width = navigation?.width;
|
|
105
|
+
const isOverlay = mode === 'drawer' || mode === 'modal' || mode === 'split' || mode === 'popover';
|
|
106
|
+
|
|
107
|
+
const close = useCallback(() => {
|
|
108
|
+
setIsOpen(false);
|
|
109
|
+
setSelectedRecord(null);
|
|
110
|
+
}, []);
|
|
111
|
+
|
|
112
|
+
const open = useCallback((record: Record<string, unknown>) => {
|
|
113
|
+
setSelectedRecord(record);
|
|
114
|
+
setIsOpen(true);
|
|
115
|
+
}, []);
|
|
116
|
+
|
|
117
|
+
const handleClick = useCallback(
|
|
118
|
+
(record: Record<string, unknown>) => {
|
|
119
|
+
// External onRowClick takes full priority
|
|
120
|
+
if (onRowClick) {
|
|
121
|
+
onRowClick(record);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// No navigation config — default to page navigation
|
|
126
|
+
if (!navigation) {
|
|
127
|
+
const recordId = record._id || record.id;
|
|
128
|
+
if (onNavigate && recordId != null) {
|
|
129
|
+
onNavigate(recordId as string | number, 'view');
|
|
130
|
+
}
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 'none' or preventNavigation — do nothing
|
|
135
|
+
if (mode === 'none' || navigation.preventNavigation) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// new_window / openNewTab — open in new browser tab
|
|
140
|
+
if (mode === 'new_window' || navigation.openNewTab) {
|
|
141
|
+
const recordId = record._id || record.id;
|
|
142
|
+
const url = objectName ? `/${objectName}/${recordId}` : `/${recordId}`;
|
|
143
|
+
window.open(url, '_blank');
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// page — delegate to onNavigate callback
|
|
148
|
+
if (mode === 'page') {
|
|
149
|
+
const recordId = record._id || record.id;
|
|
150
|
+
if (onNavigate && recordId != null) {
|
|
151
|
+
onNavigate(recordId as string | number, 'view');
|
|
152
|
+
}
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Overlay modes: drawer, modal, split, popover
|
|
157
|
+
if (isOverlay) {
|
|
158
|
+
setSelectedRecord(record);
|
|
159
|
+
setIsOpen(true);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
[onRowClick, navigation, mode, objectName, onNavigate, isOverlay]
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
return useMemo(
|
|
167
|
+
() => ({
|
|
168
|
+
isOpen,
|
|
169
|
+
selectedRecord,
|
|
170
|
+
mode,
|
|
171
|
+
close,
|
|
172
|
+
open,
|
|
173
|
+
setIsOpen,
|
|
174
|
+
handleClick,
|
|
175
|
+
width,
|
|
176
|
+
isOverlay,
|
|
177
|
+
}),
|
|
178
|
+
[isOpen, selectedRecord, mode, close, open, handleClick, width, isOverlay]
|
|
179
|
+
);
|
|
180
|
+
}
|