fix: improve product form validation and navigation guards

This commit is contained in:
Mahir-Mohamed
2026-08-11 18:19:41 +05:30
parent f012aa37e9
commit 7cd9ab5e0c
6 changed files with 250 additions and 46 deletions
+18 -13
View File
@@ -19,6 +19,7 @@ interface CategoryNode {
interface CategoryTreeSelectProps {
value?: string;
onChange: (categoryId: string) => void;
onBlur?: () => void;
placeholder?: string;
error?: string;
disabled?: boolean;
@@ -60,6 +61,7 @@ function buildCategoryTree(categories: any[]): CategoryNode[] {
export const CategoryTreeSelect: React.FC<CategoryTreeSelectProps> = ({
value,
onChange,
onBlur,
placeholder = 'Select Category...',
error,
disabled = false,
@@ -68,7 +70,7 @@ export const CategoryTreeSelect: React.FC<CategoryTreeSelectProps> = ({
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState('');
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
// Quick Create Drawer state
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [newCatName, setNewCatName] = useState('');
@@ -86,12 +88,17 @@ export const CategoryTreeSelect: React.FC<CategoryTreeSelectProps> = ({
useEffect(() => {
const handleOutsideClick = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
setIsOpen((prev) => {
if (prev) {
onBlur?.();
}
return false;
});
}
};
document.addEventListener('mousedown', handleOutsideClick);
return () => document.removeEventListener('mousedown', handleOutsideClick);
}, []);
}, [onBlur]);
const categoryTree = useMemo(() => buildCategoryTree(categories), [categories]);
@@ -138,7 +145,7 @@ export const CategoryTreeSelect: React.FC<CategoryTreeSelectProps> = ({
const createdId = created?.id || created?.data?.id;
notify.success(`Category "${newCatName}" created successfully!`);
await fetchCategories();
if (createdId) {
onChange(createdId);
@@ -178,11 +185,10 @@ export const CategoryTreeSelect: React.FC<CategoryTreeSelectProps> = ({
<div key={node.id} className="select-none">
<div
onClick={() => handleSelect(node.id)}
className={`flex items-center justify-between px-3 py-2 rounded-lg text-xs font-medium cursor-pointer transition-colors ${
isSelected
? 'bg-primary/10 text-primary font-bold'
: 'text-gray-700 hover:bg-gray-100'
}`}
className={`flex items-center justify-between px-3 py-2 rounded-lg text-xs font-medium cursor-pointer transition-colors ${isSelected
? 'bg-primary/10 text-primary font-bold'
: 'text-gray-700 hover:bg-gray-100'
}`}
style={{ paddingLeft: `${node.depth * 16 + 12}px` }}
>
<div className="flex items-center gap-2 min-w-0">
@@ -233,11 +239,10 @@ export const CategoryTreeSelect: React.FC<CategoryTreeSelectProps> = ({
{/* Trigger Button */}
<div
onClick={() => !disabled && setIsOpen(!isOpen)}
className={`w-full border rounded-lg px-3 py-2.5 text-sm flex items-center justify-between bg-white cursor-pointer transition-all ${
disabled ? 'bg-gray-50 opacity-60 cursor-not-allowed border-gray-200' :
className={`w-full border rounded-lg px-3 py-2.5 text-sm flex items-center justify-between bg-white cursor-pointer transition-all ${disabled ? 'bg-gray-50 opacity-60 cursor-not-allowed border-gray-200' :
isOpen ? 'border-primary ring-2 ring-primary/20' :
error ? 'border-red-300' : 'border-gray-200 hover:border-gray-300'
}`}
error ? 'border-red-300' : 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="flex items-center gap-2 truncate">
<Folder className={`w-4 h-4 ${selectedCategory ? 'text-primary' : 'text-gray-400'}`} />
+2 -1
View File
@@ -77,10 +77,11 @@ export function Select({
dropdownRef.current?.contains(e.target as Node)
) return;
setIsOpen(false);
onBlur?.({ target: { name } } as any);
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isOpen]);
}, [isOpen, name, onBlur]);
const handleToggle = () => {
if (disabled) return;
@@ -23,6 +23,7 @@ interface DynamicAttributeRendererProps {
attribute: Attribute;
value: any;
onChange: (value: any) => void;
onBlur?: () => void;
error?: string;
touched?: boolean;
readOnly?: boolean;
@@ -32,6 +33,7 @@ export const DynamicAttributeRenderer: React.FC<DynamicAttributeRendererProps> =
attribute,
value,
onChange,
onBlur,
error,
touched,
readOnly,
@@ -47,6 +49,7 @@ export const DynamicAttributeRenderer: React.FC<DynamicAttributeRendererProps> =
<textarea
value={value || ''}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
placeholder={`Enter ${attribute.name}`}
rows={3}
className={`${inputClass} resize-none`}
@@ -64,6 +67,7 @@ export const DynamicAttributeRenderer: React.FC<DynamicAttributeRendererProps> =
const val = e.target.value;
onChange(val === '' ? undefined : Number(val));
}}
onBlur={onBlur}
placeholder={`Enter ${attribute.name}`}
className={inputClass}
disabled={readOnly}
@@ -75,6 +79,7 @@ export const DynamicAttributeRenderer: React.FC<DynamicAttributeRendererProps> =
type="date"
value={value || ''}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
className={inputClass}
disabled={readOnly}
/>
@@ -87,6 +92,7 @@ export const DynamicAttributeRenderer: React.FC<DynamicAttributeRendererProps> =
const val = e.target.value;
onChange(val === 'true' ? true : val === 'false' ? false : undefined);
}}
onBlur={onBlur}
disabled={readOnly}
>
<option value="">Select option</option>
@@ -101,6 +107,7 @@ export const DynamicAttributeRenderer: React.FC<DynamicAttributeRendererProps> =
<Select
value={value || ''}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
disabled={readOnly}
>
<option value="">Select option</option>
@@ -119,9 +126,10 @@ export const DynamicAttributeRenderer: React.FC<DynamicAttributeRendererProps> =
updated = selectedValues.filter((v) => v !== optCode);
}
onChange(updated.join(','));
onBlur?.(); // trigger validation immediately
};
return (
<div className="space-y-2 border border-border rounded-lg p-3 bg-background/30">
<div className="space-y-2 border border-border rounded-lg p-3 bg-background/30" onBlur={onBlur}>
{attribute.optionsList?.map((opt) => {
const isChecked = selectedValues.includes(opt.code);
return (
@@ -149,6 +157,7 @@ export const DynamicAttributeRenderer: React.FC<DynamicAttributeRendererProps> =
type="text"
value={value || ''}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
placeholder={`Enter ${attribute.name}`}
className={inputClass}
disabled={readOnly}
@@ -32,6 +32,7 @@ interface DynamicAttributesSectionProps {
errors?: Record<string, any>;
touched?: Record<string, any>;
onAttributeChange: (code: string, value: any) => void;
onAttributeBlur?: (code: string) => void;
onAddAttributeClick?: (group: any) => void;
readOnly?: boolean;
}
@@ -43,6 +44,7 @@ export const DynamicAttributesSection: React.FC<DynamicAttributesSectionProps> =
errors = {},
touched = {},
onAttributeChange,
onAttributeBlur,
onAddAttributeClick,
readOnly,
}) => {
@@ -91,6 +93,7 @@ export const DynamicAttributesSection: React.FC<DynamicAttributesSectionProps> =
errors={errors}
touched={touched}
onAttributeChange={onAttributeChange}
onAttributeBlur={onAttributeBlur}
onAddAttributeClick={onAddAttributeClick}
readOnly={readOnly}
/>
@@ -33,6 +33,7 @@ interface ProductAttributeGroupProps {
errors?: Record<string, any>;
touched?: Record<string, any>;
onAttributeChange: (code: string, value: any) => void;
onAttributeBlur?: (code: string) => void;
onAddAttributeClick?: (group: AttributeGroup) => void;
readOnly?: boolean;
}
@@ -43,6 +44,7 @@ export const ProductAttributeGroup: React.FC<ProductAttributeGroupProps> = ({
errors = {},
touched = {},
onAttributeChange,
onAttributeBlur,
onAddAttributeClick,
readOnly,
}) => {
@@ -106,6 +108,7 @@ export const ProductAttributeGroup: React.FC<ProductAttributeGroupProps> = ({
attribute={attr}
value={values[attr.code]}
onChange={(val) => onAttributeChange(attr.code, val)}
onBlur={() => onAttributeBlur?.(attr.code)}
error={errors[attr.code]}
touched={touched[attr.code]}
readOnly={readOnly}
+214 -31
View File
@@ -28,6 +28,13 @@ import { Select } from '../../../components/customs/Select';
import { Loader } from '../../../components/customs/Loader';
import { CategoryTreeSelect } from '../../../components/customs/CategoryTreeSelect';
export function isEmptyAttributeValue(val: any): boolean {
if (val === undefined || val === null || val === '') return true;
if (typeof val === 'string' && val.trim() === '') return true;
if (Array.isArray(val) && val.length === 0) return true;
return false;
}
export default function NewProduct() {
const { id } = useParams<{ id?: string }>();
const navigate = useNavigate();
@@ -198,6 +205,7 @@ export default function NewProduct() {
setSelectedAttributeSetObj(null);
setCustomAddedAttributes([]);
formik.setFieldValue('attributes', {});
formik.setTouched({ ...formik.touched, attributes: {} });
return;
}
setLoadingProduct(true);
@@ -206,8 +214,10 @@ export default function NewProduct() {
setSelectedAttributeSetObj(setDetails);
setCustomAddedAttributes([]);
formik.setFieldValue('attributes', {});
} catch (err) {
notify.error('Failed to load attribute set details');
formik.setTouched({ ...formik.touched, attributes: {} });
} catch (err: any) {
console.error('Failed to load attribute set details:', err);
notify.error(`Failed to load attribute set details: ${err?.message || err}`);
} finally {
setLoadingProduct(false);
}
@@ -398,7 +408,7 @@ export default function NewProduct() {
// 3. Generate Validation Schema dynamically
const dynamicValidationSchema = useMemo(() => {
return Yup.object().shape({
const shape: any = {
name: Yup.string().required('Product name is required'),
category: Yup.string().required('Category is required'),
brand: Yup.string().required('Brand is required'),
@@ -406,8 +416,96 @@ export default function NewProduct() {
status: Yup.string().oneOf(['active', 'pending', 'draft', 'disabled']),
price: Yup.string(),
stock: Yup.number().integer().min(0, 'Stock cannot be negative'),
});
}, []);
};
const attributeShape: any = {};
if (Array.isArray(filteredAttributesList)) {
filteredAttributesList.forEach((attr: any) => {
if (!attr || !attr.code) return;
let validator: any = Yup.string();
const type = (attr.type || '').toLowerCase();
switch (type) {
case 'number':
case 'decimal':
validator = Yup.number().typeError(`${attr.name || attr.code} must be a number`);
// Numeric limits
const minVal = attr.min ?? attr.min_value ?? attr.minValue;
const maxVal = attr.max ?? attr.max_value ?? attr.maxValue;
if (minVal !== undefined && minVal !== null && minVal !== '') {
validator = validator.min(Number(minVal), `${attr.name || attr.code} cannot be less than ${minVal}`);
}
if (maxVal !== undefined && maxVal !== null && maxVal !== '') {
validator = validator.max(Number(maxVal), `${attr.name || attr.code} cannot be greater than ${maxVal}`);
}
break;
case 'date':
validator = Yup.date().typeError(`${attr.name || attr.code} must be a valid date`);
break;
case 'boolean':
validator = Yup.boolean().typeError(`${attr.name || attr.code} must be a boolean`);
break;
case 'multiselect':
validator = Yup.string();
break;
case 'text':
case 'textarea':
case 'select':
case 'enumeration':
case 'swatch':
default:
validator = Yup.string();
// String lengths
const minLen = attr.minLength ?? attr.min_length;
const maxLen = attr.maxLength ?? attr.max_length;
if (minLen !== undefined && minLen !== null && minLen !== '' && Number(minLen) > 0) {
validator = validator.min(Number(minLen), `${attr.name || attr.code} must be at least ${minLen} characters`);
}
if (maxLen !== undefined && maxLen !== null && maxLen !== '' && Number(maxLen) > 0) {
validator = validator.max(Number(maxLen), `${attr.name || attr.code} cannot exceed ${maxLen} characters`);
}
// Regex pattern
const pattern = attr.regexPattern ?? attr.regex_pattern ?? attr.pattern;
if (pattern) {
try {
const regex = new RegExp(pattern);
validator = validator.matches(regex, `${attr.name || attr.code} is invalid`);
} catch (e) {
// ignore
}
}
break;
}
// Required flag from dynamic metadata
if (attr.is_required || attr.isRequired) {
validator = validator.required(`${attr.name || attr.code} is required`)
.test('is-empty', `${attr.name || attr.code} is required`, (val: any) => {
if (val === undefined || val === null || val === '') return false;
if (typeof val === 'string' && val.trim() === '') return false;
if (Array.isArray(val) && val.length === 0) return false;
return true;
});
}
attributeShape[attr.code] = validator;
});
}
shape.attributes = Yup.object().shape(attributeShape);
return Yup.object().shape(shape);
}, [filteredAttributesList]);
const formik = useFormik({
initialValues: {
@@ -486,6 +584,18 @@ export default function NewProduct() {
}
});
const areRequiredAttributesComplete = useMemo(() => {
if (!Array.isArray(filteredAttributesList)) return true;
return !filteredAttributesList.some((attr: any) => {
const isReq = attr.is_required === true || attr.isRequired === true;
if (isReq) {
const val = formik.values.attributes?.[attr.code];
return isEmptyAttributeValue(val);
}
return false;
});
}, [filteredAttributesList, formik.values.attributes]);
const currentTabs = useMemo(() => {
const isVariant = formik.values.type === 'variant';
const tabsList = [
@@ -507,7 +617,30 @@ export default function NewProduct() {
}
}, [activeTab, currentTabs]);
const isNextDisabled = !formik.values.name || !formik.values.brand || !formik.values.unit || !formik.values.category || !formik.isValid;
const isNextDisabled = useMemo(() => {
// 1. Core General fields check (always required to proceed)
const isGeneralInvalid = !formik.values.name || !formik.values.brand || !formik.values.unit || !formik.values.category;
if (isGeneralInvalid) return true;
// 2. If on 'general' step, we only care about general fields
if (activeTab === 'general') {
const generalKeys = ['name', 'brand', 'unit', 'category', 'status', 'price', 'stock'];
return Object.keys(formik.errors).some(k => generalKeys.includes(k));
}
// 3. If on 'attributes' step, we must also block if any dynamic attributes are invalid or empty
if (activeTab === 'attributes') {
if (formik.errors.attributes && Object.keys(formik.errors.attributes).length > 0) {
return true;
}
if (!areRequiredAttributesComplete) {
return true;
}
}
// 4. Fallback: check global Formik validation status
return !formik.isValid;
}, [activeTab, formik.values, formik.errors, formik.isValid, filteredAttributesList, areRequiredAttributesComplete]);
const handleSubmitWithValidation = async (targetStatus?: string) => {
const statusToUse = targetStatus || formik.values.status || 'draft';
@@ -515,22 +648,7 @@ export default function NewProduct() {
const errors: Record<string, any> = await formik.validateForm();
if (statusToUse !== 'draft') {
if (!formik.values.category) {
errors.category = 'Category is required for review or publishing';
}
if (Array.isArray(filteredAttributesList) && filteredAttributesList.length > 0) {
filteredAttributesList.forEach((attr: any) => {
if (attr.is_required || attr.isRequired) {
const val = formik.values.attributes?.[attr.code];
if (val === undefined || val === null || val === '') {
if (!errors.attributes) errors.attributes = {};
(errors.attributes as Record<string, string>)[attr.code] = `${attr.name || attr.code} is required`;
}
}
});
}
}
// Handled by dynamic validation schema automatically
if (Object.keys(errors).length > 0) {
formik.setErrors(errors);
@@ -1440,7 +1558,10 @@ export default function NewProduct() {
<Select
name="brand"
value={formik.values.brand}
onChange={formik.handleChange}
onChange={(e) => {
formik.handleChange(e);
formik.setFieldTouched('brand', true, false);
}}
onBlur={formik.handleBlur}
disabled={isReadOnlyView}
>
@@ -1471,7 +1592,10 @@ export default function NewProduct() {
<Select
name="unit"
value={formik.values.unit}
onChange={formik.handleChange}
onChange={(e) => {
formik.handleChange(e);
formik.setFieldTouched('unit', true, false);
}}
onBlur={formik.handleBlur}
disabled={isReadOnlyView}
>
@@ -1507,6 +1631,7 @@ export default function NewProduct() {
formik.setFieldValue('category', catId, true);
formik.setFieldTouched('category', true, false);
}}
onBlur={() => formik.setFieldTouched('category', true, true)}
placeholder="Select Category..."
error={formik.touched.category && typeof formik.errors.category === 'string' ? formik.errors.category : undefined}
disabled={isReadOnlyView}
@@ -1767,7 +1892,11 @@ export default function NewProduct() {
values={formik.values.attributes || {}}
errors={formik.errors.attributes as any}
touched={formik.touched.attributes as any}
onAttributeChange={(code, val) => formik.setFieldValue(`attributes.${code}`, val)}
onAttributeChange={(code, val) => {
formik.setFieldValue(`attributes.${code}`, val, true);
formik.setFieldTouched(`attributes.${code}`, true, false);
}}
onAttributeBlur={(code) => formik.setFieldTouched(`attributes.${code}`, true)}
onAddAttributeClick={(group) => handleOpenCreateAttributeModal(group.id || group._id)}
readOnly={isReadOnlyView}
/>
@@ -1962,21 +2091,75 @@ export default function NewProduct() {
<button
type="button"
disabled={isNextDisabled}
onClick={() => {
formik.validateForm().then((errors) => {
onClick={async () => {
const errors: any = await formik.validateForm();
if (activeTab === 'general') {
const requiredFields = ['name', 'brand', 'unit', 'category'];
const hasRequiredErrors = Object.keys(errors).some(k => requiredFields.includes(k));
if (!hasRequiredErrors) {
setActiveTab(currentTabs[currentTabs.findIndex(t => t.id === activeTab) + 1].id);
} else {
formik.setTouched(
Object.keys(errors).reduce((acc: any, key: string) => {
const touchedObj: any = { ...formik.touched };
requiredFields.forEach(k => {
if (errors[k]) touchedObj[k] = true;
});
formik.setTouched(touchedObj);
}
} else if (activeTab === 'attributes') {
const requiredFields = ['name', 'brand', 'unit', 'category'];
const hasRequiredErrors = Object.keys(errors).some(k => requiredFields.includes(k));
const hasAttributeErrors = errors.attributes && Object.keys(errors.attributes).length > 0;
let isAttributesComplete = true;
if (Array.isArray(filteredAttributesList)) {
isAttributesComplete = !filteredAttributesList.some((attr: any) => {
const isReq = attr.is_required === true || attr.isRequired === true;
if (isReq) {
const val = formik.values.attributes?.[attr.code];
return isEmptyAttributeValue(val);
}
return false;
});
}
if (!hasRequiredErrors && !hasAttributeErrors && isAttributesComplete) {
setActiveTab(currentTabs[currentTabs.findIndex(t => t.id === activeTab) + 1].id);
} else {
const touchedObj: any = {
...formik.touched,
...Object.keys(errors).reduce((acc: any, key: string) => {
acc[key] = true;
return acc;
}, {})
);
};
if (errors.attributes) {
touchedObj.attributes = {
...formik.touched.attributes,
...Object.keys(errors.attributes).reduce((acc: any, key: string) => {
acc[key] = true;
return acc;
}, {})
};
}
if (Array.isArray(filteredAttributesList)) {
const attributesTouch = touchedObj.attributes || {};
filteredAttributesList.forEach((attr: any) => {
const isReq = attr.is_required === true || attr.isRequired === true;
if (isReq) {
const val = formik.values.attributes?.[attr.code];
if (isEmptyAttributeValue(val)) {
attributesTouch[attr.code] = true;
}
}
});
touchedObj.attributes = attributesTouch;
}
formik.setTouched(touchedObj);
}
});
} else {
setActiveTab(currentTabs[currentTabs.findIndex(t => t.id === activeTab) + 1].id);
}
}}
className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>