@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.
- package/LICENSE +21 -0
- package/README.md +2 -0
- package/dist/components/Field.d.ts +9 -0
- package/dist/components/Field.js +167 -0
- package/dist/components/FileField.d.ts +12 -0
- package/dist/components/FileField.js +338 -0
- package/dist/components/Form.d.ts +8 -0
- package/dist/components/Form.js +100 -0
- package/dist/src/components/Field.d.ts +9 -0
- package/dist/src/components/Field.js +156 -0
- package/dist/src/components/FileField.d.ts +12 -0
- package/dist/src/components/FileField.js +285 -0
- package/dist/src/components/Form.d.ts +8 -0
- package/dist/src/components/Form.js +70 -0
- package/dist/src/types/Field.d.ts +82 -0
- package/dist/src/types/Field.js +498 -0
- package/dist/src/types/Form.d.ts +29 -0
- package/dist/src/types/Form.js +102 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/dist/types/Field.d.ts +82 -0
- package/dist/types/Field.js +442 -0
- package/dist/types/Form.d.ts +29 -0
- package/dist/types/Form.js +107 -0
- package/package.json +57 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Serhii Budianskyi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -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,167 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.default = Field;
|
|
13
|
+
const react_hook_form_1 = require("react-hook-form");
|
|
14
|
+
const react_select_1 = require("react-select");
|
|
15
|
+
const async_1 = require("react-select/async");
|
|
16
|
+
const creatable_1 = require("react-select/creatable");
|
|
17
|
+
const async_creatable_1 = require("react-select/async-creatable");
|
|
18
|
+
const FileField_1 = require("./FileField");
|
|
19
|
+
function Field({ field, register, control, }) {
|
|
20
|
+
var _a;
|
|
21
|
+
const containerClasses = [];
|
|
22
|
+
// Get field state from react-hook-form
|
|
23
|
+
const { field: controllerField, fieldState } = (0, react_hook_form_1.useController)({
|
|
24
|
+
name: field.name,
|
|
25
|
+
control
|
|
26
|
+
});
|
|
27
|
+
// Base properties for the input element
|
|
28
|
+
const baseProps = Object.assign(Object.assign(Object.assign({}, register), { type: field.type, id: field.name, name: field.name, placeholder: field.placeholder, readOnly: field.isReadOnly, disabled: field.isDisabled, required: field.isRequired, min: field.min, max: field.max, className: '' }), field.attrs);
|
|
29
|
+
// Adjust properties based on field type
|
|
30
|
+
switch (field.type) {
|
|
31
|
+
case 'async-select':
|
|
32
|
+
case 'async-creatable-select':
|
|
33
|
+
baseProps.loadOptions = field.loadOptions;
|
|
34
|
+
// fallthrough
|
|
35
|
+
case 'creatable-select':
|
|
36
|
+
case 'select':
|
|
37
|
+
baseProps.className = 'form-control';
|
|
38
|
+
baseProps.options = field.options;
|
|
39
|
+
baseProps.value = field.isMultiple ?
|
|
40
|
+
(Array.isArray(controllerField.value) ? controllerField.value : []) :
|
|
41
|
+
((_a = controllerField.value) !== null && _a !== void 0 ? _a : null);
|
|
42
|
+
baseProps.onChange = (option) => {
|
|
43
|
+
let value;
|
|
44
|
+
if (field.isMultiple) {
|
|
45
|
+
value = option ? option : [];
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
value = option ? option : null;
|
|
49
|
+
}
|
|
50
|
+
controllerField.onChange(value);
|
|
51
|
+
};
|
|
52
|
+
baseProps.styles = {
|
|
53
|
+
container: (base) => (Object.assign(Object.assign({}, base), { padding: 0 })),
|
|
54
|
+
control: (base) => (Object.assign(Object.assign({}, base), { border: 'none', boxShadow: 'none' }))
|
|
55
|
+
};
|
|
56
|
+
break;
|
|
57
|
+
case 'file':
|
|
58
|
+
baseProps.className = 'form-control';
|
|
59
|
+
baseProps.accept = field.accept;
|
|
60
|
+
baseProps.isMultiple = field.isMultiple;
|
|
61
|
+
if (!baseProps.isMultiple) {
|
|
62
|
+
containerClasses.push('col-auto');
|
|
63
|
+
}
|
|
64
|
+
baseProps.maxFiles = field.maxFiles;
|
|
65
|
+
baseProps.maxSize = field.maxSize;
|
|
66
|
+
baseProps.onChange = (fileIds) => {
|
|
67
|
+
controllerField.onChange(fileIds);
|
|
68
|
+
};
|
|
69
|
+
break;
|
|
70
|
+
case 'radio':
|
|
71
|
+
case 'checkbox':
|
|
72
|
+
baseProps.className = 'form-check-input';
|
|
73
|
+
containerClasses.push('mx-2', 'form-check', 'form-switch');
|
|
74
|
+
break;
|
|
75
|
+
case 'number':
|
|
76
|
+
baseProps.step = field.step;
|
|
77
|
+
// fallthrough
|
|
78
|
+
case 'date':
|
|
79
|
+
case 'datetime-local':
|
|
80
|
+
case 'time':
|
|
81
|
+
containerClasses.push('col-auto');
|
|
82
|
+
// fallthrough
|
|
83
|
+
case 'text':
|
|
84
|
+
case 'email':
|
|
85
|
+
case 'password':
|
|
86
|
+
default:
|
|
87
|
+
baseProps.className = 'form-control';
|
|
88
|
+
// Override onChange to include string normalization on real-time input
|
|
89
|
+
baseProps.onInput = (event) => __awaiter(this, void 0, void 0, function* () {
|
|
90
|
+
// Apply normalization
|
|
91
|
+
const normalizedValue = field.getNormalizedValue(event.target.value);
|
|
92
|
+
event.target.value = normalizedValue;
|
|
93
|
+
// Call the original onChange
|
|
94
|
+
return register.onChange(event);
|
|
95
|
+
});
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
// Add validation classes
|
|
99
|
+
if (fieldState.error) {
|
|
100
|
+
baseProps.className += ' is-invalid';
|
|
101
|
+
}
|
|
102
|
+
else if (!baseProps.className.includes('form-check-input') && (fieldState.isTouched || fieldState.isDirty)) {
|
|
103
|
+
baseProps.className += ' is-valid';
|
|
104
|
+
}
|
|
105
|
+
// Render the label for the field
|
|
106
|
+
const renderLabel = () => (<label htmlFor={field.name} className='form-label'>
|
|
107
|
+
{field.label} {field.isRequired &&
|
|
108
|
+
<span className='text-danger' title='Field is required'>
|
|
109
|
+
*
|
|
110
|
+
</span>}
|
|
111
|
+
</label>);
|
|
112
|
+
// Render the input element based on the field type
|
|
113
|
+
const renderInput = () => {
|
|
114
|
+
var _a;
|
|
115
|
+
switch (field.type) {
|
|
116
|
+
case 'select':
|
|
117
|
+
return (<react_select_1.default {...controllerField} {...baseProps}/>);
|
|
118
|
+
case 'async-select':
|
|
119
|
+
return (<async_1.default {...controllerField} {...baseProps}/>);
|
|
120
|
+
case 'creatable-select':
|
|
121
|
+
return (<creatable_1.default {...controllerField} {...baseProps}/>);
|
|
122
|
+
case 'async-creatable-select':
|
|
123
|
+
return (<async_creatable_1.default {...controllerField} {...baseProps}/>);
|
|
124
|
+
case 'radio':
|
|
125
|
+
return (<>
|
|
126
|
+
{(_a = field.options) === null || _a === void 0 ? void 0 : _a.map((option) => (<div key={option.value}>
|
|
127
|
+
<input {...baseProps} id={`${field.name}_${option.value}`} // Override uniqueid for each option
|
|
128
|
+
value={option.value}/>
|
|
129
|
+
<label htmlFor={`${field.name}_${option.value}`}>
|
|
130
|
+
{option.label}
|
|
131
|
+
</label>
|
|
132
|
+
</div>))}
|
|
133
|
+
</>);
|
|
134
|
+
case 'textarea':
|
|
135
|
+
return <textarea {...baseProps}/>;
|
|
136
|
+
case 'file':
|
|
137
|
+
return <FileField_1.default {...controllerField} {...baseProps}/>;
|
|
138
|
+
case 'checkbox':
|
|
139
|
+
case 'number':
|
|
140
|
+
case 'date':
|
|
141
|
+
case 'datetime-local':
|
|
142
|
+
case 'time':
|
|
143
|
+
case 'text':
|
|
144
|
+
case 'email':
|
|
145
|
+
case 'password':
|
|
146
|
+
default:
|
|
147
|
+
return <input {...baseProps}/>;
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
// Render validation error message
|
|
151
|
+
const renderError = () => {
|
|
152
|
+
if (!fieldState.error) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
return (<div className='invalid-feedback d-block'>
|
|
156
|
+
{fieldState.error.message}
|
|
157
|
+
</div>);
|
|
158
|
+
};
|
|
159
|
+
// Combine container classes into a single string
|
|
160
|
+
containerClasses.push(field.className); // Add field-specific className in the end to override defaults
|
|
161
|
+
const containerClassNames = containerClasses.filter(Boolean).join(' ');
|
|
162
|
+
return (<div className={containerClassNames}>
|
|
163
|
+
{renderLabel()}
|
|
164
|
+
{renderInput()}
|
|
165
|
+
{renderError()}
|
|
166
|
+
</div>);
|
|
167
|
+
}
|
|
@@ -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,338 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
const uploady_1 = require("@rpldy/uploady");
|
|
13
|
+
const upload_drop_zone_1 = require("@rpldy/upload-drop-zone");
|
|
14
|
+
const upload_button_1 = require("@rpldy/upload-button");
|
|
15
|
+
const upload_preview_1 = require("@rpldy/upload-preview");
|
|
16
|
+
const react_1 = require("react");
|
|
17
|
+
const react_toastify_1 = require("react-toastify");
|
|
18
|
+
const filesize_1 = require("filesize");
|
|
19
|
+
require("../styles/uploady-ext.css");
|
|
20
|
+
const FileField = (0, react_1.forwardRef)(({ value, onChange, className, accept, isMultiple, maxFiles, maxSize }, ref) => {
|
|
21
|
+
const height = 200;
|
|
22
|
+
// State to store file metadata for preview
|
|
23
|
+
const [fileMetadata, setFileMetadata] = (0, react_1.useState)(new Map());
|
|
24
|
+
// State to store upload progress for each file
|
|
25
|
+
const [uploadProgress, setUploadProgress] = (0, react_1.useState)(new Map());
|
|
26
|
+
// Counter for currently uploading files
|
|
27
|
+
const uploadingCountRef = (0, react_1.useRef)(0);
|
|
28
|
+
// Ref to always have the latest fileMetadata
|
|
29
|
+
const fileMetadataRef = (0, react_1.useRef)(new Map());
|
|
30
|
+
// Ref for scroll container
|
|
31
|
+
const scrollContainerRef = (0, react_1.useRef)(null);
|
|
32
|
+
// Update ref whenever fileMetadata changes
|
|
33
|
+
(0, react_1.useEffect)(() => {
|
|
34
|
+
fileMetadataRef.current = fileMetadata;
|
|
35
|
+
}, [fileMetadata]);
|
|
36
|
+
// Auto-scroll to the end when new files are added
|
|
37
|
+
(0, react_1.useEffect)(() => {
|
|
38
|
+
if (scrollContainerRef.current && fileMetadata.size > 0) {
|
|
39
|
+
scrollContainerRef.current.scrollTo({
|
|
40
|
+
left: scrollContainerRef.current.scrollWidth,
|
|
41
|
+
behavior: 'smooth'
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}, [fileMetadata.size]);
|
|
45
|
+
// Reset in form
|
|
46
|
+
(0, react_1.useEffect)(() => {
|
|
47
|
+
const handleFormReset = (event) => {
|
|
48
|
+
const form = event.target;
|
|
49
|
+
if (form.contains(scrollContainerRef.current)) {
|
|
50
|
+
setFileMetadata(new Map());
|
|
51
|
+
setUploadProgress(new Map());
|
|
52
|
+
uploadingCountRef.current = 0;
|
|
53
|
+
// Notify parent component about the value change
|
|
54
|
+
onChange === null || onChange === void 0 ? void 0 : onChange(isMultiple ? [] : null);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
// Add reset event listener
|
|
58
|
+
window.addEventListener('reset', handleFormReset);
|
|
59
|
+
return () => {
|
|
60
|
+
// Remove event listener on unmount
|
|
61
|
+
window.removeEventListener('reset', handleFormReset);
|
|
62
|
+
};
|
|
63
|
+
}, [onChange, isMultiple]);
|
|
64
|
+
// File filter to prevent duplicates and enforce maxFiles limit
|
|
65
|
+
const fileFilter = (0, react_1.useCallback)((file) => {
|
|
66
|
+
const currentCount = fileMetadataRef.current.size + uploadingCountRef.current;
|
|
67
|
+
// Check file size limit
|
|
68
|
+
if (maxSize && file.size > maxSize) {
|
|
69
|
+
const sizeMB = (file.size / 1024 / 1024).toFixed(2);
|
|
70
|
+
const maxSizeMB = (maxSize / 1024 / 1024).toFixed(2);
|
|
71
|
+
react_toastify_1.toast.error(`File '${file.name}' is too large (${sizeMB}MB). Maximum allowed size is ${maxSizeMB}MB. ${file.name} blocked`);
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
// Check maxFiles limit
|
|
75
|
+
if (maxFiles && currentCount >= maxFiles) {
|
|
76
|
+
react_toastify_1.toast.error(`Max files limit reached (${maxFiles}): ${file.name} blocked`);
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
// Check if file already exists
|
|
80
|
+
const isDuplicate = Array.from(fileMetadataRef.current.values()).some(meta => meta.originalName === file.name && meta.size === file.size);
|
|
81
|
+
if (isDuplicate) {
|
|
82
|
+
react_toastify_1.toast.error(`Duplicate file blocked: ${file.name}`);
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
return true;
|
|
86
|
+
}, [maxFiles, maxSize]);
|
|
87
|
+
// Upload handler component for file uploads
|
|
88
|
+
const UploadHandler = ({ onUploaded }) => {
|
|
89
|
+
(0, uploady_1.useItemStartListener)((item) => {
|
|
90
|
+
// Increment uploading counter
|
|
91
|
+
uploadingCountRef.current++;
|
|
92
|
+
setFileMetadata(prev => {
|
|
93
|
+
var _a, _b, _c, _d;
|
|
94
|
+
const newMap = new Map(prev);
|
|
95
|
+
newMap.set(item.id, {
|
|
96
|
+
url: item.url,
|
|
97
|
+
filename: (_a = item.file) === null || _a === void 0 ? void 0 : _a.name,
|
|
98
|
+
originalName: (_b = item.file) === null || _b === void 0 ? void 0 : _b.name,
|
|
99
|
+
size: (_c = item.file) === null || _c === void 0 ? void 0 : _c.size,
|
|
100
|
+
mimetype: (_d = item.file) === null || _d === void 0 ? void 0 : _d.type
|
|
101
|
+
});
|
|
102
|
+
return newMap;
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
// Listen to upload progress
|
|
106
|
+
(0, uploady_1.useItemProgressListener)((item) => {
|
|
107
|
+
if (item.completed < 100) {
|
|
108
|
+
setUploadProgress(prev => {
|
|
109
|
+
const newMap = new Map(prev);
|
|
110
|
+
newMap.set(item.id, item.completed);
|
|
111
|
+
return newMap;
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
// Remove progress when complete
|
|
116
|
+
setUploadProgress(prev => {
|
|
117
|
+
const newMap = new Map(prev);
|
|
118
|
+
newMap.delete(item.id);
|
|
119
|
+
return newMap;
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
(0, uploady_1.useItemErrorListener)((item) => {
|
|
124
|
+
var _a, _b;
|
|
125
|
+
react_toastify_1.toast.error(((_b = (_a = item === null || item === void 0 ? void 0 : item.uploadResponse) === null || _a === void 0 ? void 0 : _a.data) === null || _b === void 0 ? void 0 : _b.error) || 'Upload failed');
|
|
126
|
+
// Remove preview and progress for errored item
|
|
127
|
+
setFileMetadata(prev => {
|
|
128
|
+
const newMap = new Map(prev);
|
|
129
|
+
newMap.delete(item.id);
|
|
130
|
+
return newMap;
|
|
131
|
+
});
|
|
132
|
+
setUploadProgress(prev => {
|
|
133
|
+
const newMap = new Map(prev);
|
|
134
|
+
newMap.delete(item.id);
|
|
135
|
+
return newMap;
|
|
136
|
+
});
|
|
137
|
+
// Decrement uploading counter
|
|
138
|
+
uploadingCountRef.current = Math.max(0, uploadingCountRef.current - 1);
|
|
139
|
+
});
|
|
140
|
+
(0, uploady_1.useItemFinishListener)((item) => {
|
|
141
|
+
var _a, _b;
|
|
142
|
+
// Decrement uploading counter
|
|
143
|
+
uploadingCountRef.current = Math.max(0, uploadingCountRef.current - 1);
|
|
144
|
+
if (item.state === 'finished') {
|
|
145
|
+
const responseData = (_a = item.uploadResponse) === null || _a === void 0 ? void 0 : _a.data;
|
|
146
|
+
const url = (_b = responseData === null || responseData === void 0 ? void 0 : responseData.url) !== null && _b !== void 0 ? _b : null;
|
|
147
|
+
// If no URL is returned, stop processing
|
|
148
|
+
if (!url) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
// Store file metadata for preview
|
|
152
|
+
setFileMetadata(prev => {
|
|
153
|
+
const newMap = new Map(prev);
|
|
154
|
+
if (newMap.has(item.id)) {
|
|
155
|
+
newMap.set(item.id, Object.assign(Object.assign({}, newMap.get(item.id)), { url: responseData === null || responseData === void 0 ? void 0 : responseData.url, filename: responseData === null || responseData === void 0 ? void 0 : responseData.filename, originalName: responseData === null || responseData === void 0 ? void 0 : responseData.originalName, size: responseData === null || responseData === void 0 ? void 0 : responseData.size, mimetype: responseData === null || responseData === void 0 ? void 0 : responseData.mimetype, isUploading: false }));
|
|
156
|
+
}
|
|
157
|
+
return newMap;
|
|
158
|
+
});
|
|
159
|
+
// For multiple files, append to existing array
|
|
160
|
+
if (isMultiple) {
|
|
161
|
+
const currentValue = Array.isArray(value) ? value : [];
|
|
162
|
+
onUploaded([...currentValue, url]);
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
onUploaded(url);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
return null;
|
|
170
|
+
};
|
|
171
|
+
// Custom preview component for uploaded files
|
|
172
|
+
const cleanupAllFiles = () => {
|
|
173
|
+
// Use ref to get the latest fileMetadata
|
|
174
|
+
const filenames = Array.from(fileMetadataRef.current.values()).map(meta => meta.filename);
|
|
175
|
+
if (filenames.length === 0) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
// Try sendBeacon first (most reliable)
|
|
179
|
+
const blob = new Blob([JSON.stringify({ filenames })], { type: 'application/json' });
|
|
180
|
+
if (navigator.sendBeacon('/api/upload/temp/cleanup', blob)) {
|
|
181
|
+
// Cleanup request sent via beacon
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
// Fallback to fetch with keepalive
|
|
185
|
+
fetch('/api/upload/temp/cleanup', {
|
|
186
|
+
method: 'POST',
|
|
187
|
+
headers: { 'Content-Type': 'application/json' },
|
|
188
|
+
body: JSON.stringify({ filenames }),
|
|
189
|
+
keepalive: true // Important for page unload
|
|
190
|
+
}).catch(error => console.error('Cleanup failed:', error));
|
|
191
|
+
};
|
|
192
|
+
// Cleanup all temp files on component unmount
|
|
193
|
+
(0, react_1.useEffect)(() => {
|
|
194
|
+
const handleBeforeUnload = (_) => {
|
|
195
|
+
cleanupAllFiles();
|
|
196
|
+
};
|
|
197
|
+
window.addEventListener('beforeunload', handleBeforeUnload);
|
|
198
|
+
return () => {
|
|
199
|
+
window.removeEventListener('beforeunload', handleBeforeUnload);
|
|
200
|
+
cleanupAllFiles();
|
|
201
|
+
};
|
|
202
|
+
}, []);
|
|
203
|
+
// Preview container wrapper component
|
|
204
|
+
const PreviewContainer = ({ children }) => (<div className={`d-flex border rounded position-relative align-items-center justify-content-center rounded overflow-hidden flex-shrink-0 ${isMultiple ? 'me-2' : ''}`} style={{
|
|
205
|
+
height: `${height}px`,
|
|
206
|
+
maxWidth: `${height}px`
|
|
207
|
+
}}>
|
|
208
|
+
{children}
|
|
209
|
+
</div>);
|
|
210
|
+
// Custom preview component
|
|
211
|
+
const CustomPreview = (props) => {
|
|
212
|
+
var _a;
|
|
213
|
+
const { id, url, name, removePreview } = props;
|
|
214
|
+
const metadata = fileMetadata.get(id);
|
|
215
|
+
if (!metadata) {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
// Hook to abort upload
|
|
219
|
+
const abortItem = (0, uploady_1.useAbortItem)();
|
|
220
|
+
const handleCancel = () => {
|
|
221
|
+
console.log('Cancelling upload for item id:', id);
|
|
222
|
+
// Abort the upload
|
|
223
|
+
abortItem(id);
|
|
224
|
+
// Remove from preview
|
|
225
|
+
removePreview === null || removePreview === void 0 ? void 0 : removePreview();
|
|
226
|
+
// Remove from progress
|
|
227
|
+
setUploadProgress(prev => {
|
|
228
|
+
const newMap = new Map(prev);
|
|
229
|
+
newMap.delete(id);
|
|
230
|
+
return newMap;
|
|
231
|
+
});
|
|
232
|
+
// Decrement counter
|
|
233
|
+
uploadingCountRef.current = Math.max(0, uploadingCountRef.current - 1);
|
|
234
|
+
react_toastify_1.toast.info('Upload cancelled');
|
|
235
|
+
};
|
|
236
|
+
const handleRemove = () => __awaiter(void 0, void 0, void 0, function* () {
|
|
237
|
+
// Remove from preview
|
|
238
|
+
removePreview === null || removePreview === void 0 ? void 0 : removePreview();
|
|
239
|
+
const metadata = fileMetadata.get(id);
|
|
240
|
+
// Remove from metadata immediately
|
|
241
|
+
setFileMetadata(prev => {
|
|
242
|
+
const newMap = new Map(prev);
|
|
243
|
+
newMap.delete(id);
|
|
244
|
+
return newMap;
|
|
245
|
+
});
|
|
246
|
+
try {
|
|
247
|
+
yield fetch(`/api/upload/temp/${metadata === null || metadata === void 0 ? void 0 : metadata.filename}`, {
|
|
248
|
+
method: 'DELETE'
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
console.error('Failed to delete temp file:', error);
|
|
253
|
+
}
|
|
254
|
+
finally {
|
|
255
|
+
// Remove from form value
|
|
256
|
+
const newValue = (isMultiple && Array.isArray(value)) ?
|
|
257
|
+
// Find and remove the filename associated with this preview item
|
|
258
|
+
// Note: We need to track the mapping between preview id and filename
|
|
259
|
+
value.filter((url) => url !== (metadata === null || metadata === void 0 ? void 0 : metadata.url)) :
|
|
260
|
+
null;
|
|
261
|
+
onChange === null || onChange === void 0 ? void 0 : onChange(newValue);
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
// Determine if file is an image
|
|
265
|
+
const isImage = (_a = metadata === null || metadata === void 0 ? void 0 : metadata.mimetype) === null || _a === void 0 ? void 0 : _a.startsWith('image/');
|
|
266
|
+
// Get upload progress for this file
|
|
267
|
+
const progress = uploadProgress.get(id);
|
|
268
|
+
const isUploading = progress !== undefined;
|
|
269
|
+
return (<PreviewContainer>
|
|
270
|
+
{/* Preview content */}
|
|
271
|
+
{isImage ? (<img className='object-fit-cover h-100' src={url} alt={name || 'Preview'}/>) : (<div className='d-flex flex-column align-items-center justify-content-center p-3 text-center w-100' style={{
|
|
272
|
+
minWidth: `${height / 1.35}px`
|
|
273
|
+
}}>
|
|
274
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="currentColor" viewBox="0 0 16 16">
|
|
275
|
+
<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"/>
|
|
276
|
+
<text x="50%" y="65%" textAnchor="middle" fontSize="3" fontWeight="bold" fill="currentColor">
|
|
277
|
+
{(() => {
|
|
278
|
+
const filename = (metadata === null || metadata === void 0 ? void 0 : metadata.originalName) || name || '';
|
|
279
|
+
const parts = filename.split('.');
|
|
280
|
+
return parts.length > 1 ? parts[parts.length - 1].toUpperCase() : 'FILE';
|
|
281
|
+
})()}
|
|
282
|
+
</text>
|
|
283
|
+
</svg>
|
|
284
|
+
<div className='mt-2 small'>
|
|
285
|
+
{(0, filesize_1.filesize)((metadata === null || metadata === void 0 ? void 0 : metadata.size) || 0, { base: 2, standard: "jedec" })}
|
|
286
|
+
</div>
|
|
287
|
+
</div>)}
|
|
288
|
+
|
|
289
|
+
{/* Preview cancel button */}
|
|
290
|
+
<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) => {
|
|
291
|
+
e.stopPropagation();
|
|
292
|
+
if (isUploading) {
|
|
293
|
+
handleCancel();
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
handleRemove();
|
|
297
|
+
}
|
|
298
|
+
}} title='Remove File'/>
|
|
299
|
+
|
|
300
|
+
{/* Preview footer (title/progress) */}
|
|
301
|
+
<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'>
|
|
302
|
+
{isUploading && (<div className='progress rounded-0 position-absolute w-100 left-0 bottom-0' style={{ height: '3px' }}>
|
|
303
|
+
<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}/>
|
|
304
|
+
</div>)}
|
|
305
|
+
<span className='px-3 w-100 d-inline-block text-truncate'>
|
|
306
|
+
{isUploading ? `${Math.round(progress || 0)}% - ` : ''}
|
|
307
|
+
{name}
|
|
308
|
+
</span>
|
|
309
|
+
</div>
|
|
310
|
+
</PreviewContainer>);
|
|
311
|
+
};
|
|
312
|
+
return (<uploady_1.default destination={{
|
|
313
|
+
url: '/api/upload/temp',
|
|
314
|
+
filesParamName: 'file'
|
|
315
|
+
}} multiple={isMultiple} accept={accept} fileFilter={fileFilter} concurrent={isMultiple} maxConcurrent={maxFiles}>
|
|
316
|
+
<UploadHandler onUploaded={(payload) => {
|
|
317
|
+
onChange === null || onChange === void 0 ? void 0 : onChange(payload);
|
|
318
|
+
}}/>
|
|
319
|
+
|
|
320
|
+
<upload_drop_zone_1.default className={`${className || ''}`} onDragOverClassName='drag-over'>
|
|
321
|
+
<div ref={scrollContainerRef} className='d-flex align-items-center overflow-x-auto overflow-y-hidden'>
|
|
322
|
+
<upload_preview_1.default 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'/>
|
|
323
|
+
{(!maxFiles || fileMetadata.size + uploadingCountRef.current < maxFiles) && (<upload_button_1.default className='border-0 bg-transparent p-0 cursor-pointer flex-shrink-0' extraProps={{ type: 'button' }}>
|
|
324
|
+
<PreviewContainer>
|
|
325
|
+
<div className='text-center p-3'>
|
|
326
|
+
<div className='fw-bold'>Upload File</div>
|
|
327
|
+
<div className='text-muted small mt-1'>Click or drag</div>
|
|
328
|
+
</div>
|
|
329
|
+
</PreviewContainer>
|
|
330
|
+
</upload_button_1.default>)}
|
|
331
|
+
</div>
|
|
332
|
+
</upload_drop_zone_1.default>
|
|
333
|
+
|
|
334
|
+
{/* Upload Preview for images */}
|
|
335
|
+
</uploady_1.default>);
|
|
336
|
+
});
|
|
337
|
+
FileField.displayName = 'FileField';
|
|
338
|
+
exports.default = FileField;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.default = Form;
|
|
13
|
+
const react_hook_form_1 = require("react-hook-form");
|
|
14
|
+
const zod_1 = require("@hookform/resolvers/zod");
|
|
15
|
+
const react_toastify_1 = require("react-toastify");
|
|
16
|
+
const Field_1 = require("./Field");
|
|
17
|
+
function Form({ form, onSubmit, showReset = false }) {
|
|
18
|
+
// Initialize react-hook-form with zod resolver
|
|
19
|
+
const { register, handleSubmit, reset, trigger, clearErrors, control, formState: { touchedFields, isSubmitting, isValid, isDirty } } = (0, react_hook_form_1.useForm)({
|
|
20
|
+
resolver: (0, zod_1.zodResolver)(form.schema),
|
|
21
|
+
mode: 'onChange',
|
|
22
|
+
defaultValues: form.defaultValues
|
|
23
|
+
});
|
|
24
|
+
// Handle form submission
|
|
25
|
+
const handleFormSubmit = (data) => __awaiter(this, void 0, void 0, function* () {
|
|
26
|
+
try {
|
|
27
|
+
yield onSubmit(data);
|
|
28
|
+
reset(data);
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
react_toastify_1.toast.error('Form submission failed');
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
const validateFormFields = (field) => __awaiter(this, void 0, void 0, function* () {
|
|
35
|
+
const fieldNames = Object.values(form.sections)
|
|
36
|
+
.flatMap(section => Object.values(section.fields))
|
|
37
|
+
.map(f => f.name);
|
|
38
|
+
const currentIndex = fieldNames.indexOf(field.name);
|
|
39
|
+
// Trigger validation for all fields before the current one
|
|
40
|
+
const beforeFields = fieldNames.slice(0, currentIndex + 1);
|
|
41
|
+
beforeFields.forEach(name => trigger(name));
|
|
42
|
+
// Clear errors for untouched fields after the current one
|
|
43
|
+
const afterFields = fieldNames.slice(currentIndex + 1);
|
|
44
|
+
const untouchedAfterFields = afterFields.filter(name => !touchedFields[name]);
|
|
45
|
+
clearErrors(untouchedAfterFields);
|
|
46
|
+
});
|
|
47
|
+
const getFieldRegister = (field) => {
|
|
48
|
+
const baseRegister = register(field.name, {
|
|
49
|
+
valueAsNumber: field.type === 'number'
|
|
50
|
+
});
|
|
51
|
+
return Object.assign(Object.assign({}, baseRegister), { onFocus: (_) => __awaiter(this, void 0, void 0, function* () {
|
|
52
|
+
// Call form field validations by a specific order
|
|
53
|
+
yield validateFormFields(field);
|
|
54
|
+
}), onChange: (event) => __awaiter(this, void 0, void 0, function* () {
|
|
55
|
+
// Call the base onChange handler
|
|
56
|
+
yield baseRegister.onChange(event);
|
|
57
|
+
// Trigger re-validation for specified fields
|
|
58
|
+
for (const revalidateFieldName of field.revalidates) {
|
|
59
|
+
yield trigger(revalidateFieldName);
|
|
60
|
+
}
|
|
61
|
+
}) });
|
|
62
|
+
};
|
|
63
|
+
// Render a single field
|
|
64
|
+
const renderField = (field) => {
|
|
65
|
+
const fieldRegister = getFieldRegister(field);
|
|
66
|
+
return (<Field_1.default key={field.name} field={field} register={fieldRegister} control={control}/>);
|
|
67
|
+
};
|
|
68
|
+
// Render section
|
|
69
|
+
const renderSection = (section) => {
|
|
70
|
+
return (<div className={`${section.className} mb-4`}>
|
|
71
|
+
<div className='card'>
|
|
72
|
+
<div className='card-header'>
|
|
73
|
+
{section.title && <h3>{section.title}</h3>}
|
|
74
|
+
</div>
|
|
75
|
+
<div className='card-body'>
|
|
76
|
+
<div className='row g-3'>
|
|
77
|
+
{Object.values(section.fields).map(field => renderField(field))}
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
</div>);
|
|
82
|
+
};
|
|
83
|
+
// Render form buttons
|
|
84
|
+
const renderButtons = () => {
|
|
85
|
+
return (<div className='d-flex gap-2'>
|
|
86
|
+
<button type='submit' className='btn btn-primary' disabled={!isDirty || !isValid || isSubmitting}>
|
|
87
|
+
{isSubmitting ? 'Submitting...' : 'Submit'}
|
|
88
|
+
</button>
|
|
89
|
+
{showReset && (<button type='button' className='btn btn-secondary' onClick={() => reset()} disabled={!isDirty || isSubmitting}>
|
|
90
|
+
Reset
|
|
91
|
+
</button>)}
|
|
92
|
+
</div>);
|
|
93
|
+
};
|
|
94
|
+
return (<form className='row' onSubmit={handleSubmit(handleFormSubmit)} noValidate>
|
|
95
|
+
{/* Render form fields */}
|
|
96
|
+
{form.sections.map(section => renderSection(section))}
|
|
97
|
+
{/* Render buttons */}
|
|
98
|
+
{renderButtons()}
|
|
99
|
+
</form>);
|
|
100
|
+
}
|