@object-ui/react 0.3.0 → 0.5.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 +8 -0
- package/dist/LazyPluginLoader.d.ts +38 -0
- package/dist/LazyPluginLoader.js +35 -0
- package/dist/SchemaRenderer.d.ts +7 -0
- package/dist/SchemaRenderer.js +51 -12
- package/dist/__tests__/LazyPluginLoader.test.d.ts +8 -0
- package/dist/__tests__/LazyPluginLoader.test.js +49 -0
- package/dist/components/form/FieldFactory.d.ts +43 -0
- package/dist/components/form/FieldFactory.js +157 -0
- package/dist/components/form/FormRenderer.d.ts +40 -0
- package/dist/components/form/FormRenderer.js +87 -0
- package/dist/components/form/FormRenderer.test.d.ts +8 -0
- package/dist/components/form/FormRenderer.test.js +534 -0
- package/dist/components/form/index.d.ts +11 -0
- package/dist/components/form/index.js +9 -0
- package/dist/context/SchemaRendererContext.d.ts +14 -0
- package/dist/context/SchemaRendererContext.js +23 -0
- package/dist/context/index.d.ts +8 -1
- package/dist/context/index.js +8 -1
- package/dist/hooks/index.d.ts +9 -1
- package/dist/hooks/index.js +9 -1
- package/dist/hooks/useActionRunner.d.ts +32 -0
- package/dist/hooks/useActionRunner.js +65 -0
- package/dist/hooks/useExpression.d.ts +27 -0
- package/dist/hooks/useExpression.js +41 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/index.test.d.ts +7 -0
- package/dist/index.test.js +7 -0
- package/package.json +26 -6
- package/src/LazyPluginLoader.tsx +52 -0
- package/src/SchemaRenderer.tsx +72 -12
- package/src/__tests__/LazyPluginLoader.test.tsx +80 -0
- package/src/components/form/FieldFactory.tsx +504 -0
- package/src/components/form/FormRenderer.test.tsx +599 -0
- package/src/components/form/FormRenderer.tsx +209 -0
- package/src/components/form/README.md +352 -0
- package/src/components/form/index.ts +12 -0
- package/src/context/SchemaRendererContext.tsx +43 -0
- package/src/context/index.ts +9 -1
- package/src/hooks/index.ts +11 -1
- package/src/hooks/useActionRunner.ts +81 -0
- package/src/hooks/useExpression.ts +56 -0
- package/src/index.test.ts +8 -0
- package/src/index.ts +10 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/vitest.config.ts +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -0,0 +1,38 @@
|
|
|
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
|
+
import React from 'react';
|
|
9
|
+
export interface LazyPluginOptions {
|
|
10
|
+
/**
|
|
11
|
+
* Fallback component to show while loading
|
|
12
|
+
*/
|
|
13
|
+
fallback?: React.ReactNode;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Create a lazy-loaded plugin component with Suspense wrapper
|
|
17
|
+
*
|
|
18
|
+
* @param importFn - Dynamic import function that returns a module with default export
|
|
19
|
+
* @param options - Configuration options for the lazy plugin
|
|
20
|
+
* @returns A component that lazy loads the plugin
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```tsx
|
|
24
|
+
* // Basic usage
|
|
25
|
+
* const ObjectGrid = createLazyPlugin(
|
|
26
|
+
* () => import('@object-ui/plugin-grid')
|
|
27
|
+
* );
|
|
28
|
+
*
|
|
29
|
+
* // With custom fallback
|
|
30
|
+
* const ObjectGrid = createLazyPlugin(
|
|
31
|
+
* () => import('@object-ui/plugin-grid'),
|
|
32
|
+
* { fallback: <div>Loading grid...</div> }
|
|
33
|
+
* );
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export declare function createLazyPlugin<P extends object = any>(importFn: () => Promise<{
|
|
37
|
+
default: React.ComponentType<P>;
|
|
38
|
+
}>, options?: LazyPluginOptions): React.ComponentType<P>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* ObjectUI
|
|
4
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
5
|
+
*
|
|
6
|
+
* This source code is licensed under the MIT license found in the
|
|
7
|
+
* LICENSE file in the root directory of this source tree.
|
|
8
|
+
*/
|
|
9
|
+
import { lazy, Suspense } from 'react';
|
|
10
|
+
/**
|
|
11
|
+
* Create a lazy-loaded plugin component with Suspense wrapper
|
|
12
|
+
*
|
|
13
|
+
* @param importFn - Dynamic import function that returns a module with default export
|
|
14
|
+
* @param options - Configuration options for the lazy plugin
|
|
15
|
+
* @returns A component that lazy loads the plugin
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```tsx
|
|
19
|
+
* // Basic usage
|
|
20
|
+
* const ObjectGrid = createLazyPlugin(
|
|
21
|
+
* () => import('@object-ui/plugin-grid')
|
|
22
|
+
* );
|
|
23
|
+
*
|
|
24
|
+
* // With custom fallback
|
|
25
|
+
* const ObjectGrid = createLazyPlugin(
|
|
26
|
+
* () => import('@object-ui/plugin-grid'),
|
|
27
|
+
* { fallback: <div>Loading grid...</div> }
|
|
28
|
+
* );
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export function createLazyPlugin(importFn, options) {
|
|
32
|
+
const LazyComponent = lazy(importFn);
|
|
33
|
+
const PluginWrapper = (props) => (_jsx(Suspense, { fallback: options?.fallback || null, children: _jsx(LazyComponent, { ...props }) }));
|
|
34
|
+
return PluginWrapper;
|
|
35
|
+
}
|
package/dist/SchemaRenderer.d.ts
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
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
|
+
*/
|
|
1
8
|
import React from 'react';
|
|
2
9
|
import { SchemaNode } from '@object-ui/core';
|
|
3
10
|
export declare const SchemaRenderer: React.ForwardRefExoticComponent<Omit<{
|
package/dist/SchemaRenderer.js
CHANGED
|
@@ -1,24 +1,63 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
/**
|
|
3
|
+
* ObjectUI
|
|
4
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
5
|
+
*
|
|
6
|
+
* This source code is licensed under the MIT license found in the
|
|
7
|
+
* LICENSE file in the root directory of this source tree.
|
|
8
|
+
*/
|
|
9
|
+
import React, { forwardRef, useContext, useMemo } from 'react';
|
|
10
|
+
import { ComponentRegistry, ExpressionEvaluator } from '@object-ui/core';
|
|
11
|
+
import { SchemaRendererContext } from './context/SchemaRendererContext';
|
|
4
12
|
export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
|
|
5
|
-
|
|
13
|
+
const context = useContext(SchemaRendererContext);
|
|
14
|
+
const dataSource = context?.dataSource || {};
|
|
15
|
+
// Evaluate schema expressions against the data source
|
|
16
|
+
const evaluatedSchema = useMemo(() => {
|
|
17
|
+
if (!schema || typeof schema === 'string')
|
|
18
|
+
return schema;
|
|
19
|
+
const evaluator = new ExpressionEvaluator({ data: dataSource });
|
|
20
|
+
// Shallow copy
|
|
21
|
+
const newSchema = { ...schema };
|
|
22
|
+
// COMPAT: Hoist 'properties' up to schema level
|
|
23
|
+
// This allows support for strict configs that wrap all props in 'properties'
|
|
24
|
+
if (newSchema.properties) {
|
|
25
|
+
Object.assign(newSchema, newSchema.properties);
|
|
26
|
+
}
|
|
27
|
+
// Evaluate 'content' (common in Text, Button)
|
|
28
|
+
if (typeof newSchema.content === 'string') {
|
|
29
|
+
newSchema.content = evaluator.evaluate(newSchema.content);
|
|
30
|
+
}
|
|
31
|
+
// Evaluate 'props'
|
|
32
|
+
if (newSchema.props) {
|
|
33
|
+
const newProps = { ...newSchema.props };
|
|
34
|
+
for (const [key, val] of Object.entries(newProps)) {
|
|
35
|
+
newProps[key] = evaluator.evaluate(val);
|
|
36
|
+
}
|
|
37
|
+
newSchema.props = newProps;
|
|
38
|
+
}
|
|
39
|
+
return newSchema;
|
|
40
|
+
}, [schema, dataSource]);
|
|
41
|
+
if (!evaluatedSchema)
|
|
6
42
|
return null;
|
|
7
43
|
// If schema is just a string, render it as text
|
|
8
|
-
if (typeof
|
|
9
|
-
return _jsx(_Fragment, { children:
|
|
10
|
-
const Component = ComponentRegistry.get(
|
|
44
|
+
if (typeof evaluatedSchema === 'string')
|
|
45
|
+
return _jsx(_Fragment, { children: evaluatedSchema });
|
|
46
|
+
const Component = ComponentRegistry.get(evaluatedSchema.type);
|
|
11
47
|
if (!Component) {
|
|
12
|
-
return (_jsxs("div", { className: "p-4 border border-red-500 rounded text-red-500 bg-red-50 my-2", children: ["Unknown component type: ", _jsx("strong", { children:
|
|
48
|
+
return (_jsxs("div", { className: "p-4 border border-red-500 rounded text-red-500 bg-red-50 my-2", children: ["Unknown component type: ", _jsx("strong", { children: evaluatedSchema.type }), _jsx("pre", { className: "text-xs mt-2 overflow-auto", children: JSON.stringify(evaluatedSchema, null, 2) })] }));
|
|
13
49
|
}
|
|
14
50
|
// Note: We don't forward the ref to the Component because components in the registry
|
|
15
51
|
// may not support refs. The SchemaRenderer itself can still receive refs for its own use.
|
|
52
|
+
// Extract schema metadata properties that should NOT be passed as React props
|
|
53
|
+
const { type: _type, children: _children, body: _body, schema: _schema, visible: _visible, visibleOn: _visibleOn, hidden: _hidden, hiddenOn: _hiddenOn, disabled: _disabled, disabledOn: _disabledOn, ...componentProps } = evaluatedSchema;
|
|
16
54
|
return React.createElement(Component, {
|
|
17
|
-
schema,
|
|
18
|
-
...
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
'data-obj-
|
|
55
|
+
schema: evaluatedSchema,
|
|
56
|
+
...componentProps, // Spread non-metadata schema properties as props
|
|
57
|
+
...(evaluatedSchema.props || {}), // Override with explicit props if provided
|
|
58
|
+
className: evaluatedSchema.className,
|
|
59
|
+
'data-obj-id': evaluatedSchema.id,
|
|
60
|
+
'data-obj-type': evaluatedSchema.type,
|
|
22
61
|
...props
|
|
23
62
|
});
|
|
24
63
|
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* ObjectUI
|
|
4
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
5
|
+
*
|
|
6
|
+
* This source code is licensed under the MIT license found in the
|
|
7
|
+
* LICENSE file in the root directory of this source tree.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
10
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
11
|
+
import { createLazyPlugin } from '../LazyPluginLoader';
|
|
12
|
+
describe('createLazyPlugin', () => {
|
|
13
|
+
it('should create a lazy-loaded component', async () => {
|
|
14
|
+
const TestComponent = () => _jsx("div", { children: "Test Component" });
|
|
15
|
+
const LazyComponent = createLazyPlugin(() => Promise.resolve({ default: TestComponent }));
|
|
16
|
+
render(_jsx(LazyComponent, {}));
|
|
17
|
+
await waitFor(() => {
|
|
18
|
+
expect(screen.getByText('Test Component')).toBeInTheDocument();
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
it('should show custom fallback while loading', async () => {
|
|
22
|
+
const TestComponent = () => _jsx("div", { children: "Test Component" });
|
|
23
|
+
const LazyComponent = createLazyPlugin(() => new Promise((resolve) => {
|
|
24
|
+
setTimeout(() => resolve({ default: TestComponent }), 100);
|
|
25
|
+
}), { fallback: _jsx("div", { children: "Loading..." }) });
|
|
26
|
+
render(_jsx(LazyComponent, {}));
|
|
27
|
+
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
|
28
|
+
await waitFor(() => {
|
|
29
|
+
expect(screen.getByText('Test Component')).toBeInTheDocument();
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
it('should pass props to the lazy component', async () => {
|
|
33
|
+
const TestComponent = ({ message }) => _jsx("div", { children: message });
|
|
34
|
+
const LazyComponent = createLazyPlugin(() => Promise.resolve({ default: TestComponent }));
|
|
35
|
+
render(_jsx(LazyComponent, { message: "Hello World" }));
|
|
36
|
+
await waitFor(() => {
|
|
37
|
+
expect(screen.getByText('Hello World')).toBeInTheDocument();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
it('should handle loading errors gracefully', async () => {
|
|
41
|
+
// Mock console.error to avoid noise in test output
|
|
42
|
+
const originalError = console.error;
|
|
43
|
+
console.error = vi.fn();
|
|
44
|
+
const LazyComponent = createLazyPlugin(() => Promise.reject(new Error('Load error')));
|
|
45
|
+
expect(() => render(_jsx(LazyComponent, {}))).not.toThrow();
|
|
46
|
+
// Restore console.error
|
|
47
|
+
console.error = originalError;
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
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
|
+
import React from 'react';
|
|
9
|
+
import { UseFormReturn } from 'react-hook-form';
|
|
10
|
+
import type { FormField } from '@objectstack/spec/ui';
|
|
11
|
+
/**
|
|
12
|
+
* Extended form field with additional properties for complex widgets
|
|
13
|
+
* These properties are not part of the standard FormField schema but may be
|
|
14
|
+
* provided by specific implementations
|
|
15
|
+
*/
|
|
16
|
+
export interface ExtendedFormField extends FormField {
|
|
17
|
+
multiple?: boolean;
|
|
18
|
+
accept?: string[];
|
|
19
|
+
options?: Array<{
|
|
20
|
+
label: string;
|
|
21
|
+
value: string;
|
|
22
|
+
disabled?: boolean;
|
|
23
|
+
}>;
|
|
24
|
+
}
|
|
25
|
+
export interface FieldFactoryProps {
|
|
26
|
+
/**
|
|
27
|
+
* Field configuration from FormFieldSchema
|
|
28
|
+
*/
|
|
29
|
+
field: FormField;
|
|
30
|
+
/**
|
|
31
|
+
* React Hook Form methods
|
|
32
|
+
*/
|
|
33
|
+
methods: UseFormReturn<any>;
|
|
34
|
+
/**
|
|
35
|
+
* Whether the field is disabled
|
|
36
|
+
*/
|
|
37
|
+
disabled?: boolean;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* FieldFactory component that renders different input types based on
|
|
41
|
+
* the widget property or field type
|
|
42
|
+
*/
|
|
43
|
+
export declare const FieldFactory: React.FC<FieldFactoryProps>;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* FieldFactory component that renders different input types based on
|
|
4
|
+
* the widget property or field type
|
|
5
|
+
*/
|
|
6
|
+
export const FieldFactory = ({ field, methods, disabled = false, }) => {
|
|
7
|
+
const { register, formState: { errors } } = methods;
|
|
8
|
+
// Cast to extended field for properties not in base schema
|
|
9
|
+
const extendedField = field;
|
|
10
|
+
// Determine the widget type
|
|
11
|
+
const widgetType = field.widget || 'text';
|
|
12
|
+
const fieldName = field.field;
|
|
13
|
+
const error = errors[fieldName];
|
|
14
|
+
// Helper function to handle multiple select value conversion
|
|
15
|
+
const handleMultipleSelectValue = (value) => {
|
|
16
|
+
if (extendedField.multiple && value instanceof HTMLCollection) {
|
|
17
|
+
return Array.from(value).map((opt) => opt.value);
|
|
18
|
+
}
|
|
19
|
+
return value;
|
|
20
|
+
};
|
|
21
|
+
// Handle conditional visibility
|
|
22
|
+
// Note: visibleOn expression evaluation is not yet implemented
|
|
23
|
+
// Fields are always visible unless explicitly hidden
|
|
24
|
+
// Skip if explicitly hidden
|
|
25
|
+
if (field.hidden) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
// Common field wrapper
|
|
29
|
+
const renderField = (input) => (_jsxs("div", { className: "space-y-2", children: [field.label && (_jsxs("label", { htmlFor: fieldName, className: "block text-sm font-medium text-gray-700", children: [field.label, field.required && _jsx("span", { className: "text-red-500 ml-1", children: "*" })] })), input, field.helpText && (_jsx("p", { className: "text-sm text-gray-500", children: field.helpText })), error && (_jsx("p", { className: "text-sm text-red-600", children: error.message }))] }));
|
|
30
|
+
// Render based on widget type
|
|
31
|
+
switch (widgetType.toLowerCase()) {
|
|
32
|
+
case 'text':
|
|
33
|
+
case 'string':
|
|
34
|
+
case 'email':
|
|
35
|
+
case 'password':
|
|
36
|
+
case 'url':
|
|
37
|
+
case 'tel':
|
|
38
|
+
return renderField(_jsx("input", { id: fieldName, type: widgetType === 'string' ? 'text' : widgetType, placeholder: field.placeholder, 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, {
|
|
39
|
+
required: field.required ? `${field.label || fieldName} is required` : false,
|
|
40
|
+
}) }));
|
|
41
|
+
case 'number':
|
|
42
|
+
case 'integer':
|
|
43
|
+
case 'float':
|
|
44
|
+
return renderField(_jsx("input", { id: fieldName, type: "number", placeholder: field.placeholder, 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, {
|
|
45
|
+
required: field.required ? `${field.label || fieldName} is required` : false,
|
|
46
|
+
valueAsNumber: true,
|
|
47
|
+
}) }));
|
|
48
|
+
case 'checkbox':
|
|
49
|
+
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: [field.label && (_jsxs("label", { htmlFor: fieldName, className: "text-sm font-medium text-gray-700", children: [field.label, field.required && _jsx("span", { className: "text-red-500 ml-1", children: "*" })] })), field.helpText && (_jsx("p", { className: "text-sm text-gray-500 mt-1", children: field.helpText })), error && (_jsx("p", { className: "text-sm text-red-600 mt-1", children: error.message }))] })] }));
|
|
51
|
+
case 'textarea':
|
|
52
|
+
return renderField(_jsx("textarea", { id: fieldName, placeholder: field.placeholder, 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, {
|
|
53
|
+
required: field.required ? `${field.label || fieldName} is required` : false,
|
|
54
|
+
}) }));
|
|
55
|
+
case 'select':
|
|
56
|
+
case 'dropdown':
|
|
57
|
+
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 ? `${field.label || fieldName} is required` : false,
|
|
59
|
+
setValueAs: handleMultipleSelectValue,
|
|
60
|
+
}), children: [!extendedField.multiple && _jsx("option", { value: "", children: field.placeholder || 'Select an option' }), extendedField.options?.map((option) => (_jsx("option", { value: option.value, disabled: option.disabled, children: option.label }, option.value)))] }));
|
|
61
|
+
case 'date':
|
|
62
|
+
return renderField(_jsx("input", { id: fieldName, type: "date", placeholder: field.placeholder, 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, {
|
|
63
|
+
required: field.required ? `${field.label || fieldName} is required` : false,
|
|
64
|
+
}) }));
|
|
65
|
+
case 'datetime':
|
|
66
|
+
case 'datetime-local':
|
|
67
|
+
return renderField(_jsx("input", { id: fieldName, type: "datetime-local", placeholder: field.placeholder, 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, {
|
|
68
|
+
required: field.required ? `${field.label || fieldName} is required` : false,
|
|
69
|
+
}) }));
|
|
70
|
+
case 'time':
|
|
71
|
+
return renderField(_jsx("input", { id: fieldName, type: "time", placeholder: field.placeholder, 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 ? `${field.label || fieldName} is required` : false,
|
|
73
|
+
}) }));
|
|
74
|
+
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: field.placeholder, 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, {
|
|
76
|
+
required: field.required ? `${field.label || fieldName} is required` : false,
|
|
77
|
+
valueAsNumber: true,
|
|
78
|
+
}) })] }));
|
|
79
|
+
case 'percent':
|
|
80
|
+
return renderField(_jsxs("div", { className: "relative", children: [_jsx("input", { id: fieldName, type: "number", placeholder: field.placeholder, 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, {
|
|
81
|
+
required: field.required ? `${field.label || fieldName} is required` : false,
|
|
82
|
+
valueAsNumber: true,
|
|
83
|
+
}) }), _jsx("span", { className: "absolute right-3 top-2 text-gray-500", children: "%" })] }));
|
|
84
|
+
case 'phone':
|
|
85
|
+
return renderField(_jsx("input", { id: fieldName, type: "tel", placeholder: field.placeholder || '+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, {
|
|
86
|
+
required: field.required ? `${field.label || fieldName} is required` : false,
|
|
87
|
+
}) }));
|
|
88
|
+
case 'markdown':
|
|
89
|
+
return renderField(_jsx("textarea", { id: fieldName, placeholder: field.placeholder || '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, {
|
|
90
|
+
required: field.required ? `${field.label || fieldName} is required` : false,
|
|
91
|
+
}) }));
|
|
92
|
+
case 'html':
|
|
93
|
+
return renderField(_jsx("textarea", { id: fieldName, placeholder: field.placeholder || '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, {
|
|
94
|
+
required: field.required ? `${field.label || fieldName} is required` : false,
|
|
95
|
+
}) }));
|
|
96
|
+
case 'file':
|
|
97
|
+
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 ? `${field.label || fieldName} is required` : false,
|
|
99
|
+
}) }));
|
|
100
|
+
case 'image':
|
|
101
|
+
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 ? `${field.label || fieldName} is required` : false,
|
|
103
|
+
}) }));
|
|
104
|
+
case 'location':
|
|
105
|
+
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`, {
|
|
106
|
+
required: field.required ? 'Latitude is required' : false,
|
|
107
|
+
valueAsNumber: true,
|
|
108
|
+
}) }), _jsx("input", { id: `${fieldName}-lng`, type: "number", placeholder: "Longitude", 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}.lng`, {
|
|
109
|
+
required: field.required ? 'Longitude is required' : false,
|
|
110
|
+
valueAsNumber: true,
|
|
111
|
+
}) })] }));
|
|
112
|
+
case 'lookup':
|
|
113
|
+
case 'master_detail':
|
|
114
|
+
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 ? `${field.label || fieldName} is required` : false,
|
|
116
|
+
setValueAs: handleMultipleSelectValue,
|
|
117
|
+
}), children: [!extendedField.multiple && _jsx("option", { value: "", children: field.placeholder || 'Select an option' }), extendedField.options?.map((option) => (_jsx("option", { value: option.value, disabled: option.disabled, children: option.label }, option.value)))] }));
|
|
118
|
+
case 'user':
|
|
119
|
+
case 'owner':
|
|
120
|
+
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 ? `${field.label || fieldName} is required` : false,
|
|
122
|
+
setValueAs: handleMultipleSelectValue,
|
|
123
|
+
}), children: [!extendedField.multiple && _jsx("option", { value: "", children: field.placeholder || 'Select user' }), extendedField.options?.map((option) => (_jsx("option", { value: option.value, disabled: option.disabled, children: option.label }, option.value)))] }));
|
|
124
|
+
case 'formula':
|
|
125
|
+
case 'summary':
|
|
126
|
+
case 'auto_number':
|
|
127
|
+
// Read-only computed fields - display as disabled text input
|
|
128
|
+
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
|
+
case 'object':
|
|
130
|
+
return renderField(_jsx("textarea", { id: fieldName, placeholder: field.placeholder || '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, {
|
|
131
|
+
required: field.required ? `${field.label || fieldName} is required` : false,
|
|
132
|
+
validate: (value) => {
|
|
133
|
+
if (!value)
|
|
134
|
+
return true;
|
|
135
|
+
try {
|
|
136
|
+
JSON.parse(value);
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
catch (e) {
|
|
140
|
+
return 'Invalid JSON format';
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
}) }));
|
|
144
|
+
case 'vector':
|
|
145
|
+
// Vector fields are typically read-only or require specialized input
|
|
146
|
+
return renderField(_jsx("input", { id: fieldName, type: "text", placeholder: field.placeholder || '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
|
+
case 'grid':
|
|
148
|
+
// Grid fields require complex table/grid editor - placeholder for now
|
|
149
|
+
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
|
+
default:
|
|
151
|
+
// Default to text input
|
|
152
|
+
return renderField(_jsx("input", { id: fieldName, type: "text", placeholder: field.placeholder, 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, {
|
|
153
|
+
required: field.required ? `${field.label || fieldName} is required` : false,
|
|
154
|
+
}) }));
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
FieldFactory.displayName = 'FieldFactory';
|
|
@@ -0,0 +1,40 @@
|
|
|
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
|
+
import React from 'react';
|
|
9
|
+
import type { FormView } from '@objectstack/spec/ui';
|
|
10
|
+
export interface FormRendererProps {
|
|
11
|
+
/**
|
|
12
|
+
* The FormView schema from @objectstack/spec
|
|
13
|
+
*/
|
|
14
|
+
schema: FormView;
|
|
15
|
+
/**
|
|
16
|
+
* Initial form data
|
|
17
|
+
*/
|
|
18
|
+
data?: Record<string, any>;
|
|
19
|
+
/**
|
|
20
|
+
* Form submission handler
|
|
21
|
+
*/
|
|
22
|
+
onSubmit?: (data: Record<string, any>) => void | Promise<void>;
|
|
23
|
+
/**
|
|
24
|
+
* Form change handler
|
|
25
|
+
*/
|
|
26
|
+
onChange?: (data: Record<string, any>) => void;
|
|
27
|
+
/**
|
|
28
|
+
* Custom class name for the form
|
|
29
|
+
*/
|
|
30
|
+
className?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Whether the form is disabled
|
|
33
|
+
*/
|
|
34
|
+
disabled?: boolean;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* FormRenderer component that renders forms based on FormViewSchema
|
|
38
|
+
* from @objectstack/spec
|
|
39
|
+
*/
|
|
40
|
+
export declare const FormRenderer: React.FC<FormRendererProps>;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* ObjectUI
|
|
4
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
5
|
+
*
|
|
6
|
+
* This source code is licensed under the MIT license found in the
|
|
7
|
+
* LICENSE file in the root directory of this source tree.
|
|
8
|
+
*/
|
|
9
|
+
import React from 'react';
|
|
10
|
+
import { useForm, FormProvider } from 'react-hook-form';
|
|
11
|
+
import { FieldFactory } from './FieldFactory';
|
|
12
|
+
/**
|
|
13
|
+
* FormRenderer component that renders forms based on FormViewSchema
|
|
14
|
+
* from @objectstack/spec
|
|
15
|
+
*/
|
|
16
|
+
export const FormRenderer = ({ schema, data = {}, onSubmit, onChange, className = '', disabled = false, }) => {
|
|
17
|
+
// Create form methods with react-hook-form
|
|
18
|
+
const methods = useForm({
|
|
19
|
+
defaultValues: data,
|
|
20
|
+
mode: 'onChange',
|
|
21
|
+
});
|
|
22
|
+
const { handleSubmit, watch } = methods;
|
|
23
|
+
// Watch for form changes
|
|
24
|
+
React.useEffect(() => {
|
|
25
|
+
if (onChange) {
|
|
26
|
+
const subscription = watch((value) => {
|
|
27
|
+
onChange(value);
|
|
28
|
+
});
|
|
29
|
+
return () => subscription.unsubscribe();
|
|
30
|
+
}
|
|
31
|
+
}, [watch, onChange]);
|
|
32
|
+
// Handle form submission
|
|
33
|
+
const onSubmitForm = handleSubmit(async (formData) => {
|
|
34
|
+
if (onSubmit) {
|
|
35
|
+
await onSubmit(formData);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
// Render sections or fields
|
|
39
|
+
const renderContent = () => {
|
|
40
|
+
if (!schema.sections || schema.sections.length === 0) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
return (_jsx("div", { className: "space-y-6", children: schema.sections.map((section, index) => (_jsx(FormSectionRenderer, { section: section, methods: methods, disabled: disabled }, index))) }));
|
|
44
|
+
};
|
|
45
|
+
return (_jsx(FormProvider, { ...methods, children: _jsxs("form", { onSubmit: onSubmitForm, className: `space-y-6 ${className}`, children: [renderContent(), _jsx("div", { className: "flex justify-end gap-2", children: _jsx("button", { type: "submit", disabled: disabled, className: "px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed", children: "Submit" }) })] }) }));
|
|
46
|
+
};
|
|
47
|
+
// Grid column classes for different column counts
|
|
48
|
+
const GRID_COLS = {
|
|
49
|
+
1: 'grid-cols-1',
|
|
50
|
+
2: 'grid-cols-1 md:grid-cols-2',
|
|
51
|
+
3: 'grid-cols-1 md:grid-cols-3',
|
|
52
|
+
4: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
|
|
53
|
+
};
|
|
54
|
+
// Column span classes
|
|
55
|
+
const COL_SPAN = {
|
|
56
|
+
1: 'col-span-1',
|
|
57
|
+
2: 'col-span-2',
|
|
58
|
+
3: 'col-span-3',
|
|
59
|
+
4: 'col-span-4',
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Renders a form section with grid layout
|
|
63
|
+
*/
|
|
64
|
+
const FormSectionRenderer = ({ section, methods, disabled = false, }) => {
|
|
65
|
+
const [isCollapsed, setIsCollapsed] = React.useState(section.collapsed || false);
|
|
66
|
+
// Determine grid columns based on section.columns
|
|
67
|
+
const gridCols = GRID_COLS[section.columns || 1];
|
|
68
|
+
const handleToggleCollapse = () => {
|
|
69
|
+
if (section.collapsible) {
|
|
70
|
+
setIsCollapsed(!isCollapsed);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
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) => {
|
|
74
|
+
// Handle both string fields (legacy) and FormFieldSchema objects
|
|
75
|
+
const fieldName = typeof field === 'string' ? field : field.field;
|
|
76
|
+
const fieldConfig = typeof field === 'string' ? { field: fieldName } : field;
|
|
77
|
+
// Skip hidden fields
|
|
78
|
+
if (fieldConfig.hidden) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
// Calculate colSpan for grid
|
|
82
|
+
const colSpan = fieldConfig.colSpan || 1;
|
|
83
|
+
const colSpanClass = COL_SPAN[Math.min(colSpan, section.columns || 1)];
|
|
84
|
+
return (_jsx("div", { className: colSpanClass, children: _jsx(FieldFactory, { field: fieldConfig, methods: methods, disabled: disabled || fieldConfig.readonly }) }, `${fieldName}-${index}`));
|
|
85
|
+
}) }))] }));
|
|
86
|
+
};
|
|
87
|
+
FormRenderer.displayName = 'FormRenderer';
|