feat: introduce reusable CustomInput and CustomTextArea components with built-in validation support

This commit is contained in:
Syed Waseem
2026-08-25 12:50:47 +05:30
parent 1053285ae7
commit f1294724b7
5 changed files with 303 additions and 102 deletions
@@ -157,6 +157,7 @@ export function FieldDefinitionFormModal({
...prev, ...prev,
fieldType: newType, fieldType: newType,
lookupSource: isLookupType ? prev.lookupSource : undefined, lookupSource: isLookupType ? prev.lookupSource : undefined,
validationJson: isLookupType ? undefined : prev.validationJson,
})); }));
if (setError && !isLookupType) { if (setError && !isLookupType) {
setError(null); setError(null);
@@ -235,57 +236,59 @@ export function FieldDefinitionFormModal({
/> />
{/* Validation JSON Rules */} {/* Validation JSON Rules */}
<div className="p-4 bg-slate-50 border border-slate-200 rounded-[14px] space-y-3"> {formData.fieldType !== 'dropdown' && formData.fieldType !== 'multi_select' && (
<h5 className="text-[13px] font-bold text-slate-800">Validation Rules (validation_json)</h5> <div className="p-4 bg-slate-50 border border-slate-200 rounded-[14px] space-y-3">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <h5 className="text-[13px] font-bold text-slate-800">Validation Rules (validation_json)</h5>
<CustomInput <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
label="Min Value / Length" <CustomInput
type="number" label="Min Value / Length"
value={formData.validationJson?.min !== undefined ? String(formData.validationJson.min) : ''} type="number"
onChange={(e) => value={formData.validationJson?.min !== undefined ? String(formData.validationJson.min) : ''}
setFormData((prev) => ({ onChange={(e) =>
...prev, setFormData((prev) => ({
validationJson: { ...prev,
...prev.validationJson, validationJson: {
min: e.target.value !== '' ? Number(e.target.value) : undefined, ...prev.validationJson,
}, min: e.target.value !== '' ? Number(e.target.value) : undefined,
})) },
} }))
placeholder="e.g. 312" }
/> placeholder="e.g. 312"
/>
<CustomInput <CustomInput
label="Max Value / Length" label="Max Value / Length"
type="number" type="number"
value={formData.validationJson?.max !== undefined ? String(formData.validationJson.max) : ''} value={formData.validationJson?.max !== undefined ? String(formData.validationJson.max) : ''}
onChange={(e) => onChange={(e) =>
setFormData((prev) => ({ setFormData((prev) => ({
...prev, ...prev,
validationJson: { validationJson: {
...prev.validationJson, ...prev.validationJson,
max: e.target.value !== '' ? Number(e.target.value) : undefined, max: e.target.value !== '' ? Number(e.target.value) : undefined,
}, },
})) }))
} }
placeholder="e.g. 245" placeholder="e.g. 245"
/> />
<CustomInput <CustomInput
label="Regex Pattern" label="Regex Pattern"
value={formData.validationJson?.regex || ''} value={formData.validationJson?.regex || ''}
onChange={(e) => onChange={(e) =>
setFormData((prev) => ({ setFormData((prev) => ({
...prev, ...prev,
validationJson: { validationJson: {
...prev.validationJson, ...prev.validationJson,
regex: e.target.value, regex: e.target.value,
}, },
})) }))
} }
placeholder="e.g. ^[A-Z0-9]+$" placeholder="e.g. ^[A-Z0-9]+$"
/> />
</div>
</div> </div>
</div> )}
{/* Conditional Visibility */} {/* Conditional Visibility */}
<div className="p-4 bg-amber-50/60 border border-amber-200 rounded-[14px] space-y-3"> <div className="p-4 bg-amber-50/60 border border-amber-200 rounded-[14px] space-y-3">
@@ -488,7 +488,7 @@ export default function AddPolicyEngine() {
} }
setStatus( setStatus(
data.status?.toLowerCase() === 'active' ? 'Active' : data.status?.toLowerCase() === 'active' ? 'Active' :
data.status?.toLowerCase() === 'draft' ? 'Draft' : 'Inactive' data.status?.toLowerCase() === 'draft' ? 'Draft' : 'Inactive'
); );
setDescription(data.description || ''); setDescription(data.description || '');
@@ -641,23 +641,23 @@ export default function AddPolicyEngine() {
const isFormValid = (() => { const isFormValid = (() => {
if (!policyName.trim()) return false; if (!policyName.trim()) return false;
if (audienceType === 'Selected Cohorts' && (!selectedCohorts || selectedCohorts.length === 0)) return false; if (audienceType === 'Selected Cohorts' && (!selectedCohorts || selectedCohorts.length === 0)) return false;
if (!rules || rules.length === 0) return false; if (!rules || rules.length === 0) return false;
for (const rule of rules) { for (const rule of rules) {
if (!rule.category) return false; if (!rule.category) return false;
if (!rule.conditions || rule.conditions.length === 0) return false; if (!rule.conditions || rule.conditions.length === 0) return false;
for (const cond of rule.conditions) { for (const cond of rule.conditions) {
if (!cond.condition || !cond.operator || cond.value === '' || cond.value === undefined || cond.value === null) return false; if (!cond.condition || !cond.operator || cond.value === '' || cond.value === undefined || cond.value === null) return false;
} }
if (!rule.actions || rule.actions.length === 0) return false; if (!rule.actions || rule.actions.length === 0) return false;
for (const act of rule.actions) { for (const act of rule.actions) {
if (!act.actionCategoryId || !act.actionTypeId) return false; if (!act.actionCategoryId || !act.actionTypeId) return false;
const allFields = actionTypeFieldsMap[act.actionTypeId] || []; const allFields = actionTypeFieldsMap[act.actionTypeId] || [];
const visibleFields = allFields.filter((f) => const visibleFields = allFields.filter((f) =>
isActionFieldVisible(f, act.fieldValues, allFields, fieldLookupOptionsMap) isActionFieldVisible(f, act.fieldValues, allFields, fieldLookupOptionsMap)
@@ -674,7 +674,7 @@ export default function AddPolicyEngine() {
} }
} }
} }
return true; return true;
})(); })();
@@ -784,8 +784,8 @@ export default function AddPolicyEngine() {
if (isEditMode) { if (isEditMode) {
} }
setSuccessTitle(isDeploy setSuccessTitle(isDeploy
? (isEditMode ? "Policy Updated Successfully." : "Policy Created Successfully.") ? (isEditMode ? "Policy Updated Successfully." : "Policy Created Successfully.")
: "Policy Saved as Draft."); : "Policy Saved as Draft.");
setShowSuccessModal(true); setShowSuccessModal(true);
@@ -1394,48 +1394,56 @@ export default function AddPolicyEngine() {
const fieldVal = action.fieldValues?.[field.fieldCode]; const fieldVal = action.fieldValues?.[field.fieldCode];
const lookupOpts = fieldLookupOptionsMap[field.lookupSource || ''] || []; const lookupOpts = fieldLookupOptionsMap[field.lookupSource || ''] || [];
const helpText = field.helpText || (field as any).help_text; const helpText = field.helpText || (field as any).help_text;
const valRules = field.validationJson || (field as any).validation_json;
return ( return (
<div key={field.id} className={widthClass}> <div key={field.id} className={widthClass}>
<label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
{field.fieldName}
{field.isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
{field.fieldType === 'textarea' ? ( {field.fieldType === 'textarea' ? (
<CustomTextArea <CustomTextArea
label={field.fieldName}
required={field.isRequired}
validationJson={valRules}
value={fieldVal || ''} value={fieldVal || ''}
onChange={(e) => handleFieldValueChange(rule.id, action.id, field.fieldCode, e.target.value)} onChange={(e) => handleFieldValueChange(rule.id, action.id, field.fieldCode, e.target.value)}
placeholder={field.placeholder || 'Enter details...'} placeholder={field.placeholder || 'Enter details...'}
/> />
) : field.fieldType === 'currency' ? ( ) : field.fieldType === 'currency' ? (
<div className="grid grid-cols-3 gap-2"> <div>
<div className="col-span-2"> <label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
<CustomInput {field.fieldName}
type="number" {field.isRequired && <span className="text-red-500 ml-1">*</span>}
value={fieldVal?.amount || ''} </label>
onChange={(e) => <div className="grid grid-cols-3 gap-2">
<div className="col-span-2">
<CustomInput
validationJson={valRules}
type="number"
value={fieldVal?.amount || ''}
onChange={(e) =>
handleFieldValueChange(rule.id, action.id, field.fieldCode, {
...(fieldVal || {}),
amount: e.target.value,
})
}
placeholder={field.placeholder || 'Amount'}
/>
</div>
<CustomDropdown
options={lookupOpts}
value={fieldVal?.currency}
onChange={(val) =>
handleFieldValueChange(rule.id, action.id, field.fieldCode, { handleFieldValueChange(rule.id, action.id, field.fieldCode, {
...(fieldVal || {}), ...(fieldVal || {}),
amount: e.target.value, currency: val,
}) })
} }
placeholder={field.placeholder || 'Amount'}
/> />
</div> </div>
<CustomDropdown
options={lookupOpts}
value={fieldVal?.currency}
onChange={(val) =>
handleFieldValueChange(rule.id, action.id, field.fieldCode, {
...(fieldVal || {}),
currency: val,
})
}
/>
</div> </div>
) : field.fieldType === 'dropdown' ? ( ) : field.fieldType === 'dropdown' ? (
<CustomDropdown <CustomDropdown
label={field.fieldName}
required={field.isRequired}
options={lookupOpts} options={lookupOpts}
value={fieldVal || ''} value={fieldVal || ''}
onChange={(val) => handleFieldValueChange(rule.id, action.id, field.fieldCode, val)} onChange={(val) => handleFieldValueChange(rule.id, action.id, field.fieldCode, val)}
@@ -1443,6 +1451,8 @@ export default function AddPolicyEngine() {
/> />
) : field.fieldType === 'multi_select' ? ( ) : field.fieldType === 'multi_select' ? (
<CustomMultiSelect <CustomMultiSelect
label={field.fieldName}
required={field.isRequired}
options={lookupOpts} options={lookupOpts}
value={Array.isArray(fieldVal) ? fieldVal : []} value={Array.isArray(fieldVal) ? fieldVal : []}
onChange={(vals) => handleFieldValueChange(rule.id, action.id, field.fieldCode, vals)} onChange={(vals) => handleFieldValueChange(rule.id, action.id, field.fieldCode, vals)}
@@ -1455,17 +1465,26 @@ export default function AddPolicyEngine() {
label={field.fieldName} label={field.fieldName}
/> />
) : field.fieldType === 'switch' ? ( ) : field.fieldType === 'switch' ? (
<div className="flex items-center gap-3 pt-1"> <div>
<CustomSwitch <label className="block text-[13px] font-semibold text-slate-700 mb-1.5">
checked={!!fieldVal} {field.fieldName}
onChange={(e) => handleFieldValueChange(rule.id, action.id, field.fieldCode, e.target.checked)} {field.isRequired && <span className="text-red-500 ml-1">*</span>}
/> </label>
<span className="text-[13px] font-medium text-slate-700"> <div className="flex items-center gap-3 pt-1">
{fieldVal ? 'Enabled' : 'Disabled'} <CustomSwitch
</span> checked={!!fieldVal}
onChange={(e) => handleFieldValueChange(rule.id, action.id, field.fieldCode, e.target.checked)}
/>
<span className="text-[13px] font-medium text-slate-700">
{fieldVal ? 'Enabled' : 'Disabled'}
</span>
</div>
</div> </div>
) : ( ) : (
<CustomInput <CustomInput
label={field.fieldName}
required={field.isRequired}
validationJson={valRules}
type={ type={
field.fieldType === 'number' || field.fieldType === 'decimal' || field.fieldType === 'percentage' field.fieldType === 'number' || field.fieldType === 'decimal' || field.fieldType === 'percentage'
? 'number' ? 'number'
@@ -200,7 +200,7 @@ export default function PolicyEngineList() {
className: "text-right", className: "text-right",
accessor: (row) => ( accessor: (row) => (
<CustomActionMenu> <CustomActionMenu>
{row.status !== "Active" && ( {row.status?.toLowerCase() === "inactive" && (
<CustomActionItem <CustomActionItem
icon={<ChecksIcon size={15} />} icon={<ChecksIcon size={15} />}
variant="success" variant="success"
@@ -209,7 +209,7 @@ export default function PolicyEngineList() {
Activate Activate
</CustomActionItem> </CustomActionItem>
)} )}
{row.status === "Active" && ( {row.status?.toLowerCase() === "active" && (
<CustomActionItem <CustomActionItem
icon={<XIcon size={15} />} icon={<XIcon size={15} />}
onClick={() => setDeactivateTarget(row)} onClick={() => setDeactivateTarget(row)}
+105 -9
View File
@@ -1,8 +1,14 @@
import React, { useState, forwardRef } from "react"; import React, { useState, forwardRef, useEffect } from "react";
import { PhoneIcon, EyeIcon, EyeSlashIcon } from "@phosphor-icons/react"; import { PhoneIcon, EyeIcon, EyeSlashIcon } from "@phosphor-icons/react";
type InputType = "text" | "password" | "number" | "email" | "tel" | "date" | "month" | "datetime-local" | "time"; type InputType = "text" | "password" | "number" | "email" | "tel" | "date" | "month" | "datetime-local" | "time";
export interface ValidationJsonRule {
min?: number;
max?: number;
regex?: string;
}
interface CustomInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size"> { interface CustomInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size"> {
label?: string; label?: string;
type?: InputType; type?: InputType;
@@ -13,6 +19,7 @@ interface CustomInputProps extends Omit<React.InputHTMLAttributes<HTMLInputEleme
error?: string; error?: string;
containerClassName?: string; containerClassName?: string;
size?: "sm" | "md" | "lg"; size?: "sm" | "md" | "lg";
validationJson?: ValidationJsonRule | string;
} }
const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>( const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
@@ -29,7 +36,9 @@ const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
containerClassName = "", containerClassName = "",
error, error,
onInput, onInput,
onChange,
size = "md", size = "md",
validationJson,
...props ...props
}, },
ref ref
@@ -37,6 +46,7 @@ const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
const isPassword = type === "password"; const isPassword = type === "password";
const isPhone = phonePrefix !== undefined; const isPhone = phonePrefix !== undefined;
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [validationError, setValidationError] = useState<string>("");
const inputType = isPassword && showPassword ? "text" : type; const inputType = isPassword && showPassword ? "text" : type;
@@ -46,16 +56,101 @@ const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
lg: "py-3 text-base h-[48px]", lg: "py-3 text-base h-[48px]",
}; };
const handleInput = (e: React.FormEvent<HTMLInputElement>) => { const parsedValJson: ValidationJsonRule | null = (() => {
if (maxLength && (type === "number" || type === "tel")) { if (!validationJson) return null;
const target = e.target as HTMLInputElement; if (typeof validationJson === "string") {
if (target.value.length > maxLength) { try {
target.value = target.value.slice(0, maxLength); return JSON.parse(validationJson);
} catch {
return null;
} }
} }
return validationJson;
})();
const effectiveMax = parsedValJson?.max !== undefined ? Number(parsedValJson.max) : maxLength;
const effectiveMin = parsedValJson?.min !== undefined ? Number(parsedValJson.min) : undefined;
const effectiveRegex = parsedValJson?.regex || undefined;
const validateInput = (val: string) => {
const fieldLabel = label ? `"${label}"` : "This field";
// 1. Max length / value check
if (effectiveMax !== undefined && effectiveMax > 0) {
if (type === "number") {
if (val !== "" && Number(val) > effectiveMax) {
setValidationError(`${fieldLabel} cannot be greater than ${effectiveMax}`);
return;
}
} else {
if (val.length >= effectiveMax) {
setValidationError(`${fieldLabel} cannot exceed ${effectiveMax} characters`);
return;
}
}
}
// 2. Min length / value check
if (effectiveMin !== undefined && effectiveMin > 0) {
if (type === "number") {
if (val !== "" && Number(val) < effectiveMin) {
setValidationError(`${fieldLabel} must be at least ${effectiveMin}`);
return;
}
} else {
if (val.length > 0 && val.length < effectiveMin) {
setValidationError(`${fieldLabel} must be at least ${effectiveMin} characters`);
return;
}
}
}
// 3. Regex check
if (effectiveRegex && val.length > 0) {
try {
const reg = new RegExp(effectiveRegex, "u");
if (!reg.test(val)) {
setValidationError(`Invalid format for ${fieldLabel}`);
return;
}
} catch {
// ignore invalid regex string syntax
}
}
setValidationError("");
};
useEffect(() => {
if (props.value !== undefined && props.value !== null) {
validateInput(String(props.value));
}
}, [props.value, effectiveMax, effectiveMin, effectiveRegex, label]);
const handleInput = (e: React.FormEvent<HTMLInputElement>) => {
const target = e.target as HTMLInputElement;
if (effectiveMax && (type === "number" || type === "tel")) {
if (target.value.length > effectiveMax) {
target.value = target.value.slice(0, effectiveMax);
}
}
validateInput(target.value);
onInput?.(e as any); onInput?.(e as any);
}; };
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const target = e.target as HTMLInputElement;
if (effectiveMax && (type === "number" || type === "tel")) {
if (target.value.length > effectiveMax) {
target.value = target.value.slice(0, effectiveMax);
}
}
validateInput(target.value);
onChange?.(e);
};
const displayError = error || validationError;
return ( return (
<div className={`w-full flex flex-col gap-1.5 ${containerClassName}`}> <div className={`w-full flex flex-col gap-1.5 ${containerClassName}`}>
{label && ( {label && (
@@ -88,9 +183,10 @@ const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
ref={ref} ref={ref}
type={inputType} type={inputType}
maxLength={ maxLength={
type === "number" || type === "tel" ? undefined : maxLength type === "number" || type === "tel" ? undefined : effectiveMax
} }
onInput={handleInput} onInput={handleInput}
onChange={handleChange}
disabled={disabled} disabled={disabled}
className={` className={`
${isPhone ${isPhone
@@ -98,7 +194,7 @@ const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
: ` : `
w-full rounded-lg w-full rounded-lg
bg-white text-gray-900 bg-white text-gray-900
border ${error ? "border-red-500" : "border-gray-300"} border ${displayError ? "border-red-500" : "border-gray-300"}
px-3 ${sizeClasses[size]} px-3 ${sizeClasses[size]}
outline-none outline-none
transition-all duration-200 transition-all duration-200
@@ -131,7 +227,7 @@ const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
) )
)} )}
</div> </div>
{error && <p className="text-xs text-red-500 mt-1">{error}</p>} {displayError && <p className="text-xs text-red-500 mt-1">{displayError}</p>}
</div> </div>
); );
} }
+87 -4
View File
@@ -1,4 +1,10 @@
import React, { forwardRef } from "react"; import React, { useState, forwardRef, useEffect } from "react";
export interface ValidationJsonRule {
min?: number;
max?: number;
regex?: string;
}
interface CustomTextAreaProps interface CustomTextAreaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> { extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
@@ -8,6 +14,7 @@ interface CustomTextAreaProps
maxLength?: number; maxLength?: number;
error?: string; error?: string;
size?: "sm" | "md" | "lg"; size?: "sm" | "md" | "lg";
validationJson?: ValidationJsonRule | string;
} }
const CustomTextArea = forwardRef<HTMLTextAreaElement, CustomTextAreaProps>( const CustomTextArea = forwardRef<HTMLTextAreaElement, CustomTextAreaProps>(
@@ -21,16 +28,90 @@ const CustomTextArea = forwardRef<HTMLTextAreaElement, CustomTextAreaProps>(
className = "", className = "",
rows = 4, rows = 4,
size = "md", size = "md",
error,
onInput,
onChange,
validationJson,
...props ...props
}, },
ref ref
) => { ) => {
const [validationError, setValidationError] = useState<string>("");
const sizeClasses = { const sizeClasses = {
sm: "py-2 text-sm", sm: "py-2 text-sm",
md: "py-2.5 text-sm", md: "py-2.5 text-sm",
lg: "py-3 text-base", lg: "py-3 text-base",
}; };
const parsedValJson: ValidationJsonRule | null = (() => {
if (!validationJson) return null;
if (typeof validationJson === "string") {
try {
return JSON.parse(validationJson);
} catch {
return null;
}
}
return validationJson;
})();
const effectiveMax = parsedValJson?.max !== undefined ? Number(parsedValJson.max) : maxLength;
const effectiveMin = parsedValJson?.min !== undefined ? Number(parsedValJson.min) : undefined;
const effectiveRegex = parsedValJson?.regex || undefined;
const validateInput = (val: string) => {
const fieldLabel = label ? `"${label}"` : "This field";
if (effectiveMax !== undefined && effectiveMax > 0) {
if (val.length >= effectiveMax) {
setValidationError(`${fieldLabel} cannot exceed ${effectiveMax} characters`);
return;
}
}
if (effectiveMin !== undefined && effectiveMin > 0) {
if (val.length > 0 && val.length < effectiveMin) {
setValidationError(`${fieldLabel} must be at least ${effectiveMin} characters`);
return;
}
}
if (effectiveRegex && val.length > 0) {
try {
const reg = new RegExp(effectiveRegex, "u");
if (!reg.test(val)) {
setValidationError(`Invalid format for ${fieldLabel}`);
return;
}
} catch {
// ignore invalid regex string syntax
}
}
setValidationError("");
};
useEffect(() => {
if (props.value !== undefined && props.value !== null) {
validateInput(String(props.value));
}
}, [props.value, effectiveMax, effectiveMin, effectiveRegex, label]);
const handleInput = (e: React.FormEvent<HTMLTextAreaElement>) => {
const target = e.target as HTMLTextAreaElement;
validateInput(target.value);
onInput?.(e as any);
};
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const target = e.target as HTMLTextAreaElement;
validateInput(target.value);
onChange?.(e);
};
const displayError = error || validationError;
return ( return (
<div className="w-full flex flex-col gap-1.5"> <div className="w-full flex flex-col gap-1.5">
{label && ( {label && (
@@ -50,12 +131,14 @@ const CustomTextArea = forwardRef<HTMLTextAreaElement, CustomTextAreaProps>(
<textarea <textarea
ref={ref} ref={ref}
rows={rows} rows={rows}
maxLength={maxLength} maxLength={effectiveMax}
onInput={handleInput}
onChange={handleChange}
disabled={disabled} disabled={disabled}
className={` className={`
w-full rounded-lg w-full rounded-lg
bg-white text-gray-900 bg-white text-gray-900
border ${props.error ? 'border-red-500' : 'border-gray-300'} border ${displayError ? 'border-red-500' : 'border-gray-300'}
px-3 ${sizeClasses[size]} px-3 ${sizeClasses[size]}
outline-none outline-none
transition-all duration-200 transition-all duration-200
@@ -77,7 +160,7 @@ const CustomTextArea = forwardRef<HTMLTextAreaElement, CustomTextAreaProps>(
</span> </span>
)} )}
</div> </div>
{props.error && <p className="text-xs text-red-500 mt-1">{props.error}</p>} {displayError && <p className="text-xs text-red-500 mt-1">{displayError}</p>}
</div> </div>
); );
} }