@serhiibudianskyi/wui 0.0.1

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.
@@ -0,0 +1,9 @@
1
+ import { UseFormRegisterReturn, Control } from 'react-hook-form';
2
+ import type { Field as FieldType } from '../types/Field';
3
+ interface FieldProps {
4
+ field: FieldType;
5
+ register: UseFormRegisterReturn;
6
+ control: Control<any>;
7
+ }
8
+ export default function Field({ field, register, control, }: FieldProps): JSX.Element;
9
+ export {};
@@ -0,0 +1,156 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useController } from 'react-hook-form';
3
+ import Select from 'react-select';
4
+ import AsyncSelect from 'react-select/async';
5
+ import CreatableSelect from 'react-select/creatable';
6
+ import AsyncCreatableSelect from 'react-select/async-creatable';
7
+ import FileField from './FileField';
8
+ export default function Field({ field, register, control, }) {
9
+ const containerClasses = [];
10
+ // Get field state from react-hook-form
11
+ const { field: controllerField, fieldState } = useController({
12
+ name: field.name,
13
+ control
14
+ });
15
+ // Base properties for the input element
16
+ const baseProps = {
17
+ ...register,
18
+ type: field.type,
19
+ id: field.name,
20
+ name: field.name,
21
+ placeholder: field.placeholder,
22
+ readOnly: field.isReadOnly,
23
+ disabled: field.isDisabled,
24
+ required: field.isRequired,
25
+ min: field.min,
26
+ max: field.max,
27
+ className: '',
28
+ ...field.attrs
29
+ };
30
+ // Adjust properties based on field type
31
+ switch (field.type) {
32
+ case 'async-select':
33
+ case 'async-creatable-select':
34
+ baseProps.loadOptions = field.loadOptions;
35
+ // fallthrough
36
+ case 'creatable-select':
37
+ case 'select':
38
+ baseProps.className = 'form-control';
39
+ baseProps.options = field.options;
40
+ baseProps.value = field.isMultiple ?
41
+ (Array.isArray(controllerField.value) ? controllerField.value : []) :
42
+ (controllerField.value ?? null);
43
+ baseProps.onChange = (option) => {
44
+ let value;
45
+ if (field.isMultiple) {
46
+ value = option ? option : [];
47
+ }
48
+ else {
49
+ value = option ? option : null;
50
+ }
51
+ controllerField.onChange(value);
52
+ };
53
+ baseProps.styles = {
54
+ container: (base) => ({
55
+ ...base,
56
+ padding: 0
57
+ }),
58
+ control: (base) => ({
59
+ ...base,
60
+ border: 'none',
61
+ boxShadow: 'none'
62
+ })
63
+ };
64
+ break;
65
+ case 'file':
66
+ baseProps.className = 'form-control';
67
+ baseProps.accept = field.accept;
68
+ baseProps.isMultiple = field.isMultiple;
69
+ if (!baseProps.isMultiple) {
70
+ containerClasses.push('col-auto');
71
+ }
72
+ baseProps.maxFiles = field.maxFiles;
73
+ baseProps.maxSize = field.maxSize;
74
+ baseProps.onChange = (fileIds) => {
75
+ controllerField.onChange(fileIds);
76
+ };
77
+ break;
78
+ case 'radio':
79
+ case 'checkbox':
80
+ baseProps.className = 'form-check-input';
81
+ containerClasses.push('mx-2', 'form-check', 'form-switch');
82
+ break;
83
+ case 'number':
84
+ baseProps.step = field.step;
85
+ // fallthrough
86
+ case 'date':
87
+ case 'datetime-local':
88
+ case 'time':
89
+ containerClasses.push('col-auto');
90
+ // fallthrough
91
+ case 'text':
92
+ case 'email':
93
+ case 'password':
94
+ default:
95
+ baseProps.className = 'form-control';
96
+ // Override onChange to include string normalization on real-time input
97
+ baseProps.onInput = async (event) => {
98
+ // Apply normalization
99
+ const normalizedValue = field.getNormalizedValue(event.target.value);
100
+ event.target.value = normalizedValue;
101
+ // Call the original onChange
102
+ return register.onChange(event);
103
+ };
104
+ break;
105
+ }
106
+ // Add validation classes
107
+ if (fieldState.error) {
108
+ baseProps.className += ' is-invalid';
109
+ }
110
+ else if (!baseProps.className.includes('form-check-input') && (fieldState.isTouched || fieldState.isDirty)) {
111
+ baseProps.className += ' is-valid';
112
+ }
113
+ // Render the label for the field
114
+ const renderLabel = () => (_jsxs("label", { htmlFor: field.name, className: 'form-label', children: [field.label, " ", field.isRequired &&
115
+ _jsx("span", { className: 'text-danger', title: 'Field is required', children: "*" })] }));
116
+ // Render the input element based on the field type
117
+ const renderInput = () => {
118
+ switch (field.type) {
119
+ case 'select':
120
+ return (_jsx(Select, { ...controllerField, ...baseProps }));
121
+ case 'async-select':
122
+ return (_jsx(AsyncSelect, { ...controllerField, ...baseProps }));
123
+ case 'creatable-select':
124
+ return (_jsx(CreatableSelect, { ...controllerField, ...baseProps }));
125
+ case 'async-creatable-select':
126
+ return (_jsx(AsyncCreatableSelect, { ...controllerField, ...baseProps }));
127
+ case 'radio':
128
+ return (_jsx(_Fragment, { children: field.options?.map((option) => (_jsxs("div", { children: [_jsx("input", { ...baseProps, id: `${field.name}_${option.value}`, value: option.value }), _jsx("label", { htmlFor: `${field.name}_${option.value}`, children: option.label })] }, option.value))) }));
129
+ case 'textarea':
130
+ return _jsx("textarea", { ...baseProps });
131
+ case 'file':
132
+ return _jsx(FileField, { ...controllerField, ...baseProps });
133
+ case 'checkbox':
134
+ case 'number':
135
+ case 'date':
136
+ case 'datetime-local':
137
+ case 'time':
138
+ case 'text':
139
+ case 'email':
140
+ case 'password':
141
+ default:
142
+ return _jsx("input", { ...baseProps });
143
+ }
144
+ };
145
+ // Render validation error message
146
+ const renderError = () => {
147
+ if (!fieldState.error) {
148
+ return null;
149
+ }
150
+ return (_jsx("div", { className: 'invalid-feedback d-block', children: fieldState.error.message }));
151
+ };
152
+ // Combine container classes into a single string
153
+ containerClasses.push(field.className); // Add field-specific className in the end to override defaults
154
+ const containerClassNames = containerClasses.filter(Boolean).join(' ');
155
+ return (_jsxs("div", { className: containerClassNames, children: [renderLabel(), renderInput(), renderError()] }));
156
+ }
@@ -0,0 +1,12 @@
1
+ import '../styles/uploady-ext.css';
2
+ interface FileFieldProps {
3
+ value?: any;
4
+ onChange?: (value: any) => void;
5
+ className?: string;
6
+ accept?: string;
7
+ isMultiple?: boolean;
8
+ maxFiles?: number;
9
+ maxSize?: number;
10
+ }
11
+ declare const FileField: import("react").ForwardRefExoticComponent<FileFieldProps & import("react").RefAttributes<HTMLInputElement>>;
12
+ export default FileField;
@@ -0,0 +1,285 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import Uploady, { useItemStartListener, useItemFinishListener, useItemProgressListener, useAbortItem, useItemErrorListener } from '@rpldy/uploady';
3
+ import UploadDropZone from '@rpldy/upload-drop-zone';
4
+ import UploadButton from '@rpldy/upload-button';
5
+ import UploadPreview from '@rpldy/upload-preview';
6
+ import { useState, forwardRef, useEffect, useRef, useCallback } from 'react';
7
+ import { toast } from 'react-toastify';
8
+ import { filesize } from 'filesize';
9
+ import '../styles/uploady-ext.css';
10
+ const FileField = forwardRef(({ value, onChange, className, accept, isMultiple, maxFiles, maxSize }, ref) => {
11
+ const height = 200;
12
+ // State to store file metadata for preview
13
+ const [fileMetadata, setFileMetadata] = useState(new Map());
14
+ // State to store upload progress for each file
15
+ const [uploadProgress, setUploadProgress] = useState(new Map());
16
+ // Counter for currently uploading files
17
+ const uploadingCountRef = useRef(0);
18
+ // Ref to always have the latest fileMetadata
19
+ const fileMetadataRef = useRef(new Map());
20
+ // Ref for scroll container
21
+ const scrollContainerRef = useRef(null);
22
+ // Update ref whenever fileMetadata changes
23
+ useEffect(() => {
24
+ fileMetadataRef.current = fileMetadata;
25
+ }, [fileMetadata]);
26
+ // Auto-scroll to the end when new files are added
27
+ useEffect(() => {
28
+ if (scrollContainerRef.current && fileMetadata.size > 0) {
29
+ scrollContainerRef.current.scrollTo({
30
+ left: scrollContainerRef.current.scrollWidth,
31
+ behavior: 'smooth'
32
+ });
33
+ }
34
+ }, [fileMetadata.size]);
35
+ // Reset in form
36
+ useEffect(() => {
37
+ const handleFormReset = (event) => {
38
+ const form = event.target;
39
+ if (form.contains(scrollContainerRef.current)) {
40
+ setFileMetadata(new Map());
41
+ setUploadProgress(new Map());
42
+ uploadingCountRef.current = 0;
43
+ // Notify parent component about the value change
44
+ onChange?.(isMultiple ? [] : null);
45
+ }
46
+ };
47
+ // Add reset event listener
48
+ window.addEventListener('reset', handleFormReset);
49
+ return () => {
50
+ // Remove event listener on unmount
51
+ window.removeEventListener('reset', handleFormReset);
52
+ };
53
+ }, [onChange, isMultiple]);
54
+ // File filter to prevent duplicates and enforce maxFiles limit
55
+ const fileFilter = useCallback((file) => {
56
+ const currentCount = fileMetadataRef.current.size + uploadingCountRef.current;
57
+ // Check file size limit
58
+ if (maxSize && file.size > maxSize) {
59
+ const sizeMB = (file.size / 1024 / 1024).toFixed(2);
60
+ const maxSizeMB = (maxSize / 1024 / 1024).toFixed(2);
61
+ toast.error(`File '${file.name}' is too large (${sizeMB}MB). Maximum allowed size is ${maxSizeMB}MB. ${file.name} blocked`);
62
+ return false;
63
+ }
64
+ // Check maxFiles limit
65
+ if (maxFiles && currentCount >= maxFiles) {
66
+ toast.error(`Max files limit reached (${maxFiles}): ${file.name} blocked`);
67
+ return false;
68
+ }
69
+ // Check if file already exists
70
+ const isDuplicate = Array.from(fileMetadataRef.current.values()).some(meta => meta.originalName === file.name && meta.size === file.size);
71
+ if (isDuplicate) {
72
+ toast.error(`Duplicate file blocked: ${file.name}`);
73
+ return false;
74
+ }
75
+ return true;
76
+ }, [maxFiles, maxSize]);
77
+ // Upload handler component for file uploads
78
+ const UploadHandler = ({ onUploaded }) => {
79
+ useItemStartListener((item) => {
80
+ // Increment uploading counter
81
+ uploadingCountRef.current++;
82
+ setFileMetadata(prev => {
83
+ const newMap = new Map(prev);
84
+ newMap.set(item.id, {
85
+ url: item.url,
86
+ filename: item.file?.name,
87
+ originalName: item.file?.name,
88
+ size: item.file?.size,
89
+ mimetype: item.file?.type
90
+ });
91
+ return newMap;
92
+ });
93
+ });
94
+ // Listen to upload progress
95
+ useItemProgressListener((item) => {
96
+ if (item.completed < 100) {
97
+ setUploadProgress(prev => {
98
+ const newMap = new Map(prev);
99
+ newMap.set(item.id, item.completed);
100
+ return newMap;
101
+ });
102
+ }
103
+ else {
104
+ // Remove progress when complete
105
+ setUploadProgress(prev => {
106
+ const newMap = new Map(prev);
107
+ newMap.delete(item.id);
108
+ return newMap;
109
+ });
110
+ }
111
+ });
112
+ useItemErrorListener((item) => {
113
+ toast.error(item?.uploadResponse?.data?.error || 'Upload failed');
114
+ // Remove preview and progress for errored item
115
+ setFileMetadata(prev => {
116
+ const newMap = new Map(prev);
117
+ newMap.delete(item.id);
118
+ return newMap;
119
+ });
120
+ setUploadProgress(prev => {
121
+ const newMap = new Map(prev);
122
+ newMap.delete(item.id);
123
+ return newMap;
124
+ });
125
+ // Decrement uploading counter
126
+ uploadingCountRef.current = Math.max(0, uploadingCountRef.current - 1);
127
+ });
128
+ useItemFinishListener((item) => {
129
+ // Decrement uploading counter
130
+ uploadingCountRef.current = Math.max(0, uploadingCountRef.current - 1);
131
+ if (item.state === 'finished') {
132
+ const responseData = item.uploadResponse?.data;
133
+ const url = responseData?.url ?? null;
134
+ // If no URL is returned, stop processing
135
+ if (!url) {
136
+ return;
137
+ }
138
+ // Store file metadata for preview
139
+ setFileMetadata(prev => {
140
+ const newMap = new Map(prev);
141
+ if (newMap.has(item.id)) {
142
+ newMap.set(item.id, {
143
+ ...newMap.get(item.id),
144
+ url: responseData?.url,
145
+ filename: responseData?.filename,
146
+ originalName: responseData?.originalName,
147
+ size: responseData?.size,
148
+ mimetype: responseData?.mimetype,
149
+ isUploading: false,
150
+ });
151
+ }
152
+ return newMap;
153
+ });
154
+ // For multiple files, append to existing array
155
+ if (isMultiple) {
156
+ const currentValue = Array.isArray(value) ? value : [];
157
+ onUploaded([...currentValue, url]);
158
+ }
159
+ else {
160
+ onUploaded(url);
161
+ }
162
+ }
163
+ });
164
+ return null;
165
+ };
166
+ // Custom preview component for uploaded files
167
+ const cleanupAllFiles = () => {
168
+ // Use ref to get the latest fileMetadata
169
+ const filenames = Array.from(fileMetadataRef.current.values()).map(meta => meta.filename);
170
+ if (filenames.length === 0) {
171
+ return;
172
+ }
173
+ // Try sendBeacon first (most reliable)
174
+ const blob = new Blob([JSON.stringify({ filenames })], { type: 'application/json' });
175
+ if (navigator.sendBeacon('/api/upload/temp/cleanup', blob)) {
176
+ // Cleanup request sent via beacon
177
+ return;
178
+ }
179
+ // Fallback to fetch with keepalive
180
+ fetch('/api/upload/temp/cleanup', {
181
+ method: 'POST',
182
+ headers: { 'Content-Type': 'application/json' },
183
+ body: JSON.stringify({ filenames }),
184
+ keepalive: true // Important for page unload
185
+ }).catch(error => console.error('Cleanup failed:', error));
186
+ };
187
+ // Cleanup all temp files on component unmount
188
+ useEffect(() => {
189
+ const handleBeforeUnload = (_) => {
190
+ cleanupAllFiles();
191
+ };
192
+ window.addEventListener('beforeunload', handleBeforeUnload);
193
+ return () => {
194
+ window.removeEventListener('beforeunload', handleBeforeUnload);
195
+ cleanupAllFiles();
196
+ };
197
+ }, []);
198
+ // Preview container wrapper component
199
+ const PreviewContainer = ({ children }) => (_jsx("div", { className: `d-flex border rounded position-relative align-items-center justify-content-center rounded overflow-hidden flex-shrink-0 ${isMultiple ? 'me-2' : ''}`, style: {
200
+ height: `${height}px`,
201
+ maxWidth: `${height}px`
202
+ }, children: children }));
203
+ // Custom preview component
204
+ const CustomPreview = (props) => {
205
+ const { id, url, name, removePreview } = props;
206
+ const metadata = fileMetadata.get(id);
207
+ if (!metadata) {
208
+ return null;
209
+ }
210
+ // Hook to abort upload
211
+ const abortItem = useAbortItem();
212
+ const handleCancel = () => {
213
+ console.log('Cancelling upload for item id:', id);
214
+ // Abort the upload
215
+ abortItem(id);
216
+ // Remove from preview
217
+ removePreview?.();
218
+ // Remove from progress
219
+ setUploadProgress(prev => {
220
+ const newMap = new Map(prev);
221
+ newMap.delete(id);
222
+ return newMap;
223
+ });
224
+ // Decrement counter
225
+ uploadingCountRef.current = Math.max(0, uploadingCountRef.current - 1);
226
+ toast.info('Upload cancelled');
227
+ };
228
+ const handleRemove = async () => {
229
+ // Remove from preview
230
+ removePreview?.();
231
+ const metadata = fileMetadata.get(id);
232
+ // Remove from metadata immediately
233
+ setFileMetadata(prev => {
234
+ const newMap = new Map(prev);
235
+ newMap.delete(id);
236
+ return newMap;
237
+ });
238
+ try {
239
+ await fetch(`/api/upload/temp/${metadata?.filename}`, {
240
+ method: 'DELETE'
241
+ });
242
+ }
243
+ catch (error) {
244
+ console.error('Failed to delete temp file:', error);
245
+ }
246
+ finally {
247
+ // Remove from form value
248
+ const newValue = (isMultiple && Array.isArray(value)) ?
249
+ // Find and remove the filename associated with this preview item
250
+ // Note: We need to track the mapping between preview id and filename
251
+ value.filter((url) => url !== metadata?.url) :
252
+ null;
253
+ onChange?.(newValue);
254
+ }
255
+ };
256
+ // Determine if file is an image
257
+ const isImage = metadata?.mimetype?.startsWith('image/');
258
+ // Get upload progress for this file
259
+ const progress = uploadProgress.get(id);
260
+ const isUploading = progress !== undefined;
261
+ return (_jsxs(PreviewContainer, { children: [isImage ? (_jsx("img", { className: 'object-fit-cover h-100', src: url, alt: name || 'Preview' })) : (_jsxs("div", { className: 'd-flex flex-column align-items-center justify-content-center p-3 text-center w-100', style: {
262
+ minWidth: `${height / 1.35}px`
263
+ }, children: [_jsxs("svg", { xmlns: "http://www.w3.org/2000/svg", width: "64", height: "64", fill: "currentColor", viewBox: "0 0 16 16", children: [_jsx("path", { d: "M14 4.5V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5L14 4.5zm-3 0A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4.5h-2z" }), _jsx("text", { x: "50%", y: "65%", textAnchor: "middle", fontSize: "3", fontWeight: "bold", fill: "currentColor", children: (() => {
264
+ const filename = metadata?.originalName || name || '';
265
+ const parts = filename.split('.');
266
+ return parts.length > 1 ? parts[parts.length - 1].toUpperCase() : 'FILE';
267
+ })() })] }), _jsx("div", { className: 'mt-2 small', children: filesize(metadata?.size || 0, { base: 2, standard: "jedec" }) })] })), _jsx("button", { type: 'button', className: 'btn btn-close bg-danger p-2 btn-sm position-absolute top-0 end-0 m-2', "aria-label": 'Close', onClick: (e) => {
268
+ e.stopPropagation();
269
+ if (isUploading) {
270
+ handleCancel();
271
+ }
272
+ else {
273
+ handleRemove();
274
+ }
275
+ }, title: 'Remove File' }), _jsxs("div", { className: 'position-absolute bottom-0 start-0 end-0 bg-dark bg-opacity-75 text-white py-2 text-truncate small d-flex align-items-center', children: [isUploading && (_jsx("div", { className: 'progress rounded-0 position-absolute w-100 left-0 bottom-0', style: { height: '3px' }, children: _jsx("div", { className: 'progress-bar progress-bar-striped progress-bar-animated bg-primary', role: 'progressbar', style: { width: `${progress}%` }, "aria-valuenow": progress, "aria-valuemin": 0, "aria-valuemax": 100 }) })), _jsxs("span", { className: 'px-3 w-100 d-inline-block text-truncate', children: [isUploading ? `${Math.round(progress || 0)}% - ` : '', name] })] })] }));
276
+ };
277
+ return (_jsxs(Uploady, { destination: {
278
+ url: '/api/upload/temp',
279
+ filesParamName: 'file'
280
+ }, multiple: isMultiple, accept: accept, fileFilter: fileFilter, concurrent: isMultiple, maxConcurrent: maxFiles, children: [_jsx(UploadHandler, { onUploaded: (payload) => {
281
+ onChange?.(payload);
282
+ } }), _jsx(UploadDropZone, { className: `${className || ''}`, onDragOverClassName: 'drag-over', children: _jsxs("div", { ref: scrollContainerRef, className: 'd-flex align-items-center overflow-x-auto overflow-y-hidden', children: [_jsx(UploadPreview, { rememberPreviousBatches: true, PreviewComponent: CustomPreview, fallbackUrl: 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="100" height="100"%3E%3Crect width="100" height="100" fill="%23ddd"/%3E%3Ctext x="50%25" y="50%25" text-anchor="middle" dy=".3em" fill="%23999"%3EImage%3C/text%3E%3C/svg%3E' }), (!maxFiles || fileMetadata.size + uploadingCountRef.current < maxFiles) && (_jsx(UploadButton, { className: 'border-0 bg-transparent p-0 cursor-pointer flex-shrink-0', extraProps: { type: 'button' }, children: _jsx(PreviewContainer, { children: _jsxs("div", { className: 'text-center p-3', children: [_jsx("div", { className: 'fw-bold', children: "Upload File" }), _jsx("div", { className: 'text-muted small mt-1', children: "Click or drag" })] }) }) }))] }) })] }));
283
+ });
284
+ FileField.displayName = 'FileField';
285
+ export default FileField;
@@ -0,0 +1,8 @@
1
+ import type { Form as FormType } from '../types/Form';
2
+ interface FormProps {
3
+ form: FormType;
4
+ onSubmit: (data: any) => Promise<void>;
5
+ showReset?: boolean;
6
+ }
7
+ export default function Form({ form, onSubmit, showReset }: FormProps): JSX.Element;
8
+ export {};
@@ -0,0 +1,70 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useForm } from 'react-hook-form';
3
+ import { zodResolver } from '@hookform/resolvers/zod';
4
+ import { toast } from 'react-toastify';
5
+ import Field from './Field';
6
+ export default function Form({ form, onSubmit, showReset = false }) {
7
+ // Initialize react-hook-form with zod resolver
8
+ const { register, handleSubmit, reset, trigger, clearErrors, control, formState: { touchedFields, isSubmitting, isValid, isDirty } } = useForm({
9
+ resolver: zodResolver(form.schema),
10
+ mode: 'onChange',
11
+ defaultValues: form.defaultValues
12
+ });
13
+ // Handle form submission
14
+ const handleFormSubmit = async (data) => {
15
+ try {
16
+ await onSubmit(data);
17
+ reset(data);
18
+ }
19
+ catch (error) {
20
+ toast.error('Form submission failed');
21
+ }
22
+ };
23
+ const validateFormFields = async (field) => {
24
+ const fieldNames = Object.values(form.sections)
25
+ .flatMap(section => Object.values(section.fields))
26
+ .map(f => f.name);
27
+ const currentIndex = fieldNames.indexOf(field.name);
28
+ // Trigger validation for all fields before the current one
29
+ const beforeFields = fieldNames.slice(0, currentIndex + 1);
30
+ beforeFields.forEach(name => trigger(name));
31
+ // Clear errors for untouched fields after the current one
32
+ const afterFields = fieldNames.slice(currentIndex + 1);
33
+ const untouchedAfterFields = afterFields.filter(name => !touchedFields[name]);
34
+ clearErrors(untouchedAfterFields);
35
+ };
36
+ const getFieldRegister = (field) => {
37
+ const baseRegister = register(field.name, {
38
+ valueAsNumber: field.type === 'number'
39
+ });
40
+ return {
41
+ ...baseRegister,
42
+ onFocus: async (_) => {
43
+ // Call form field validations by a specific order
44
+ await validateFormFields(field);
45
+ },
46
+ onChange: async (event) => {
47
+ // Call the base onChange handler
48
+ await baseRegister.onChange(event);
49
+ // Trigger re-validation for specified fields
50
+ for (const revalidateFieldName of field.revalidates) {
51
+ await trigger(revalidateFieldName);
52
+ }
53
+ },
54
+ };
55
+ };
56
+ // Render a single field
57
+ const renderField = (field) => {
58
+ const fieldRegister = getFieldRegister(field);
59
+ return (_jsx(Field, { field: field, register: fieldRegister, control: control }, field.name));
60
+ };
61
+ // Render section
62
+ const renderSection = (section) => {
63
+ return (_jsx("div", { className: `${section.className} mb-4`, children: _jsxs("div", { className: 'card', children: [_jsx("div", { className: 'card-header', children: section.title && _jsx("h3", { children: section.title }) }), _jsx("div", { className: 'card-body', children: _jsx("div", { className: 'row g-3', children: Object.values(section.fields).map(field => renderField(field)) }) })] }) }));
64
+ };
65
+ // Render form buttons
66
+ const renderButtons = () => {
67
+ return (_jsxs("div", { className: 'd-flex gap-2', children: [_jsx("button", { type: 'submit', className: 'btn btn-primary', disabled: !isDirty || !isValid || isSubmitting, children: isSubmitting ? 'Submitting...' : 'Submit' }), showReset && (_jsx("button", { type: 'button', className: 'btn btn-secondary', onClick: () => reset(), disabled: !isDirty || isSubmitting, children: "Reset" }))] }));
68
+ };
69
+ return (_jsxs("form", { className: 'row', onSubmit: handleSubmit(handleFormSubmit), noValidate: true, children: [form.sections.map(section => renderSection(section)), renderButtons()] }));
70
+ }
@@ -0,0 +1,82 @@
1
+ import { z } from 'zod';
2
+ export type FieldType = 'text' | 'email' | 'password' | 'number' | 'checkbox' | 'date' | 'datetime-local' | 'time' | 'radio' | 'select' | 'async-select' | 'creatable-select' | 'async-creatable-select' | 'textarea' | 'file';
3
+ export interface Option {
4
+ label: string;
5
+ value: string;
6
+ }
7
+ export interface FieldConfig {
8
+ type: FieldType;
9
+ name: string;
10
+ label: string;
11
+ placeholder?: string;
12
+ isReadOnly?: boolean;
13
+ isDisabled?: boolean;
14
+ isRequired?: boolean;
15
+ min?: number | string;
16
+ max?: number | string;
17
+ revalidates?: string[];
18
+ step?: number;
19
+ options?: Option[];
20
+ isMultiple?: boolean;
21
+ maxSize?: number;
22
+ accept?: string;
23
+ maxFiles?: number;
24
+ loadOptions?: (search: string) => Promise<Option[]>;
25
+ validate?: (values: Record<string, any>, ctx: z.RefinementCtx) => void;
26
+ normalize?: (value: unknown) => unknown;
27
+ className?: string;
28
+ attrs?: Record<string, any>;
29
+ }
30
+ export declare class Field<T = any> {
31
+ protected readonly _config: FieldConfig;
32
+ readonly schema: z.ZodSchema<T>;
33
+ constructor(config: FieldConfig, schema: z.ZodSchema<T>);
34
+ get type(): FieldType;
35
+ get name(): string;
36
+ get label(): string;
37
+ get placeholder(): string;
38
+ get isReadOnly(): boolean;
39
+ get isDisabled(): boolean;
40
+ get isRequired(): boolean;
41
+ get min(): string | number | undefined;
42
+ get max(): string | number | undefined;
43
+ get revalidates(): string[];
44
+ get step(): number | undefined;
45
+ get options(): Option[];
46
+ get isMultiple(): boolean;
47
+ get maxSize(): number | undefined;
48
+ get accept(): string | undefined;
49
+ get maxFiles(): number;
50
+ get loadOptions(): ((search: string) => Promise<Option[]>) | undefined;
51
+ get className(): string;
52
+ get attrs(): Record<string, any>;
53
+ getNormalizedValue(value: unknown): T;
54
+ runValidation(values: Record<string, any>, ctx: z.RefinementCtx): void;
55
+ getDefaultValue(): T;
56
+ }
57
+ export declare class FieldFactory {
58
+ private static createStringSchema;
59
+ private static createEmailSchema;
60
+ static text(name: string, label: string, config?: Partial<FieldConfig>): Field<string>;
61
+ static email(config: Partial<FieldConfig>): Field<string>;
62
+ static password(config: Partial<FieldConfig>): Field<string>;
63
+ static textarea(name: string, label: string, config?: Partial<FieldConfig>): Field<string>;
64
+ private static createNumberSchema;
65
+ static number(name: string, label: string, config?: Partial<FieldConfig>): Field<number | undefined>;
66
+ private static createCheckboxSchema;
67
+ static checkbox(name: string, label: string, config?: Partial<FieldConfig>): Field<boolean>;
68
+ private static createDateSchema;
69
+ static date(name: string, label: string, config?: Partial<FieldConfig>): Field<Date | undefined>;
70
+ private static createDateTimeLocalSchema;
71
+ static datetimeLocal(name: string, label: string, config?: Partial<FieldConfig>): Field<Date | undefined>;
72
+ private static createTimeSchema;
73
+ static time(name: string, label: string, config?: Partial<FieldConfig>): Field<string | undefined>;
74
+ static radio(name: string, label: string, options: Option[], config?: Partial<FieldConfig>): Field<string>;
75
+ private static createSelectSchema;
76
+ static select(name: string, label: string, options: Option[], config?: Partial<FieldConfig>): Field<Option | Option[] | null>;
77
+ static asyncSelect(name: string, label: string, loadOptions: (search: string) => Promise<Option[]>, config?: Partial<FieldConfig>): Field<Option | Option[] | null>;
78
+ static creatableSelect(name: string, label: string, options: Option[], config?: Partial<FieldConfig>): Field<Option | Option[] | null>;
79
+ static asyncCreatableSelect(name: string, label: string, loadOptions: (search: string) => Promise<Option[]>, config?: Partial<FieldConfig>): Field<Option | Option[] | null>;
80
+ private static createFileSchema;
81
+ static file(name: string, label: string, config?: Partial<FieldConfig>): Field<string | string[] | null>;
82
+ }