@object-ui/react 0.5.0 → 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 +1 -1
- package/CHANGELOG.md +13 -0
- package/dist/LazyPluginLoader.d.ts +44 -0
- package/dist/LazyPluginLoader.js +92 -3
- 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/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 +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 +6 -4
- package/src/LazyPluginLoader.tsx +129 -7
- package/src/__tests__/LazyPluginLoader.test.tsx +97 -1
- 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/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 +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
package/.turbo/turbo-build.log
CHANGED
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# @object-ui/react
|
|
2
2
|
|
|
3
|
+
## 2.0.0
|
|
4
|
+
|
|
5
|
+
### Major Changes
|
|
6
|
+
|
|
7
|
+
- b859617: Release v1.0.0 — unify all package versions to 1.0.0
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- Updated dependencies [b859617]
|
|
12
|
+
- @object-ui/types@2.0.0
|
|
13
|
+
- @object-ui/core@2.0.0
|
|
14
|
+
- @object-ui/i18n@2.0.0
|
|
15
|
+
|
|
3
16
|
## 0.3.1
|
|
4
17
|
|
|
5
18
|
### Patch Changes
|
|
@@ -11,6 +11,21 @@ export interface LazyPluginOptions {
|
|
|
11
11
|
* Fallback component to show while loading
|
|
12
12
|
*/
|
|
13
13
|
fallback?: React.ReactNode;
|
|
14
|
+
/**
|
|
15
|
+
* Number of retry attempts on load failure (default: 2)
|
|
16
|
+
*/
|
|
17
|
+
retries?: number;
|
|
18
|
+
/**
|
|
19
|
+
* Delay in ms between retries (default: 1000)
|
|
20
|
+
*/
|
|
21
|
+
retryDelay?: number;
|
|
22
|
+
/**
|
|
23
|
+
* Custom error fallback component
|
|
24
|
+
*/
|
|
25
|
+
errorFallback?: React.ComponentType<{
|
|
26
|
+
error: Error;
|
|
27
|
+
retry: () => void;
|
|
28
|
+
}>;
|
|
14
29
|
}
|
|
15
30
|
/**
|
|
16
31
|
* Create a lazy-loaded plugin component with Suspense wrapper
|
|
@@ -31,8 +46,37 @@ export interface LazyPluginOptions {
|
|
|
31
46
|
* () => import('@object-ui/plugin-grid'),
|
|
32
47
|
* { fallback: <div>Loading grid...</div> }
|
|
33
48
|
* );
|
|
49
|
+
*
|
|
50
|
+
* // With retry and error handling
|
|
51
|
+
* const ObjectGrid = createLazyPlugin(
|
|
52
|
+
* () => import('@object-ui/plugin-grid'),
|
|
53
|
+
* {
|
|
54
|
+
* retries: 3,
|
|
55
|
+
* errorFallback: ({ error, retry }) => (
|
|
56
|
+
* <div>
|
|
57
|
+
* <p>Failed to load: {error.message}</p>
|
|
58
|
+
* <button onClick={retry}>Retry</button>
|
|
59
|
+
* </div>
|
|
60
|
+
* ),
|
|
61
|
+
* }
|
|
62
|
+
* );
|
|
34
63
|
* ```
|
|
35
64
|
*/
|
|
36
65
|
export declare function createLazyPlugin<P extends object = any>(importFn: () => Promise<{
|
|
37
66
|
default: React.ComponentType<P>;
|
|
38
67
|
}>, options?: LazyPluginOptions): React.ComponentType<P>;
|
|
68
|
+
/**
|
|
69
|
+
* Preload a plugin module without rendering it.
|
|
70
|
+
* Useful for preloading plugins that will be needed soon.
|
|
71
|
+
*
|
|
72
|
+
* @param importFn - Dynamic import function
|
|
73
|
+
* @returns Promise that resolves when the module is loaded
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* ```tsx
|
|
77
|
+
* // Preload on hover
|
|
78
|
+
* const loadGrid = () => import('@object-ui/plugin-grid');
|
|
79
|
+
* <button onMouseEnter={() => preloadPlugin(loadGrid)}>Show Grid</button>
|
|
80
|
+
* ```
|
|
81
|
+
*/
|
|
82
|
+
export declare function preloadPlugin<T = unknown>(importFn: () => Promise<T>): Promise<T>;
|
package/dist/LazyPluginLoader.js
CHANGED
|
@@ -6,7 +6,55 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
6
6
|
* This source code is licensed under the MIT license found in the
|
|
7
7
|
* LICENSE file in the root directory of this source tree.
|
|
8
8
|
*/
|
|
9
|
-
import { lazy, Suspense } from 'react';
|
|
9
|
+
import { lazy, Suspense, Component } from 'react';
|
|
10
|
+
class PluginErrorBoundary extends Component {
|
|
11
|
+
constructor() {
|
|
12
|
+
super(...arguments);
|
|
13
|
+
Object.defineProperty(this, "state", {
|
|
14
|
+
enumerable: true,
|
|
15
|
+
configurable: true,
|
|
16
|
+
writable: true,
|
|
17
|
+
value: { hasError: false, error: null }
|
|
18
|
+
});
|
|
19
|
+
Object.defineProperty(this, "handleRetry", {
|
|
20
|
+
enumerable: true,
|
|
21
|
+
configurable: true,
|
|
22
|
+
writable: true,
|
|
23
|
+
value: () => {
|
|
24
|
+
this.setState({ hasError: false, error: null });
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
static getDerivedStateFromError(error) {
|
|
29
|
+
return { hasError: true, error };
|
|
30
|
+
}
|
|
31
|
+
render() {
|
|
32
|
+
if (this.state.hasError && this.state.error) {
|
|
33
|
+
const FallbackComponent = this.props.fallback;
|
|
34
|
+
if (FallbackComponent) {
|
|
35
|
+
return _jsx(FallbackComponent, { error: this.state.error, retry: this.handleRetry });
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
return this.props.children;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Create a lazy-loaded import function with retry support
|
|
44
|
+
*/
|
|
45
|
+
function createRetryImport(importFn, retries, retryDelay) {
|
|
46
|
+
return () => {
|
|
47
|
+
let attempt = 0;
|
|
48
|
+
const tryImport = () => importFn().catch((err) => {
|
|
49
|
+
attempt++;
|
|
50
|
+
if (attempt <= retries) {
|
|
51
|
+
return new Promise((resolve) => setTimeout(() => resolve(tryImport()), retryDelay));
|
|
52
|
+
}
|
|
53
|
+
throw err;
|
|
54
|
+
});
|
|
55
|
+
return tryImport();
|
|
56
|
+
};
|
|
57
|
+
}
|
|
10
58
|
/**
|
|
11
59
|
* Create a lazy-loaded plugin component with Suspense wrapper
|
|
12
60
|
*
|
|
@@ -26,10 +74,51 @@ import { lazy, Suspense } from 'react';
|
|
|
26
74
|
* () => import('@object-ui/plugin-grid'),
|
|
27
75
|
* { fallback: <div>Loading grid...</div> }
|
|
28
76
|
* );
|
|
77
|
+
*
|
|
78
|
+
* // With retry and error handling
|
|
79
|
+
* const ObjectGrid = createLazyPlugin(
|
|
80
|
+
* () => import('@object-ui/plugin-grid'),
|
|
81
|
+
* {
|
|
82
|
+
* retries: 3,
|
|
83
|
+
* errorFallback: ({ error, retry }) => (
|
|
84
|
+
* <div>
|
|
85
|
+
* <p>Failed to load: {error.message}</p>
|
|
86
|
+
* <button onClick={retry}>Retry</button>
|
|
87
|
+
* </div>
|
|
88
|
+
* ),
|
|
89
|
+
* }
|
|
90
|
+
* );
|
|
29
91
|
* ```
|
|
30
92
|
*/
|
|
31
93
|
export function createLazyPlugin(importFn, options) {
|
|
32
|
-
const
|
|
33
|
-
const
|
|
94
|
+
const { retries = 2, retryDelay = 1000, errorFallback } = options || {};
|
|
95
|
+
const retryImport = retries > 0
|
|
96
|
+
? createRetryImport(importFn, retries, retryDelay)
|
|
97
|
+
: importFn;
|
|
98
|
+
const LazyComponent = lazy(retryImport);
|
|
99
|
+
const PluginWrapper = (props) => {
|
|
100
|
+
const content = (_jsx(Suspense, { fallback: options?.fallback || null, children: _jsx(LazyComponent, { ...props }) }));
|
|
101
|
+
if (errorFallback) {
|
|
102
|
+
return (_jsx(PluginErrorBoundary, { fallback: errorFallback, children: content }));
|
|
103
|
+
}
|
|
104
|
+
return content;
|
|
105
|
+
};
|
|
34
106
|
return PluginWrapper;
|
|
35
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Preload a plugin module without rendering it.
|
|
110
|
+
* Useful for preloading plugins that will be needed soon.
|
|
111
|
+
*
|
|
112
|
+
* @param importFn - Dynamic import function
|
|
113
|
+
* @returns Promise that resolves when the module is loaded
|
|
114
|
+
*
|
|
115
|
+
* @example
|
|
116
|
+
* ```tsx
|
|
117
|
+
* // Preload on hover
|
|
118
|
+
* const loadGrid = () => import('@object-ui/plugin-grid');
|
|
119
|
+
* <button onMouseEnter={() => preloadPlugin(loadGrid)}>Show Grid</button>
|
|
120
|
+
* ```
|
|
121
|
+
*/
|
|
122
|
+
export function preloadPlugin(importFn) {
|
|
123
|
+
return importFn();
|
|
124
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { resolveI18nLabel } from '../../utils/i18n';
|
|
2
3
|
/**
|
|
3
4
|
* FieldFactory component that renders different input types based on
|
|
4
5
|
* the widget property or field type
|
|
@@ -11,6 +12,10 @@ export const FieldFactory = ({ field, methods, disabled = false, }) => {
|
|
|
11
12
|
const widgetType = field.widget || 'text';
|
|
12
13
|
const fieldName = field.field;
|
|
13
14
|
const error = errors[fieldName];
|
|
15
|
+
// Resolve I18nLabel values to plain strings
|
|
16
|
+
const fieldLabel = resolveI18nLabel(field.label);
|
|
17
|
+
const fieldPlaceholder = resolveI18nLabel(field.placeholder);
|
|
18
|
+
const fieldHelpText = resolveI18nLabel(field.helpText);
|
|
14
19
|
// Helper function to handle multiple select value conversion
|
|
15
20
|
const handleMultipleSelectValue = (value) => {
|
|
16
21
|
if (extendedField.multiple && value instanceof HTMLCollection) {
|
|
@@ -19,14 +24,13 @@ export const FieldFactory = ({ field, methods, disabled = false, }) => {
|
|
|
19
24
|
return value;
|
|
20
25
|
};
|
|
21
26
|
// Handle conditional visibility
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
// Skip if explicitly hidden
|
|
27
|
+
// Fields are hidden when explicitly hidden or when visibleOn evaluates to false
|
|
28
|
+
// Note: dependsOn is handled at the FormSectionRenderer level
|
|
25
29
|
if (field.hidden) {
|
|
26
30
|
return null;
|
|
27
31
|
}
|
|
28
32
|
// Common field wrapper
|
|
29
|
-
const renderField = (input) => (_jsxs("div", { className: "space-y-2", children: [
|
|
33
|
+
const renderField = (input) => (_jsxs("div", { className: "space-y-2", children: [fieldLabel && (_jsxs("label", { htmlFor: fieldName, className: "block text-sm font-medium text-gray-700", children: [fieldLabel, field.required && _jsx("span", { className: "text-red-500 ml-1", children: "*" })] })), input, fieldHelpText && (_jsx("p", { className: "text-sm text-gray-500", children: fieldHelpText })), error && (_jsx("p", { className: "text-sm text-red-600", children: error.message }))] }));
|
|
30
34
|
// Render based on widget type
|
|
31
35
|
switch (widgetType.toLowerCase()) {
|
|
32
36
|
case 'text':
|
|
@@ -35,71 +39,71 @@ export const FieldFactory = ({ field, methods, disabled = false, }) => {
|
|
|
35
39
|
case 'password':
|
|
36
40
|
case 'url':
|
|
37
41
|
case 'tel':
|
|
38
|
-
return renderField(_jsx("input", { id: fieldName, type: widgetType === 'string' ? 'text' : widgetType, placeholder:
|
|
39
|
-
required: field.required ? `${
|
|
42
|
+
return renderField(_jsx("input", { id: fieldName, type: widgetType === 'string' ? 'text' : widgetType, placeholder: fieldPlaceholder, disabled: disabled, className: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed", ...register(fieldName, {
|
|
43
|
+
required: field.required ? `${fieldLabel || fieldName} is required` : false,
|
|
40
44
|
}) }));
|
|
41
45
|
case 'number':
|
|
42
46
|
case 'integer':
|
|
43
47
|
case 'float':
|
|
44
|
-
return renderField(_jsx("input", { id: fieldName, type: "number", placeholder:
|
|
45
|
-
required: field.required ? `${
|
|
48
|
+
return renderField(_jsx("input", { id: fieldName, type: "number", placeholder: fieldPlaceholder, disabled: disabled, step: widgetType === 'integer' ? '1' : 'any', className: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed", ...register(fieldName, {
|
|
49
|
+
required: field.required ? `${fieldLabel || fieldName} is required` : false,
|
|
46
50
|
valueAsNumber: true,
|
|
47
51
|
}) }));
|
|
48
52
|
case 'checkbox':
|
|
49
53
|
case 'boolean':
|
|
50
|
-
return (_jsxs("div", { className: "flex items-start space-x-2", children: [_jsx("input", { id: fieldName, type: "checkbox", disabled: disabled, className: "mt-1 h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-2 focus:ring-blue-500 disabled:opacity-50", ...register(fieldName) }), _jsxs("div", { className: "flex-1", children: [
|
|
54
|
+
return (_jsxs("div", { className: "flex items-start space-x-2", children: [_jsx("input", { id: fieldName, type: "checkbox", disabled: disabled, className: "mt-1 h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-2 focus:ring-blue-500 disabled:opacity-50", ...register(fieldName) }), _jsxs("div", { className: "flex-1", children: [fieldLabel && (_jsxs("label", { htmlFor: fieldName, className: "text-sm font-medium text-gray-700", children: [fieldLabel, field.required && _jsx("span", { className: "text-red-500 ml-1", children: "*" })] })), fieldHelpText && (_jsx("p", { className: "text-sm text-gray-500 mt-1", children: fieldHelpText })), error && (_jsx("p", { className: "text-sm text-red-600 mt-1", children: error.message }))] })] }));
|
|
51
55
|
case 'textarea':
|
|
52
|
-
return renderField(_jsx("textarea", { id: fieldName, placeholder:
|
|
53
|
-
required: field.required ? `${
|
|
56
|
+
return renderField(_jsx("textarea", { id: fieldName, placeholder: fieldPlaceholder, disabled: disabled, rows: 4, className: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed", ...register(fieldName, {
|
|
57
|
+
required: field.required ? `${fieldLabel || fieldName} is required` : false,
|
|
54
58
|
}) }));
|
|
55
59
|
case 'select':
|
|
56
60
|
case 'dropdown':
|
|
57
61
|
return renderField(_jsxs("select", { id: fieldName, disabled: disabled, multiple: extendedField.multiple, className: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed", ...register(fieldName, {
|
|
58
|
-
required: field.required ? `${
|
|
62
|
+
required: field.required ? `${fieldLabel || fieldName} is required` : false,
|
|
59
63
|
setValueAs: handleMultipleSelectValue,
|
|
60
|
-
}), children: [!extendedField.multiple && _jsx("option", { value: "", children:
|
|
64
|
+
}), children: [!extendedField.multiple && _jsx("option", { value: "", children: fieldPlaceholder || 'Select an option' }), extendedField.options?.map((option) => (_jsx("option", { value: option.value, disabled: option.disabled, children: option.label }, option.value)))] }));
|
|
61
65
|
case 'date':
|
|
62
|
-
return renderField(_jsx("input", { id: fieldName, type: "date", placeholder:
|
|
63
|
-
required: field.required ? `${
|
|
66
|
+
return renderField(_jsx("input", { id: fieldName, type: "date", placeholder: fieldPlaceholder, disabled: disabled, className: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed", ...register(fieldName, {
|
|
67
|
+
required: field.required ? `${fieldLabel || fieldName} is required` : false,
|
|
64
68
|
}) }));
|
|
65
69
|
case 'datetime':
|
|
66
70
|
case 'datetime-local':
|
|
67
|
-
return renderField(_jsx("input", { id: fieldName, type: "datetime-local", placeholder:
|
|
68
|
-
required: field.required ? `${
|
|
71
|
+
return renderField(_jsx("input", { id: fieldName, type: "datetime-local", placeholder: fieldPlaceholder, disabled: disabled, className: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed", ...register(fieldName, {
|
|
72
|
+
required: field.required ? `${fieldLabel || fieldName} is required` : false,
|
|
69
73
|
}) }));
|
|
70
74
|
case 'time':
|
|
71
|
-
return renderField(_jsx("input", { id: fieldName, type: "time", placeholder:
|
|
72
|
-
required: field.required ? `${
|
|
75
|
+
return renderField(_jsx("input", { id: fieldName, type: "time", placeholder: fieldPlaceholder, disabled: disabled, className: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed", ...register(fieldName, {
|
|
76
|
+
required: field.required ? `${fieldLabel || fieldName} is required` : false,
|
|
73
77
|
}) }));
|
|
74
78
|
case 'currency':
|
|
75
|
-
return renderField(_jsxs("div", { className: "relative", children: [_jsx("span", { className: "absolute left-3 top-2 text-gray-500", children: "$" }), _jsx("input", { id: fieldName, type: "number", placeholder:
|
|
76
|
-
required: field.required ? `${
|
|
79
|
+
return renderField(_jsxs("div", { className: "relative", children: [_jsx("span", { className: "absolute left-3 top-2 text-gray-500", children: "$" }), _jsx("input", { id: fieldName, type: "number", placeholder: fieldPlaceholder, disabled: disabled, step: "0.01", className: "w-full pl-8 pr-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed", ...register(fieldName, {
|
|
80
|
+
required: field.required ? `${fieldLabel || fieldName} is required` : false,
|
|
77
81
|
valueAsNumber: true,
|
|
78
82
|
}) })] }));
|
|
79
83
|
case 'percent':
|
|
80
|
-
return renderField(_jsxs("div", { className: "relative", children: [_jsx("input", { id: fieldName, type: "number", placeholder:
|
|
81
|
-
required: field.required ? `${
|
|
84
|
+
return renderField(_jsxs("div", { className: "relative", children: [_jsx("input", { id: fieldName, type: "number", placeholder: fieldPlaceholder, disabled: disabled, step: "0.01", min: "0", max: "100", className: "w-full pr-8 pl-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed", ...register(fieldName, {
|
|
85
|
+
required: field.required ? `${fieldLabel || fieldName} is required` : false,
|
|
82
86
|
valueAsNumber: true,
|
|
83
87
|
}) }), _jsx("span", { className: "absolute right-3 top-2 text-gray-500", children: "%" })] }));
|
|
84
88
|
case 'phone':
|
|
85
|
-
return renderField(_jsx("input", { id: fieldName, type: "tel", placeholder:
|
|
86
|
-
required: field.required ? `${
|
|
89
|
+
return renderField(_jsx("input", { id: fieldName, type: "tel", placeholder: fieldPlaceholder || '+1 (555) 000-0000', disabled: disabled, className: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed", ...register(fieldName, {
|
|
90
|
+
required: field.required ? `${fieldLabel || fieldName} is required` : false,
|
|
87
91
|
}) }));
|
|
88
92
|
case 'markdown':
|
|
89
|
-
return renderField(_jsx("textarea", { id: fieldName, placeholder:
|
|
90
|
-
required: field.required ? `${
|
|
93
|
+
return renderField(_jsx("textarea", { id: fieldName, placeholder: fieldPlaceholder || 'Enter markdown text...', disabled: disabled, rows: 8, className: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed font-mono", ...register(fieldName, {
|
|
94
|
+
required: field.required ? `${fieldLabel || fieldName} is required` : false,
|
|
91
95
|
}) }));
|
|
92
96
|
case 'html':
|
|
93
|
-
return renderField(_jsx("textarea", { id: fieldName, placeholder:
|
|
94
|
-
required: field.required ? `${
|
|
97
|
+
return renderField(_jsx("textarea", { id: fieldName, placeholder: fieldPlaceholder || 'Enter HTML...', disabled: disabled, rows: 8, className: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed font-mono", ...register(fieldName, {
|
|
98
|
+
required: field.required ? `${fieldLabel || fieldName} is required` : false,
|
|
95
99
|
}) }));
|
|
96
100
|
case 'file':
|
|
97
101
|
return renderField(_jsx("input", { id: fieldName, type: "file", disabled: disabled, multiple: extendedField.multiple, accept: extendedField.accept?.join(','), className: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100", ...register(fieldName, {
|
|
98
|
-
required: field.required ? `${
|
|
102
|
+
required: field.required ? `${fieldLabel || fieldName} is required` : false,
|
|
99
103
|
}) }));
|
|
100
104
|
case 'image':
|
|
101
105
|
return renderField(_jsx("input", { id: fieldName, type: "file", disabled: disabled, multiple: extendedField.multiple, accept: extendedField.accept?.join(',') || 'image/*', className: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100", ...register(fieldName, {
|
|
102
|
-
required: field.required ? `${
|
|
106
|
+
required: field.required ? `${fieldLabel || fieldName} is required` : false,
|
|
103
107
|
}) }));
|
|
104
108
|
case 'location':
|
|
105
109
|
return renderField(_jsxs("div", { className: "space-y-2", children: [_jsx("input", { id: `${fieldName}-lat`, type: "number", placeholder: "Latitude", disabled: disabled, step: "any", className: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed", ...register(`${fieldName}.lat`, {
|
|
@@ -112,23 +116,23 @@ export const FieldFactory = ({ field, methods, disabled = false, }) => {
|
|
|
112
116
|
case 'lookup':
|
|
113
117
|
case 'master_detail':
|
|
114
118
|
return renderField(_jsxs("select", { id: fieldName, disabled: disabled, multiple: extendedField.multiple, className: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed", ...register(fieldName, {
|
|
115
|
-
required: field.required ? `${
|
|
119
|
+
required: field.required ? `${fieldLabel || fieldName} is required` : false,
|
|
116
120
|
setValueAs: handleMultipleSelectValue,
|
|
117
|
-
}), children: [!extendedField.multiple && _jsx("option", { value: "", children:
|
|
121
|
+
}), children: [!extendedField.multiple && _jsx("option", { value: "", children: fieldPlaceholder || 'Select an option' }), extendedField.options?.map((option) => (_jsx("option", { value: option.value, disabled: option.disabled, children: option.label }, option.value)))] }));
|
|
118
122
|
case 'user':
|
|
119
123
|
case 'owner':
|
|
120
124
|
return renderField(_jsxs("select", { id: fieldName, disabled: disabled, multiple: extendedField.multiple, className: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed", ...register(fieldName, {
|
|
121
|
-
required: field.required ? `${
|
|
125
|
+
required: field.required ? `${fieldLabel || fieldName} is required` : false,
|
|
122
126
|
setValueAs: handleMultipleSelectValue,
|
|
123
|
-
}), children: [!extendedField.multiple && _jsx("option", { value: "", children:
|
|
127
|
+
}), children: [!extendedField.multiple && _jsx("option", { value: "", children: fieldPlaceholder || 'Select user' }), extendedField.options?.map((option) => (_jsx("option", { value: option.value, disabled: option.disabled, children: option.label }, option.value)))] }));
|
|
124
128
|
case 'formula':
|
|
125
129
|
case 'summary':
|
|
126
130
|
case 'auto_number':
|
|
127
131
|
// Read-only computed fields - display as disabled text input
|
|
128
132
|
return renderField(_jsx("input", { id: fieldName, type: "text", disabled: true, className: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-gray-50 cursor-not-allowed text-gray-600", ...register(fieldName) }));
|
|
129
133
|
case 'object':
|
|
130
|
-
return renderField(_jsx("textarea", { id: fieldName, placeholder:
|
|
131
|
-
required: field.required ? `${
|
|
134
|
+
return renderField(_jsx("textarea", { id: fieldName, placeholder: fieldPlaceholder || 'Enter JSON object...', disabled: disabled, rows: 6, className: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed font-mono text-sm", ...register(fieldName, {
|
|
135
|
+
required: field.required ? `${fieldLabel || fieldName} is required` : false,
|
|
132
136
|
validate: (value) => {
|
|
133
137
|
if (!value)
|
|
134
138
|
return true;
|
|
@@ -143,14 +147,14 @@ export const FieldFactory = ({ field, methods, disabled = false, }) => {
|
|
|
143
147
|
}) }));
|
|
144
148
|
case 'vector':
|
|
145
149
|
// Vector fields are typically read-only or require specialized input
|
|
146
|
-
return renderField(_jsx("input", { id: fieldName, type: "text", placeholder:
|
|
150
|
+
return renderField(_jsx("input", { id: fieldName, type: "text", placeholder: fieldPlaceholder || 'Vector data (read-only)', disabled: true, className: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-gray-50 cursor-not-allowed text-gray-600", ...register(fieldName) }));
|
|
147
151
|
case 'grid':
|
|
148
152
|
// Grid fields require complex table/grid editor - placeholder for now
|
|
149
153
|
return renderField(_jsx("div", { className: "w-full px-3 py-2 border border-gray-300 rounded-md bg-gray-50 text-gray-600", children: _jsx("p", { className: "text-sm", children: "Grid editor not yet implemented" }) }));
|
|
150
154
|
default:
|
|
151
155
|
// Default to text input
|
|
152
|
-
return renderField(_jsx("input", { id: fieldName, type: "text", placeholder:
|
|
153
|
-
required: field.required ? `${
|
|
156
|
+
return renderField(_jsx("input", { id: fieldName, type: "text", placeholder: fieldPlaceholder, disabled: disabled, className: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed", ...register(fieldName, {
|
|
157
|
+
required: field.required ? `${fieldLabel || fieldName} is required` : false,
|
|
154
158
|
}) }));
|
|
155
159
|
}
|
|
156
160
|
};
|
|
@@ -9,6 +9,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
9
9
|
import React from 'react';
|
|
10
10
|
import { useForm, FormProvider } from 'react-hook-form';
|
|
11
11
|
import { FieldFactory } from './FieldFactory';
|
|
12
|
+
import { ExpressionEvaluator } from '@object-ui/core';
|
|
13
|
+
import { resolveI18nLabel } from '../../utils/i18n';
|
|
12
14
|
/**
|
|
13
15
|
* FormRenderer component that renders forms based on FormViewSchema
|
|
14
16
|
* from @objectstack/spec
|
|
@@ -58,6 +60,33 @@ const COL_SPAN = {
|
|
|
58
60
|
3: 'col-span-3',
|
|
59
61
|
4: 'col-span-4',
|
|
60
62
|
};
|
|
63
|
+
/**
|
|
64
|
+
* Evaluate whether a field should be visible based on visibleOn expression
|
|
65
|
+
* and dependsOn parent field value.
|
|
66
|
+
*/
|
|
67
|
+
function evaluateFieldVisibility(fieldConfig, formValues) {
|
|
68
|
+
// If explicitly hidden, skip
|
|
69
|
+
if (fieldConfig.hidden) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
// Evaluate visibleOn expression
|
|
73
|
+
if (fieldConfig.visibleOn) {
|
|
74
|
+
try {
|
|
75
|
+
const evaluator = new ExpressionEvaluator({ data: formValues, form: formValues });
|
|
76
|
+
return evaluator.evaluateCondition(fieldConfig.visibleOn);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// On error, default to visible
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// Evaluate dependsOn: field is hidden if parent field has no value
|
|
84
|
+
if (fieldConfig.dependsOn) {
|
|
85
|
+
const parentValue = formValues[fieldConfig.dependsOn];
|
|
86
|
+
return parentValue !== undefined && parentValue !== null && parentValue !== '';
|
|
87
|
+
}
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
61
90
|
/**
|
|
62
91
|
* Renders a form section with grid layout
|
|
63
92
|
*/
|
|
@@ -65,17 +94,30 @@ const FormSectionRenderer = ({ section, methods, disabled = false, }) => {
|
|
|
65
94
|
const [isCollapsed, setIsCollapsed] = React.useState(section.collapsed || false);
|
|
66
95
|
// Determine grid columns based on section.columns
|
|
67
96
|
const gridCols = GRID_COLS[section.columns || 1];
|
|
97
|
+
// Collect whether there are any conditional fields
|
|
98
|
+
const hasConditionalFields = React.useMemo(() => {
|
|
99
|
+
return section.fields.some((field) => {
|
|
100
|
+
if (typeof field === 'string')
|
|
101
|
+
return false;
|
|
102
|
+
return !!field.dependsOn || !!field.visibleOn;
|
|
103
|
+
});
|
|
104
|
+
}, [section.fields]);
|
|
105
|
+
// Watch form values for conditional field evaluation
|
|
106
|
+
// Only watch when there are fields with dependsOn or visibleOn to avoid
|
|
107
|
+
// unnecessary re-renders in forms without conditional fields.
|
|
108
|
+
const allFormValues = methods.watch();
|
|
109
|
+
const formValues = hasConditionalFields ? allFormValues : undefined;
|
|
68
110
|
const handleToggleCollapse = () => {
|
|
69
111
|
if (section.collapsible) {
|
|
70
112
|
setIsCollapsed(!isCollapsed);
|
|
71
113
|
}
|
|
72
114
|
};
|
|
73
|
-
return (_jsxs("div", { className: "border rounded-lg p-4", children: [section.label && (_jsxs("div", { className: `flex items-center justify-between mb-4 ${section.collapsible ? 'cursor-pointer' : ''}`, onClick: handleToggleCollapse, children: [_jsx("h3", { className: "text-lg font-semibold", children: section.label }), section.collapsible && (_jsx("span", { className: "text-sm text-gray-500", children: isCollapsed ? '▶' : '▼' }))] })), !isCollapsed && (_jsx("div", { className: `grid ${gridCols} gap-4`, children: section.fields.map((field, index) => {
|
|
115
|
+
return (_jsxs("div", { className: "border rounded-lg p-4", children: [section.label && (_jsxs("div", { className: `flex items-center justify-between mb-4 ${section.collapsible ? 'cursor-pointer' : ''}`, onClick: handleToggleCollapse, children: [_jsx("h3", { className: "text-lg font-semibold", children: resolveI18nLabel(section.label) }), section.collapsible && (_jsx("span", { className: "text-sm text-gray-500", children: isCollapsed ? '▶' : '▼' }))] })), !isCollapsed && (_jsx("div", { className: `grid ${gridCols} gap-4`, children: section.fields.map((field, index) => {
|
|
74
116
|
// Handle both string fields (legacy) and FormFieldSchema objects
|
|
75
117
|
const fieldName = typeof field === 'string' ? field : field.field;
|
|
76
118
|
const fieldConfig = typeof field === 'string' ? { field: fieldName } : field;
|
|
77
|
-
//
|
|
78
|
-
if (fieldConfig
|
|
119
|
+
// Evaluate visibility: static hidden, visibleOn expression, dependsOn parent
|
|
120
|
+
if (!evaluateFieldVisibility(fieldConfig, formValues || {})) {
|
|
79
121
|
return null;
|
|
80
122
|
}
|
|
81
123
|
// Calculate colSpan for grid
|
|
@@ -0,0 +1,76 @@
|
|
|
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
|
+
* @object-ui/react - Action Context
|
|
10
|
+
*
|
|
11
|
+
* Provides a shared ActionRunner instance and handlers to the component tree.
|
|
12
|
+
* Components can consume actions without creating their own runner instances.
|
|
13
|
+
*/
|
|
14
|
+
import React from 'react';
|
|
15
|
+
import { ActionRunner, type ActionContext as ActionCtx, type ActionDef, type ActionResult, type ConfirmationHandler, type ToastHandler, type ModalHandler, type NavigationHandler, type ParamCollectionHandler } from '@object-ui/core';
|
|
16
|
+
export interface ActionProviderProps {
|
|
17
|
+
children: React.ReactNode;
|
|
18
|
+
/** Initial action context (record, user, data, etc.) */
|
|
19
|
+
context?: ActionCtx;
|
|
20
|
+
/** Custom confirmation handler (e.g., Shadcn AlertDialog) */
|
|
21
|
+
onConfirm?: ConfirmationHandler;
|
|
22
|
+
/** Custom toast handler (e.g., Sonner) */
|
|
23
|
+
onToast?: ToastHandler;
|
|
24
|
+
/** Custom modal handler — render modals in response to modal actions */
|
|
25
|
+
onModal?: ModalHandler;
|
|
26
|
+
/** Custom navigation handler — SPA-aware routing (e.g., React Router navigate) */
|
|
27
|
+
onNavigate?: NavigationHandler;
|
|
28
|
+
/** Custom param collection handler — show dialog to collect ActionParam values */
|
|
29
|
+
onParamCollection?: ParamCollectionHandler;
|
|
30
|
+
/** Pre-registered custom action handlers */
|
|
31
|
+
handlers?: Record<string, (action: ActionDef, ctx: ActionCtx) => Promise<ActionResult>>;
|
|
32
|
+
}
|
|
33
|
+
interface ActionContextValue {
|
|
34
|
+
/** Execute an action */
|
|
35
|
+
execute: (action: ActionDef) => Promise<ActionResult>;
|
|
36
|
+
/** Execute multiple actions in chain */
|
|
37
|
+
executeChain: (actions: ActionDef[], mode?: 'sequential' | 'parallel') => Promise<ActionResult>;
|
|
38
|
+
/** Whether an action is currently being executed */
|
|
39
|
+
loading: boolean;
|
|
40
|
+
/** Last error message */
|
|
41
|
+
error: string | null;
|
|
42
|
+
/** Last action result */
|
|
43
|
+
result: ActionResult | null;
|
|
44
|
+
/** The underlying ActionRunner instance */
|
|
45
|
+
runner: ActionRunner;
|
|
46
|
+
/** Update context data */
|
|
47
|
+
updateContext: (ctx: Partial<ActionCtx>) => void;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* ActionProvider — Provides a shared ActionRunner to the tree.
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* ```tsx
|
|
54
|
+
* <ActionProvider
|
|
55
|
+
* context={{ record: currentRecord, user }}
|
|
56
|
+
* onToast={(msg, opts) => toast[opts?.type ?? 'info'](msg)}
|
|
57
|
+
* onConfirm={async (msg) => showConfirmDialog(msg)}
|
|
58
|
+
* onNavigate={(url) => router.push(url)}
|
|
59
|
+
* >
|
|
60
|
+
* <SchemaRenderer schema={pageSchema} />
|
|
61
|
+
* </ActionProvider>
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
export declare const ActionProvider: React.FC<ActionProviderProps>;
|
|
65
|
+
/**
|
|
66
|
+
* Hook to consume the ActionProvider context.
|
|
67
|
+
* Returns the shared ActionRunner execute function, loading state, and more.
|
|
68
|
+
*
|
|
69
|
+
* Falls back to a local ActionRunner if no ActionProvider is present.
|
|
70
|
+
*/
|
|
71
|
+
export declare function useAction(): ActionContextValue;
|
|
72
|
+
/**
|
|
73
|
+
* Hook to check if an ActionProvider is available.
|
|
74
|
+
*/
|
|
75
|
+
export declare function useHasActionProvider(): boolean;
|
|
76
|
+
export {};
|