Skip to content

Commit c6942b1

Browse files
Copilothotlong
andcommitted
Fix code review issues: Add TypeScript types, fix Tailwind classes, improve error handling and validation
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 0eb968a commit c6942b1

2 files changed

Lines changed: 113 additions & 26 deletions

File tree

docs/components/form.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ The `validation` object supports various rules:
9696
"message": "Project name must not exceed 50 characters"
9797
},
9898
"pattern": {
99-
"value": "^[a-zA-Z0-9\\s]+$",
99+
"value": "/^[a-zA-Z0-9\\s]+$/",
100100
"message": "Only letters, numbers, and spaces allowed"
101101
}
102102
}
@@ -110,9 +110,11 @@ The `validation` object supports various rules:
110110
- `maxLength`: `{value: number, message: string}` - Maximum length
111111
- `min`: `{value: number, message: string}` - Minimum value (for numbers)
112112
- `max`: `{value: number, message: string}` - Maximum value (for numbers)
113-
- `pattern`: `{value: string, message: string}` - Regex pattern
113+
- `pattern`: `{value: string | RegExp, message: string}` - Regex pattern (string format: "/pattern/" or raw regex)
114114
- `validate`: Custom validation function
115115

116+
**Note**: For `pattern` validation, you can provide either a regex string (e.g., "/^[a-z]+$/") or a raw regex string without delimiters (e.g., "^[a-z]+$"). React Hook Form will handle the conversion internally.
117+
116118
## Conditional Fields
117119

118120
Show or hide fields based on other field values:

packages/components/src/renderers/form/form.tsx

Lines changed: 109 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,48 @@ import {
1515
import { renderChildren } from '../../lib/utils';
1616
import { Alert, AlertDescription } from '@/ui/alert';
1717
import { AlertCircle, Loader2 } from 'lucide-react';
18+
import { cn } from '@/lib/utils';
1819
import React from 'react';
1920

21+
// TypeScript interfaces for type safety
22+
interface SelectOption {
23+
label: string;
24+
value: string;
25+
}
26+
27+
interface FieldValidation {
28+
required?: string | boolean;
29+
minLength?: { value: number; message: string };
30+
maxLength?: { value: number; message: string };
31+
min?: { value: number; message: string };
32+
max?: { value: number; message: string };
33+
pattern?: { value: string | RegExp; message: string };
34+
validate?: (value: any) => boolean | string;
35+
}
36+
37+
interface FieldCondition {
38+
field: string;
39+
equals?: any;
40+
notEquals?: any;
41+
in?: any[];
42+
}
43+
44+
interface FormFieldConfig {
45+
id?: string;
46+
name: string;
47+
label?: string;
48+
description?: string;
49+
type?: string;
50+
inputType?: string;
51+
required?: boolean;
52+
disabled?: boolean;
53+
placeholder?: string;
54+
options?: SelectOption[];
55+
validation?: FieldValidation;
56+
condition?: FieldCondition;
57+
[key: string]: any;
58+
}
59+
2060
// Form renderer component - Airtable-style feature-complete form
2161
ComponentRegistry.register('form',
2262
({ schema, className, onAction, ...props }) => {
@@ -44,9 +84,9 @@ ComponentRegistry.register('form',
4484
const [isSubmitting, setIsSubmitting] = React.useState(false);
4585
const [submitError, setSubmitError] = React.useState<string | null>(null);
4686

47-
// Watch for form changes
87+
// Watch for form changes - only track changes when onAction is available
4888
React.useEffect(() => {
49-
if (onChangeProp && onAction) {
89+
if (onAction) {
5090
const subscription = form.watch((data) => {
5191
onAction({
5292
type: 'form_change',
@@ -56,7 +96,7 @@ ComponentRegistry.register('form',
5696
});
5797
return () => subscription.unsubscribe();
5898
}
59-
}, [form, onAction, onChangeProp]);
99+
}, [form, onAction]);
60100

61101
// Handle form submission
62102
const handleSubmit = form.handleSubmit(async (data) => {
@@ -85,9 +125,19 @@ ComponentRegistry.register('form',
85125
if (resetOnSubmit) {
86126
form.reset();
87127
}
88-
} catch (error: any) {
89-
setSubmitError(error?.message || 'An error occurred during submission');
90-
console.error('Form submission error:', error);
128+
} catch (error) {
129+
// Handle different error types safely
130+
const errorMessage = error instanceof Error
131+
? error.message
132+
: typeof error === 'string'
133+
? error
134+
: 'An error occurred during submission';
135+
setSubmitError(errorMessage);
136+
137+
// Only log errors in development
138+
if (process.env.NODE_ENV === 'development') {
139+
console.error('Form submission error:', error);
140+
}
91141
} finally {
92142
setIsSubmitting(false);
93143
}
@@ -104,9 +154,15 @@ ComponentRegistry.register('form',
104154
}
105155
};
106156

107-
// Determine grid classes based on columns
157+
// Determine grid classes based on columns (explicit classes for Tailwind JIT)
158+
const gridColsClass =
159+
columns === 1 ? '' :
160+
columns === 2 ? 'md:grid-cols-2' :
161+
columns === 3 ? 'md:grid-cols-3' :
162+
'md:grid-cols-4';
163+
108164
const gridClass = columns > 1
109-
? `grid gap-4 md:grid-cols-${Math.min(columns, 4)}`
165+
? cn('grid gap-4', gridColsClass)
110166
: 'space-y-4';
111167

112168
return (
@@ -129,7 +185,7 @@ ComponentRegistry.register('form',
129185
) : (
130186
// Otherwise render fields from schema
131187
<div className={schema.fieldContainerClass || gridClass}>
132-
{fields.map((field: any, index: number) => {
188+
{fields.map((field: FormFieldConfig, index: number) => {
133189
const {
134190
name,
135191
label,
@@ -142,18 +198,21 @@ ComponentRegistry.register('form',
142198
...fieldProps
143199
} = field;
144200

145-
// Handle conditional rendering
201+
// Handle conditional rendering with null/undefined safety
146202
if (condition) {
147203
const watchField = condition.field;
148204
const watchValue = form.watch(watchField);
149205

150-
if (condition.equals && watchValue !== condition.equals) {
206+
// Check for null/undefined before evaluating conditions
207+
const hasValue = watchValue !== undefined && watchValue !== null;
208+
209+
if (condition.equals !== undefined && watchValue !== condition.equals) {
151210
return null;
152211
}
153-
if (condition.notEquals && watchValue === condition.notEquals) {
212+
if (condition.notEquals !== undefined && watchValue === condition.notEquals) {
154213
return null;
155214
}
156-
if (condition.in && !condition.in.includes(watchValue)) {
215+
if (condition.in && (!hasValue || !condition.in.includes(watchValue))) {
157216
return null;
158217
}
159218
}
@@ -164,12 +223,17 @@ ComponentRegistry.register('form',
164223
};
165224

166225
if (required) {
167-
rules.required = validation.required || `${label || name} is required`;
226+
rules.required = typeof validation.required === 'string'
227+
? validation.required
228+
: `${label || name} is required`;
168229
}
169230

231+
// Use field.id or field.name for stable keys (never use index alone)
232+
const fieldKey = field.id ?? name;
233+
170234
return (
171235
<FormField
172-
key={field.id || name || index}
236+
key={fieldKey}
173237
control={form.control}
174238
name={name}
175239
rules={rules}
@@ -178,7 +242,11 @@ ComponentRegistry.register('form',
178242
{label && (
179243
<FormLabel>
180244
{label}
181-
{required && <span className="text-destructive ml-1">*</span>}
245+
{required && (
246+
<span className="text-destructive ml-1" aria-label="required">
247+
*
248+
</span>
249+
)}
182250
</FormLabel>
183251
)}
184252
<FormControl>
@@ -188,6 +256,7 @@ ComponentRegistry.register('form',
188256
...formField,
189257
inputType: fieldProps.inputType,
190258
options: fieldProps.options,
259+
placeholder: fieldProps.placeholder,
191260
disabled: disabled || fieldDisabled || isSubmitting,
192261
})}
193262
</FormControl>
@@ -303,16 +372,26 @@ ComponentRegistry.register('form',
303372
}
304373
);
305374

306-
// Helper function to render field components
307-
function renderFieldComponent(type: string, props: any) {
308-
const { schema, inputType, options, ...fieldProps } = props;
375+
// Helper function to render field components with proper typing
376+
interface RenderFieldProps {
377+
inputType?: string;
378+
options?: SelectOption[];
379+
placeholder?: string;
380+
value?: any;
381+
onChange?: (value: any) => void;
382+
disabled?: boolean;
383+
[key: string]: any;
384+
}
385+
386+
function renderFieldComponent(type: string, props: RenderFieldProps) {
387+
const { inputType, options = [], placeholder, ...fieldProps } = props;
309388

310389
switch (type) {
311390
case 'input':
312-
return <Input type={inputType || 'text'} {...fieldProps} />;
391+
return <Input type={inputType || 'text'} placeholder={placeholder} {...fieldProps} />;
313392

314393
case 'textarea':
315-
return <Textarea {...fieldProps} />;
394+
return <Textarea placeholder={placeholder} {...fieldProps} />;
316395

317396
case 'checkbox':
318397
// For checkbox, we need to handle the value differently
@@ -330,13 +409,19 @@ function renderFieldComponent(type: string, props: any) {
330409
case 'select':
331410
// For select with react-hook-form, we need to handle the onChange
332411
const { value: selectValue, onChange: selectOnChange, ...selectProps } = fieldProps;
412+
413+
// Safety check for options
414+
if (!options || options.length === 0) {
415+
return <div className="text-sm text-muted-foreground">No options available</div>;
416+
}
417+
333418
return (
334419
<Select value={selectValue} onValueChange={selectOnChange} {...selectProps}>
335420
<SelectTrigger>
336-
<SelectValue placeholder="Select an option" />
421+
<SelectValue placeholder={placeholder ?? 'Select an option'} />
337422
</SelectTrigger>
338423
<SelectContent>
339-
{options?.map((opt: any) => (
424+
{options.map((opt: SelectOption) => (
340425
<SelectItem key={opt.value} value={opt.value}>
341426
{opt.label}
342427
</SelectItem>
@@ -346,6 +431,6 @@ function renderFieldComponent(type: string, props: any) {
346431
);
347432

348433
default:
349-
return <Input type={inputType || 'text'} {...fieldProps} />;
434+
return <Input type={inputType || 'text'} placeholder={placeholder} {...fieldProps} />;
350435
}
351436
}

0 commit comments

Comments
 (0)