@object-ui/react 0.5.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +32 -0
- package/dist/LazyPluginLoader.d.ts +44 -0
- package/dist/LazyPluginLoader.js +92 -3
- package/dist/SchemaRenderer.d.ts +20 -1
- package/dist/SchemaRenderer.js +76 -13
- package/dist/components/form/FieldFactory.js +44 -40
- package/dist/components/form/FormRenderer.js +45 -3
- package/dist/context/ActionContext.d.ts +76 -0
- package/dist/context/ActionContext.js +139 -0
- package/dist/context/DndContext.d.ts +135 -0
- package/dist/context/DndContext.js +120 -0
- package/dist/context/NotificationContext.d.ts +113 -0
- package/dist/context/NotificationContext.js +130 -0
- package/dist/context/ThemeContext.d.ts +94 -0
- package/dist/context/ThemeContext.js +215 -0
- package/dist/context/index.d.ts +4 -0
- package/dist/context/index.js +4 -0
- package/dist/hooks/index.d.ts +22 -0
- package/dist/hooks/index.js +22 -0
- package/dist/hooks/useActionRunner.d.ts +21 -7
- package/dist/hooks/useActionRunner.js +27 -7
- package/dist/hooks/useAnimation.d.ts +46 -0
- package/dist/hooks/useAnimation.js +59 -0
- package/dist/hooks/useClientNotifications.d.ts +53 -0
- package/dist/hooks/useClientNotifications.js +190 -0
- package/dist/hooks/useColumnSummary.d.ts +38 -0
- package/dist/hooks/useColumnSummary.js +54 -0
- package/dist/hooks/useCrudShortcuts.d.ts +39 -0
- package/dist/hooks/useCrudShortcuts.js +96 -0
- package/dist/hooks/useDensityMode.d.ts +48 -0
- package/dist/hooks/useDensityMode.js +60 -0
- package/dist/hooks/useDiscovery.d.ts +78 -0
- package/dist/hooks/useDiscovery.js +96 -0
- package/dist/hooks/useDynamicApp.d.ts +70 -0
- package/dist/hooks/useDynamicApp.js +92 -0
- package/dist/hooks/useETagCache.d.ts +75 -0
- package/dist/hooks/useETagCache.js +306 -0
- package/dist/hooks/useFocusManagement.d.ts +89 -0
- package/dist/hooks/useFocusManagement.js +188 -0
- package/dist/hooks/useFocusTrap.d.ts +41 -0
- package/dist/hooks/useFocusTrap.js +103 -0
- package/dist/hooks/useKeyboardShortcuts.d.ts +49 -0
- package/dist/hooks/useKeyboardShortcuts.js +86 -0
- package/dist/hooks/useNavigationOverlay.d.ts +78 -0
- package/dist/hooks/useNavigationOverlay.js +112 -0
- package/dist/hooks/useOffline.d.ts +96 -0
- package/dist/hooks/useOffline.js +185 -0
- package/dist/hooks/usePageTransition.d.ts +60 -0
- package/dist/hooks/usePageTransition.js +111 -0
- package/dist/hooks/usePageVariables.d.ts +67 -0
- package/dist/hooks/usePageVariables.js +109 -0
- package/dist/hooks/usePerformance.d.ts +87 -0
- package/dist/hooks/usePerformance.js +165 -0
- package/dist/hooks/usePerformanceBudget.d.ts +78 -0
- package/dist/hooks/usePerformanceBudget.js +209 -0
- package/dist/hooks/useReducedMotion.d.ts +28 -0
- package/dist/hooks/useReducedMotion.js +48 -0
- package/dist/hooks/useSchemaPersistence.d.ts +87 -0
- package/dist/hooks/useSchemaPersistence.js +173 -0
- package/dist/hooks/useViewData.d.ts +73 -0
- package/dist/hooks/useViewData.js +135 -0
- package/dist/hooks/useViewSharing.d.ts +57 -0
- package/dist/hooks/useViewSharing.js +74 -0
- package/dist/hooks/useViewTransition.d.ts +54 -0
- package/dist/hooks/useViewTransition.js +83 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/utils/i18n.d.ts +10 -0
- package/dist/utils/i18n.js +12 -0
- package/package.json +7 -5
- package/src/LazyPluginLoader.tsx +129 -7
- package/src/SchemaRenderer.tsx +122 -13
- package/src/__tests__/LazyPluginLoader.test.tsx +97 -1
- package/src/__tests__/SchemaRenderer.aria.test.tsx +118 -0
- package/src/__tests__/SchemaRenderer.errorBoundary.test.tsx +114 -0
- package/src/__tests__/SchemaRendererProvider.smoke.test.tsx +132 -0
- package/src/__tests__/ThemeProvider.test.tsx +451 -0
- package/src/__tests__/useNavigationOverlay.test.ts +416 -0
- package/src/__tests__/useOffline.test.ts +198 -0
- package/src/__tests__/usePageTransition.test.ts +214 -0
- package/src/__tests__/usePageVariables.test.tsx +334 -0
- package/src/__tests__/usePerformance.test.ts +137 -0
- package/src/__tests__/useViewData.test.tsx +422 -0
- package/src/__tests__/view-switching.test.tsx +456 -0
- package/src/components/form/FieldFactory.tsx +51 -46
- package/src/components/form/FormRenderer.test.tsx +145 -0
- package/src/components/form/FormRenderer.tsx +52 -3
- package/src/context/ActionContext.tsx +213 -0
- package/src/context/DndContext.tsx +260 -0
- package/src/context/NotificationContext.tsx +280 -0
- package/src/context/ThemeContext.tsx +325 -0
- package/src/context/__tests__/ActionContext.test.tsx +174 -0
- package/src/context/__tests__/DndContext.test.tsx +150 -0
- package/src/context/__tests__/NotificationContext.test.tsx +176 -0
- package/src/context/index.ts +4 -0
- package/src/hooks/__tests__/useActionRunner.test.ts +153 -0
- package/src/hooks/__tests__/useAnimation.test.ts +82 -0
- package/src/hooks/__tests__/useColumnSummary.test.ts +93 -0
- package/src/hooks/__tests__/useDensityMode.test.ts +81 -0
- package/src/hooks/__tests__/useDiscovery.test.tsx +149 -0
- package/src/hooks/__tests__/useDynamicApp.test.ts +161 -0
- package/src/hooks/__tests__/useETagCache.test.ts +153 -0
- package/src/hooks/__tests__/useExpression.test.ts +86 -0
- package/src/hooks/__tests__/useFocusTrap.test.ts +47 -0
- package/src/hooks/__tests__/useKeyboardShortcuts.test.ts +144 -0
- package/src/hooks/__tests__/usePerformanceBudget.test.ts +157 -0
- package/src/hooks/__tests__/useSchemaPersistence.test.ts +220 -0
- package/src/hooks/__tests__/useViewSharing.test.ts +121 -0
- package/src/hooks/__tests__/useViewTransition.test.ts +61 -0
- package/src/hooks/index.ts +22 -1
- package/src/hooks/useActionRunner.ts +49 -11
- package/src/hooks/useAnimation.ts +117 -0
- package/src/hooks/useClientNotifications.ts +251 -0
- package/src/hooks/useColumnSummary.ts +87 -0
- package/src/hooks/useCrudShortcuts.ts +126 -0
- package/src/hooks/useDensityMode.ts +102 -0
- package/src/hooks/useDiscovery.ts +141 -0
- package/src/hooks/useDynamicApp.ts +142 -0
- package/src/hooks/useETagCache.ts +404 -0
- package/src/hooks/useFocusManagement.ts +248 -0
- package/src/hooks/useFocusTrap.ts +138 -0
- package/src/hooks/useKeyboardShortcuts.ts +119 -0
- package/src/hooks/useNavigationOverlay.ts +180 -0
- package/src/hooks/useOffline.ts +301 -0
- package/src/hooks/usePageTransition.ts +187 -0
- package/src/hooks/usePageVariables.tsx +157 -0
- package/src/hooks/usePerformance.ts +246 -0
- package/src/hooks/usePerformanceBudget.ts +313 -0
- package/src/hooks/useReducedMotion.ts +53 -0
- package/src/hooks/useSchemaPersistence.ts +222 -0
- package/src/hooks/useViewData.ts +201 -0
- package/src/hooks/useViewSharing.ts +140 -0
- package/src/hooks/useViewTransition.ts +144 -0
- package/src/index.ts +21 -0
- package/src/utils/i18n.ts +10 -0
- package/tsconfig.json +1 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/__tests__/LazyPluginLoader.test.d.ts +0 -8
- package/dist/__tests__/LazyPluginLoader.test.js +0 -49
- package/dist/components/form/FormRenderer.test.d.ts +0 -8
- package/dist/components/form/FormRenderer.test.js +0 -534
- package/dist/index.test.d.ts +0 -8
- package/dist/index.test.js +0 -13
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import React from 'react';
|
|
10
10
|
import { UseFormReturn } from 'react-hook-form';
|
|
11
11
|
import type { FormField } from '@objectstack/spec/ui';
|
|
12
|
+
import { resolveI18nLabel } from '../../utils/i18n';
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Extended form field with additional properties for complex widgets
|
|
@@ -58,6 +59,11 @@ export const FieldFactory: React.FC<FieldFactoryProps> = ({
|
|
|
58
59
|
const widgetType = field.widget || 'text';
|
|
59
60
|
const fieldName = field.field;
|
|
60
61
|
const error = errors[fieldName];
|
|
62
|
+
|
|
63
|
+
// Resolve I18nLabel values to plain strings
|
|
64
|
+
const fieldLabel = resolveI18nLabel(field.label);
|
|
65
|
+
const fieldPlaceholder = resolveI18nLabel(field.placeholder);
|
|
66
|
+
const fieldHelpText = resolveI18nLabel(field.helpText);
|
|
61
67
|
|
|
62
68
|
// Helper function to handle multiple select value conversion
|
|
63
69
|
const handleMultipleSelectValue = (value: any) => {
|
|
@@ -68,9 +74,8 @@ export const FieldFactory: React.FC<FieldFactoryProps> = ({
|
|
|
68
74
|
};
|
|
69
75
|
|
|
70
76
|
// Handle conditional visibility
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
// Skip if explicitly hidden
|
|
77
|
+
// Fields are hidden when explicitly hidden or when visibleOn evaluates to false
|
|
78
|
+
// Note: dependsOn is handled at the FormSectionRenderer level
|
|
74
79
|
if (field.hidden) {
|
|
75
80
|
return null;
|
|
76
81
|
}
|
|
@@ -78,15 +83,15 @@ export const FieldFactory: React.FC<FieldFactoryProps> = ({
|
|
|
78
83
|
// Common field wrapper
|
|
79
84
|
const renderField = (input: React.ReactNode) => (
|
|
80
85
|
<div className="space-y-2">
|
|
81
|
-
{
|
|
86
|
+
{fieldLabel && (
|
|
82
87
|
<label htmlFor={fieldName} className="block text-sm font-medium text-gray-700">
|
|
83
|
-
{
|
|
88
|
+
{fieldLabel}
|
|
84
89
|
{field.required && <span className="text-red-500 ml-1">*</span>}
|
|
85
90
|
</label>
|
|
86
91
|
)}
|
|
87
92
|
{input}
|
|
88
|
-
{
|
|
89
|
-
<p className="text-sm text-gray-500">{
|
|
93
|
+
{fieldHelpText && (
|
|
94
|
+
<p className="text-sm text-gray-500">{fieldHelpText}</p>
|
|
90
95
|
)}
|
|
91
96
|
{error && (
|
|
92
97
|
<p className="text-sm text-red-600">{error.message as string}</p>
|
|
@@ -106,11 +111,11 @@ export const FieldFactory: React.FC<FieldFactoryProps> = ({
|
|
|
106
111
|
<input
|
|
107
112
|
id={fieldName}
|
|
108
113
|
type={widgetType === 'string' ? 'text' : widgetType}
|
|
109
|
-
placeholder={
|
|
114
|
+
placeholder={fieldPlaceholder}
|
|
110
115
|
disabled={disabled}
|
|
111
116
|
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"
|
|
112
117
|
{...register(fieldName, {
|
|
113
|
-
required: field.required ? `${
|
|
118
|
+
required: field.required ? `${fieldLabel || fieldName} is required` : false,
|
|
114
119
|
})}
|
|
115
120
|
/>
|
|
116
121
|
);
|
|
@@ -122,12 +127,12 @@ export const FieldFactory: React.FC<FieldFactoryProps> = ({
|
|
|
122
127
|
<input
|
|
123
128
|
id={fieldName}
|
|
124
129
|
type="number"
|
|
125
|
-
placeholder={
|
|
130
|
+
placeholder={fieldPlaceholder}
|
|
126
131
|
disabled={disabled}
|
|
127
132
|
step={widgetType === 'integer' ? '1' : 'any'}
|
|
128
133
|
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"
|
|
129
134
|
{...register(fieldName, {
|
|
130
|
-
required: field.required ? `${
|
|
135
|
+
required: field.required ? `${fieldLabel || fieldName} is required` : false,
|
|
131
136
|
valueAsNumber: true,
|
|
132
137
|
})}
|
|
133
138
|
/>
|
|
@@ -145,14 +150,14 @@ export const FieldFactory: React.FC<FieldFactoryProps> = ({
|
|
|
145
150
|
{...register(fieldName)}
|
|
146
151
|
/>
|
|
147
152
|
<div className="flex-1">
|
|
148
|
-
{
|
|
153
|
+
{fieldLabel && (
|
|
149
154
|
<label htmlFor={fieldName} className="text-sm font-medium text-gray-700">
|
|
150
|
-
{
|
|
155
|
+
{fieldLabel}
|
|
151
156
|
{field.required && <span className="text-red-500 ml-1">*</span>}
|
|
152
157
|
</label>
|
|
153
158
|
)}
|
|
154
|
-
{
|
|
155
|
-
<p className="text-sm text-gray-500 mt-1">{
|
|
159
|
+
{fieldHelpText && (
|
|
160
|
+
<p className="text-sm text-gray-500 mt-1">{fieldHelpText}</p>
|
|
156
161
|
)}
|
|
157
162
|
{error && (
|
|
158
163
|
<p className="text-sm text-red-600 mt-1">{error.message as string}</p>
|
|
@@ -165,12 +170,12 @@ export const FieldFactory: React.FC<FieldFactoryProps> = ({
|
|
|
165
170
|
return renderField(
|
|
166
171
|
<textarea
|
|
167
172
|
id={fieldName}
|
|
168
|
-
placeholder={
|
|
173
|
+
placeholder={fieldPlaceholder}
|
|
169
174
|
disabled={disabled}
|
|
170
175
|
rows={4}
|
|
171
176
|
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"
|
|
172
177
|
{...register(fieldName, {
|
|
173
|
-
required: field.required ? `${
|
|
178
|
+
required: field.required ? `${fieldLabel || fieldName} is required` : false,
|
|
174
179
|
})}
|
|
175
180
|
/>
|
|
176
181
|
);
|
|
@@ -184,11 +189,11 @@ export const FieldFactory: React.FC<FieldFactoryProps> = ({
|
|
|
184
189
|
multiple={extendedField.multiple}
|
|
185
190
|
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"
|
|
186
191
|
{...register(fieldName, {
|
|
187
|
-
required: field.required ? `${
|
|
192
|
+
required: field.required ? `${fieldLabel || fieldName} is required` : false,
|
|
188
193
|
setValueAs: handleMultipleSelectValue,
|
|
189
194
|
})}
|
|
190
195
|
>
|
|
191
|
-
{!extendedField.multiple && <option value="">{
|
|
196
|
+
{!extendedField.multiple && <option value="">{fieldPlaceholder || 'Select an option'}</option>}
|
|
192
197
|
{extendedField.options?.map((option) => (
|
|
193
198
|
<option key={option.value} value={option.value} disabled={option.disabled}>
|
|
194
199
|
{option.label}
|
|
@@ -202,11 +207,11 @@ export const FieldFactory: React.FC<FieldFactoryProps> = ({
|
|
|
202
207
|
<input
|
|
203
208
|
id={fieldName}
|
|
204
209
|
type="date"
|
|
205
|
-
placeholder={
|
|
210
|
+
placeholder={fieldPlaceholder}
|
|
206
211
|
disabled={disabled}
|
|
207
212
|
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"
|
|
208
213
|
{...register(fieldName, {
|
|
209
|
-
required: field.required ? `${
|
|
214
|
+
required: field.required ? `${fieldLabel || fieldName} is required` : false,
|
|
210
215
|
})}
|
|
211
216
|
/>
|
|
212
217
|
);
|
|
@@ -217,11 +222,11 @@ export const FieldFactory: React.FC<FieldFactoryProps> = ({
|
|
|
217
222
|
<input
|
|
218
223
|
id={fieldName}
|
|
219
224
|
type="datetime-local"
|
|
220
|
-
placeholder={
|
|
225
|
+
placeholder={fieldPlaceholder}
|
|
221
226
|
disabled={disabled}
|
|
222
227
|
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"
|
|
223
228
|
{...register(fieldName, {
|
|
224
|
-
required: field.required ? `${
|
|
229
|
+
required: field.required ? `${fieldLabel || fieldName} is required` : false,
|
|
225
230
|
})}
|
|
226
231
|
/>
|
|
227
232
|
);
|
|
@@ -231,11 +236,11 @@ export const FieldFactory: React.FC<FieldFactoryProps> = ({
|
|
|
231
236
|
<input
|
|
232
237
|
id={fieldName}
|
|
233
238
|
type="time"
|
|
234
|
-
placeholder={
|
|
239
|
+
placeholder={fieldPlaceholder}
|
|
235
240
|
disabled={disabled}
|
|
236
241
|
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"
|
|
237
242
|
{...register(fieldName, {
|
|
238
|
-
required: field.required ? `${
|
|
243
|
+
required: field.required ? `${fieldLabel || fieldName} is required` : false,
|
|
239
244
|
})}
|
|
240
245
|
/>
|
|
241
246
|
);
|
|
@@ -247,12 +252,12 @@ export const FieldFactory: React.FC<FieldFactoryProps> = ({
|
|
|
247
252
|
<input
|
|
248
253
|
id={fieldName}
|
|
249
254
|
type="number"
|
|
250
|
-
placeholder={
|
|
255
|
+
placeholder={fieldPlaceholder}
|
|
251
256
|
disabled={disabled}
|
|
252
257
|
step="0.01"
|
|
253
258
|
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"
|
|
254
259
|
{...register(fieldName, {
|
|
255
|
-
required: field.required ? `${
|
|
260
|
+
required: field.required ? `${fieldLabel || fieldName} is required` : false,
|
|
256
261
|
valueAsNumber: true,
|
|
257
262
|
})}
|
|
258
263
|
/>
|
|
@@ -265,14 +270,14 @@ export const FieldFactory: React.FC<FieldFactoryProps> = ({
|
|
|
265
270
|
<input
|
|
266
271
|
id={fieldName}
|
|
267
272
|
type="number"
|
|
268
|
-
placeholder={
|
|
273
|
+
placeholder={fieldPlaceholder}
|
|
269
274
|
disabled={disabled}
|
|
270
275
|
step="0.01"
|
|
271
276
|
min="0"
|
|
272
277
|
max="100"
|
|
273
278
|
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"
|
|
274
279
|
{...register(fieldName, {
|
|
275
|
-
required: field.required ? `${
|
|
280
|
+
required: field.required ? `${fieldLabel || fieldName} is required` : false,
|
|
276
281
|
valueAsNumber: true,
|
|
277
282
|
})}
|
|
278
283
|
/>
|
|
@@ -285,11 +290,11 @@ export const FieldFactory: React.FC<FieldFactoryProps> = ({
|
|
|
285
290
|
<input
|
|
286
291
|
id={fieldName}
|
|
287
292
|
type="tel"
|
|
288
|
-
placeholder={
|
|
293
|
+
placeholder={fieldPlaceholder || '+1 (555) 000-0000'}
|
|
289
294
|
disabled={disabled}
|
|
290
295
|
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"
|
|
291
296
|
{...register(fieldName, {
|
|
292
|
-
required: field.required ? `${
|
|
297
|
+
required: field.required ? `${fieldLabel || fieldName} is required` : false,
|
|
293
298
|
})}
|
|
294
299
|
/>
|
|
295
300
|
);
|
|
@@ -298,12 +303,12 @@ export const FieldFactory: React.FC<FieldFactoryProps> = ({
|
|
|
298
303
|
return renderField(
|
|
299
304
|
<textarea
|
|
300
305
|
id={fieldName}
|
|
301
|
-
placeholder={
|
|
306
|
+
placeholder={fieldPlaceholder || 'Enter markdown text...'}
|
|
302
307
|
disabled={disabled}
|
|
303
308
|
rows={8}
|
|
304
309
|
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"
|
|
305
310
|
{...register(fieldName, {
|
|
306
|
-
required: field.required ? `${
|
|
311
|
+
required: field.required ? `${fieldLabel || fieldName} is required` : false,
|
|
307
312
|
})}
|
|
308
313
|
/>
|
|
309
314
|
);
|
|
@@ -312,12 +317,12 @@ export const FieldFactory: React.FC<FieldFactoryProps> = ({
|
|
|
312
317
|
return renderField(
|
|
313
318
|
<textarea
|
|
314
319
|
id={fieldName}
|
|
315
|
-
placeholder={
|
|
320
|
+
placeholder={fieldPlaceholder || 'Enter HTML...'}
|
|
316
321
|
disabled={disabled}
|
|
317
322
|
rows={8}
|
|
318
323
|
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"
|
|
319
324
|
{...register(fieldName, {
|
|
320
|
-
required: field.required ? `${
|
|
325
|
+
required: field.required ? `${fieldLabel || fieldName} is required` : false,
|
|
321
326
|
})}
|
|
322
327
|
/>
|
|
323
328
|
);
|
|
@@ -332,7 +337,7 @@ export const FieldFactory: React.FC<FieldFactoryProps> = ({
|
|
|
332
337
|
accept={extendedField.accept?.join(',')}
|
|
333
338
|
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"
|
|
334
339
|
{...register(fieldName, {
|
|
335
|
-
required: field.required ? `${
|
|
340
|
+
required: field.required ? `${fieldLabel || fieldName} is required` : false,
|
|
336
341
|
})}
|
|
337
342
|
/>
|
|
338
343
|
);
|
|
@@ -347,7 +352,7 @@ export const FieldFactory: React.FC<FieldFactoryProps> = ({
|
|
|
347
352
|
accept={extendedField.accept?.join(',') || 'image/*'}
|
|
348
353
|
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"
|
|
349
354
|
{...register(fieldName, {
|
|
350
|
-
required: field.required ? `${
|
|
355
|
+
required: field.required ? `${fieldLabel || fieldName} is required` : false,
|
|
351
356
|
})}
|
|
352
357
|
/>
|
|
353
358
|
);
|
|
@@ -391,11 +396,11 @@ export const FieldFactory: React.FC<FieldFactoryProps> = ({
|
|
|
391
396
|
multiple={extendedField.multiple}
|
|
392
397
|
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"
|
|
393
398
|
{...register(fieldName, {
|
|
394
|
-
required: field.required ? `${
|
|
399
|
+
required: field.required ? `${fieldLabel || fieldName} is required` : false,
|
|
395
400
|
setValueAs: handleMultipleSelectValue,
|
|
396
401
|
})}
|
|
397
402
|
>
|
|
398
|
-
{!extendedField.multiple && <option value="">{
|
|
403
|
+
{!extendedField.multiple && <option value="">{fieldPlaceholder || 'Select an option'}</option>}
|
|
399
404
|
{extendedField.options?.map((option) => (
|
|
400
405
|
<option key={option.value} value={option.value} disabled={option.disabled}>
|
|
401
406
|
{option.label}
|
|
@@ -413,11 +418,11 @@ export const FieldFactory: React.FC<FieldFactoryProps> = ({
|
|
|
413
418
|
multiple={extendedField.multiple}
|
|
414
419
|
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"
|
|
415
420
|
{...register(fieldName, {
|
|
416
|
-
required: field.required ? `${
|
|
421
|
+
required: field.required ? `${fieldLabel || fieldName} is required` : false,
|
|
417
422
|
setValueAs: handleMultipleSelectValue,
|
|
418
423
|
})}
|
|
419
424
|
>
|
|
420
|
-
{!extendedField.multiple && <option value="">{
|
|
425
|
+
{!extendedField.multiple && <option value="">{fieldPlaceholder || 'Select user'}</option>}
|
|
421
426
|
{extendedField.options?.map((option) => (
|
|
422
427
|
<option key={option.value} value={option.value} disabled={option.disabled}>
|
|
423
428
|
{option.label}
|
|
@@ -444,12 +449,12 @@ export const FieldFactory: React.FC<FieldFactoryProps> = ({
|
|
|
444
449
|
return renderField(
|
|
445
450
|
<textarea
|
|
446
451
|
id={fieldName}
|
|
447
|
-
placeholder={
|
|
452
|
+
placeholder={fieldPlaceholder || 'Enter JSON object...'}
|
|
448
453
|
disabled={disabled}
|
|
449
454
|
rows={6}
|
|
450
455
|
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"
|
|
451
456
|
{...register(fieldName, {
|
|
452
|
-
required: field.required ? `${
|
|
457
|
+
required: field.required ? `${fieldLabel || fieldName} is required` : false,
|
|
453
458
|
validate: (value) => {
|
|
454
459
|
if (!value) return true;
|
|
455
460
|
try {
|
|
@@ -469,7 +474,7 @@ export const FieldFactory: React.FC<FieldFactoryProps> = ({
|
|
|
469
474
|
<input
|
|
470
475
|
id={fieldName}
|
|
471
476
|
type="text"
|
|
472
|
-
placeholder={
|
|
477
|
+
placeholder={fieldPlaceholder || 'Vector data (read-only)'}
|
|
473
478
|
disabled={true}
|
|
474
479
|
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-gray-50 cursor-not-allowed text-gray-600"
|
|
475
480
|
{...register(fieldName)}
|
|
@@ -490,11 +495,11 @@ export const FieldFactory: React.FC<FieldFactoryProps> = ({
|
|
|
490
495
|
<input
|
|
491
496
|
id={fieldName}
|
|
492
497
|
type="text"
|
|
493
|
-
placeholder={
|
|
498
|
+
placeholder={fieldPlaceholder}
|
|
494
499
|
disabled={disabled}
|
|
495
500
|
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"
|
|
496
501
|
{...register(fieldName, {
|
|
497
|
-
required: field.required ? `${
|
|
502
|
+
required: field.required ? `${fieldLabel || fieldName} is required` : false,
|
|
498
503
|
})}
|
|
499
504
|
/>
|
|
500
505
|
);
|
|
@@ -596,4 +596,149 @@ describe('FormRenderer', () => {
|
|
|
596
596
|
expect(textarea.tagName).toBe('TEXTAREA');
|
|
597
597
|
});
|
|
598
598
|
});
|
|
599
|
+
|
|
600
|
+
// ==========================================================================
|
|
601
|
+
// FormField.dependsOn — conditional field visibility based on parent value
|
|
602
|
+
// ==========================================================================
|
|
603
|
+
|
|
604
|
+
describe('FormField.dependsOn', () => {
|
|
605
|
+
it('should hide fields with dependsOn when parent has no value', () => {
|
|
606
|
+
const schema: FormView = {
|
|
607
|
+
type: 'simple',
|
|
608
|
+
sections: [
|
|
609
|
+
{
|
|
610
|
+
label: 'Cascading Fields',
|
|
611
|
+
collapsible: false,
|
|
612
|
+
collapsed: false,
|
|
613
|
+
columns: 1,
|
|
614
|
+
fields: [
|
|
615
|
+
{
|
|
616
|
+
field: 'country',
|
|
617
|
+
label: 'Country',
|
|
618
|
+
widget: 'text',
|
|
619
|
+
},
|
|
620
|
+
{
|
|
621
|
+
field: 'state',
|
|
622
|
+
label: 'State',
|
|
623
|
+
widget: 'text',
|
|
624
|
+
dependsOn: 'country',
|
|
625
|
+
},
|
|
626
|
+
],
|
|
627
|
+
},
|
|
628
|
+
],
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
render(<FormRenderer schema={schema} />);
|
|
632
|
+
|
|
633
|
+
// Parent field is always visible
|
|
634
|
+
expect(screen.getByLabelText('Country')).toBeInTheDocument();
|
|
635
|
+
// Dependent field is hidden when parent has no value
|
|
636
|
+
expect(screen.queryByLabelText('State')).not.toBeInTheDocument();
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
it('should show fields with dependsOn when parent has a value', () => {
|
|
640
|
+
const schema: FormView = {
|
|
641
|
+
type: 'simple',
|
|
642
|
+
sections: [
|
|
643
|
+
{
|
|
644
|
+
label: 'Cascading Fields',
|
|
645
|
+
collapsible: false,
|
|
646
|
+
collapsed: false,
|
|
647
|
+
columns: 1,
|
|
648
|
+
fields: [
|
|
649
|
+
{
|
|
650
|
+
field: 'country',
|
|
651
|
+
label: 'Country',
|
|
652
|
+
widget: 'text',
|
|
653
|
+
},
|
|
654
|
+
{
|
|
655
|
+
field: 'state',
|
|
656
|
+
label: 'State',
|
|
657
|
+
widget: 'text',
|
|
658
|
+
dependsOn: 'country',
|
|
659
|
+
},
|
|
660
|
+
],
|
|
661
|
+
},
|
|
662
|
+
],
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
// When country has an initial value, state should be visible
|
|
666
|
+
render(<FormRenderer schema={schema} data={{ country: 'USA' }} />);
|
|
667
|
+
|
|
668
|
+
expect(screen.getByLabelText('Country')).toBeInTheDocument();
|
|
669
|
+
expect(screen.getByLabelText('State')).toBeInTheDocument();
|
|
670
|
+
});
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
// ==========================================================================
|
|
674
|
+
// FormField.visibleOn — expression-based visibility
|
|
675
|
+
// ==========================================================================
|
|
676
|
+
|
|
677
|
+
describe('FormField.visibleOn', () => {
|
|
678
|
+
it('should hide fields when visibleOn evaluates to false', () => {
|
|
679
|
+
const schema: FormView = {
|
|
680
|
+
type: 'simple',
|
|
681
|
+
sections: [
|
|
682
|
+
{
|
|
683
|
+
label: 'Conditional Fields',
|
|
684
|
+
collapsible: false,
|
|
685
|
+
collapsed: false,
|
|
686
|
+
columns: 1,
|
|
687
|
+
fields: [
|
|
688
|
+
{
|
|
689
|
+
field: 'status',
|
|
690
|
+
label: 'Status',
|
|
691
|
+
widget: 'text',
|
|
692
|
+
},
|
|
693
|
+
{
|
|
694
|
+
field: 'price',
|
|
695
|
+
label: 'Price',
|
|
696
|
+
widget: 'currency',
|
|
697
|
+
visibleOn: "${data.status === 'active'}",
|
|
698
|
+
},
|
|
699
|
+
],
|
|
700
|
+
},
|
|
701
|
+
],
|
|
702
|
+
};
|
|
703
|
+
|
|
704
|
+
// Status is 'draft', so price should be hidden
|
|
705
|
+
render(<FormRenderer schema={schema} data={{ status: 'draft' }} />);
|
|
706
|
+
|
|
707
|
+
expect(screen.getByLabelText('Status')).toBeInTheDocument();
|
|
708
|
+
expect(screen.queryByLabelText('Price')).not.toBeInTheDocument();
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
it('should show fields when visibleOn evaluates to true', () => {
|
|
712
|
+
const schema: FormView = {
|
|
713
|
+
type: 'simple',
|
|
714
|
+
sections: [
|
|
715
|
+
{
|
|
716
|
+
label: 'Conditional Fields',
|
|
717
|
+
collapsible: false,
|
|
718
|
+
collapsed: false,
|
|
719
|
+
columns: 1,
|
|
720
|
+
fields: [
|
|
721
|
+
{
|
|
722
|
+
field: 'status',
|
|
723
|
+
label: 'Status',
|
|
724
|
+
widget: 'text',
|
|
725
|
+
},
|
|
726
|
+
{
|
|
727
|
+
field: 'price',
|
|
728
|
+
label: 'Price',
|
|
729
|
+
widget: 'currency',
|
|
730
|
+
visibleOn: "${data.status === 'active'}",
|
|
731
|
+
},
|
|
732
|
+
],
|
|
733
|
+
},
|
|
734
|
+
],
|
|
735
|
+
};
|
|
736
|
+
|
|
737
|
+
// Status is 'active', so price should be visible
|
|
738
|
+
render(<FormRenderer schema={schema} data={{ status: 'active' }} />);
|
|
739
|
+
|
|
740
|
+
expect(screen.getByLabelText('Status')).toBeInTheDocument();
|
|
741
|
+
expect(screen.getByLabelText('Price')).toBeInTheDocument();
|
|
742
|
+
});
|
|
743
|
+
});
|
|
599
744
|
});
|
|
@@ -10,6 +10,8 @@ import React from 'react';
|
|
|
10
10
|
import { useForm, FormProvider, UseFormReturn } from 'react-hook-form';
|
|
11
11
|
import type { FormView, FormSection } from '@objectstack/spec/ui';
|
|
12
12
|
import { FieldFactory } from './FieldFactory';
|
|
13
|
+
import { ExpressionEvaluator } from '@object-ui/core';
|
|
14
|
+
import { resolveI18nLabel } from '../../utils/i18n';
|
|
13
15
|
|
|
14
16
|
export interface FormRendererProps {
|
|
15
17
|
/**
|
|
@@ -137,6 +139,39 @@ const COL_SPAN = {
|
|
|
137
139
|
4: 'col-span-4',
|
|
138
140
|
} as const;
|
|
139
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Evaluate whether a field should be visible based on visibleOn expression
|
|
144
|
+
* and dependsOn parent field value.
|
|
145
|
+
*/
|
|
146
|
+
function evaluateFieldVisibility(
|
|
147
|
+
fieldConfig: any,
|
|
148
|
+
formValues: Record<string, any>,
|
|
149
|
+
): boolean {
|
|
150
|
+
// If explicitly hidden, skip
|
|
151
|
+
if (fieldConfig.hidden) {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Evaluate visibleOn expression
|
|
156
|
+
if (fieldConfig.visibleOn) {
|
|
157
|
+
try {
|
|
158
|
+
const evaluator = new ExpressionEvaluator({ data: formValues, form: formValues });
|
|
159
|
+
return evaluator.evaluateCondition(fieldConfig.visibleOn);
|
|
160
|
+
} catch {
|
|
161
|
+
// On error, default to visible
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Evaluate dependsOn: field is hidden if parent field has no value
|
|
167
|
+
if (fieldConfig.dependsOn) {
|
|
168
|
+
const parentValue = formValues[fieldConfig.dependsOn];
|
|
169
|
+
return parentValue !== undefined && parentValue !== null && parentValue !== '';
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
|
|
140
175
|
/**
|
|
141
176
|
* Renders a form section with grid layout
|
|
142
177
|
*/
|
|
@@ -150,6 +185,20 @@ const FormSectionRenderer: React.FC<FormSectionRendererProps> = ({
|
|
|
150
185
|
// Determine grid columns based on section.columns
|
|
151
186
|
const gridCols = GRID_COLS[section.columns || 1];
|
|
152
187
|
|
|
188
|
+
// Collect whether there are any conditional fields
|
|
189
|
+
const hasConditionalFields = React.useMemo(() => {
|
|
190
|
+
return section.fields.some((field) => {
|
|
191
|
+
if (typeof field === 'string') return false;
|
|
192
|
+
return !!field.dependsOn || !!field.visibleOn;
|
|
193
|
+
});
|
|
194
|
+
}, [section.fields]);
|
|
195
|
+
|
|
196
|
+
// Watch form values for conditional field evaluation
|
|
197
|
+
// Only watch when there are fields with dependsOn or visibleOn to avoid
|
|
198
|
+
// unnecessary re-renders in forms without conditional fields.
|
|
199
|
+
const allFormValues = methods.watch();
|
|
200
|
+
const formValues = hasConditionalFields ? allFormValues : undefined;
|
|
201
|
+
|
|
153
202
|
const handleToggleCollapse = () => {
|
|
154
203
|
if (section.collapsible) {
|
|
155
204
|
setIsCollapsed(!isCollapsed);
|
|
@@ -165,7 +214,7 @@ const FormSectionRenderer: React.FC<FormSectionRendererProps> = ({
|
|
|
165
214
|
}`}
|
|
166
215
|
onClick={handleToggleCollapse}
|
|
167
216
|
>
|
|
168
|
-
<h3 className="text-lg font-semibold">{section.label}</h3>
|
|
217
|
+
<h3 className="text-lg font-semibold">{resolveI18nLabel(section.label)}</h3>
|
|
169
218
|
{section.collapsible && (
|
|
170
219
|
<span className="text-sm text-gray-500">
|
|
171
220
|
{isCollapsed ? '▶' : '▼'}
|
|
@@ -181,8 +230,8 @@ const FormSectionRenderer: React.FC<FormSectionRendererProps> = ({
|
|
|
181
230
|
const fieldName = typeof field === 'string' ? field : field.field;
|
|
182
231
|
const fieldConfig = typeof field === 'string' ? { field: fieldName } : field;
|
|
183
232
|
|
|
184
|
-
//
|
|
185
|
-
if (fieldConfig
|
|
233
|
+
// Evaluate visibility: static hidden, visibleOn expression, dependsOn parent
|
|
234
|
+
if (!evaluateFieldVisibility(fieldConfig, formValues || {})) {
|
|
186
235
|
return null;
|
|
187
236
|
}
|
|
188
237
|
|