feat: introduce reusable CustomInput and CustomTextArea components with built-in validation support
This commit is contained in:
@@ -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)}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user