feat: add file uploader component and refactor form field validation

- Add FileUploader component import and integration in form-field
- Refactor validateField function to accept typed parameters (string | number | boolean)
- Move validateField definition before useEffect for better code organization
- Improve code formatting and consistency in select case block
- Add file upload type handling in renderInput switch statement
- Extend FormFieldType interface with comprehensive field properties
- Format template strings for better readability

This change enhances the form field component to support file uploads and improves type safety in validation logic. The refactoring also improves code maintainability by reorganizing function definitions and standardizing formatting.
This commit is contained in:
MehrdadAdabi 2025-11-27 20:03:05 +03:30
parent 518650ccd5
commit 7c8590c075
6 changed files with 381 additions and 268 deletions

View File

@ -9,6 +9,7 @@ import {
import { useEffect, useState, type ChangeEvent, type FC } from "react"; import { useEffect, useState, type ChangeEvent, type FC } from "react";
import { BaseDropdown } from "./base-drop-down"; import { BaseDropdown } from "./base-drop-down";
import { CustomCheckbox } from "./checkbox"; import { CustomCheckbox } from "./checkbox";
import { FileUploader } from "./file-uploader";
import { ImageUploader } from "./image-uploader"; import { ImageUploader } from "./image-uploader";
import { CustomInput } from "./input"; import { CustomInput } from "./input";
import { PersonSearchInput } from "./person-search-input"; // Import PersonSearchInput import { PersonSearchInput } from "./person-search-input"; // Import PersonSearchInput
@ -30,11 +31,9 @@ const FormField: FC<FormFieldProps> = ({
}) => { }) => {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => {
validateField(value);
}, [value]);
const validateField = (currentValue: any) => {
const validateField = (currentValue: string | number | boolean) => {
let errorMessage: string | null = null; let errorMessage: string | null = null;
// Required validation // Required validation
@ -127,6 +126,10 @@ const FormField: FC<FormFieldProps> = ({
setError(errorMessage); setError(errorMessage);
}; };
useEffect(() => {
validateField(value);
}, [value]);
const handleChange = ( const handleChange = (
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement> e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
) => { ) => {
@ -152,8 +155,7 @@ const FormField: FC<FormFieldProps> = ({
value: value || "", value: value || "",
onChange: handleChange, onChange: handleChange,
required: field.Required, required: field.Required,
className: `w-full p-2 border rounded-md text-right ${ className: `w-full p-2 border rounded-md text-right ${error ? "border-red-500" : "border-gray-300"
error ? "border-red-500" : "border-gray-300"
}`, }`,
dir: "rtl", dir: "rtl",
}; };
@ -251,6 +253,7 @@ const FormField: FC<FormFieldProps> = ({
/> />
); );
case "select": case "select":
{
const isDependent = field.Parent_FieldID !== undefined; const isDependent = field.Parent_FieldID !== undefined;
const parentFieldValue = const parentFieldValue =
isDependent && field.Parent_FieldID !== undefined isDependent && field.Parent_FieldID !== undefined
@ -281,13 +284,13 @@ const FormField: FC<FormFieldProps> = ({
undefined undefined
} }
onChange={(optionValue: string) => onChange(optionValue)} onChange={(optionValue: string) => onChange(optionValue)}
className={`w-full p-2 border rounded-md text-right ${ className={`w-full p-2 border rounded-md text-right ${error ? "border-red-500" : "border-gray-300"
error ? "border-red-500" : "border-gray-300"
}`} }`}
fetchOptions={fetchDependentOptions} fetchOptions={fetchDependentOptions}
disabled={isDependent && !parentFieldValue} // Disable if dependent and parent value is not set disabled={isDependent && !parentFieldValue} // Disable if dependent and parent value is not set
/> />
); );
}
case "radio": case "radio":
return ( return (
<div className="flex flex-col space-y-2" dir="rtl"> <div className="flex flex-col space-y-2" dir="rtl">
@ -325,19 +328,17 @@ const FormField: FC<FormFieldProps> = ({
<ImageUploader <ImageUploader
onImageChange={(file: File | null) => onChange(file)} onImageChange={(file: File | null) => onChange(file)}
previewImage={value} previewImage={value}
className={`w-full p-2 border rounded-md text-right ${ className={`w-full p-2 border rounded-md text-right ${error ? "border-red-500" : "border-gray-300"
error ? "border-red-500" : "border-gray-300"
}`} }`}
/> />
); );
case "file-upload": case "file-upload":
return ( return (
<CustomInput <FileUploader
{...commonProps} {...commonProps}
type="file"
onChange={(e: ChangeEvent<HTMLInputElement>) => onFileChange={(file) => onChange(file)}
onChange(e.target.files ? e.target.files[0] : null)
}
value={""} // File inputs are uncontrolled, value should be empty value={""} // File inputs are uncontrolled, value should be empty
/> />
); );
@ -356,15 +357,15 @@ const FormField: FC<FormFieldProps> = ({
{field.Description || field.Name} {field.Description || field.Name}
</p> </p>
); );
default: // default:
return ( // return (
<CustomInput // <CustomInput
{...commonProps} // {...commonProps}
type="text" // type="text"
maxLength={field.Length!} // maxLength={field.Length!}
placeholder={field.Name} // placeholder={field.Name}
/> // />
); // );
} }
}; };

View File

@ -4,7 +4,7 @@ import to from "await-to-js";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { API_ADDRESS } from "../service/api-address"; import { API_ADDRESS } from "../service/api-address";
export const getContactImageUrl = (stageID: Number): string => { export const getContactImageUrl = (stageID: number): string => {
const token = userInfoService.getToken(); const token = userInfoService.getToken();
if (!token) { if (!token) {
throw new Error("توکن یافت نشد"); throw new Error("توکن یافت نشد");

View File

@ -8,6 +8,7 @@ import React, {
import { CustomButton } from "@/core/components/base/button"; import { CustomButton } from "@/core/components/base/button";
import FormField from "@/core/components/base/form-field"; import FormField from "@/core/components/base/form-field";
import { uploadImage } from "@/core/utils";
import { import {
FieldTypes, FieldTypes,
FormService, FormService,
@ -16,20 +17,37 @@ import {
} from "@/core/utils/dynamic-field.utils"; } from "@/core/utils/dynamic-field.utils";
import type { DynamicFormProps } from "./step-form.type"; import type { DynamicFormProps } from "./step-form.type";
const DynamicForm: FC<DynamicFormProps> = ({ fields, processId, stepId }) => { const DynamicForm: FC<DynamicFormProps> = ({ fields, processId, stepId, workflowId, onChange }) => {
const [formValues, setFormValues] = useState<Record<string, any>>({}); const [formValues, setFormValues] = useState({});
const [_, setFormErrors] = useState<Record<string, string | null>>({}); const [_, setFormErrors] = useState<Record<string, string | null>>({});
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const evaluateShowCondition = useCallback(
(condition: string, currentFormValues: Record<string, any>): boolean => {
try {
const parts = condition.split("===").map((s) => s.trim());
if (parts.length === 2) {
const fieldName = parts[0];
const expectedValue = parts[1].replace(/^['"]|['"]$/g, ""); // Remove quotes
return currentFormValues[fieldName] == expectedValue;
}
} catch (e) {
console.error("Error evaluating ShowCondition:", e);
}
return true; // Default to visible if condition cannot be parsed
},
[]
);
useEffect(() => { useEffect(() => {
const initialValues: Record<string, any> = {}; const initialValues: Record<string, unknown> = {};
fields.forEach((field) => { fields.forEach((field) => {
if (field.DefaultValue !== undefined) { if (field.DefaultValue !== undefined) {
initialValues[field.Name] = field.DefaultValue; initialValues[field.NameOrID] = field.DefaultValue;
} else if (field.Type === FieldTypes.چندانتخابی) { } else if (field.Type === FieldTypes.چندانتخابی) {
initialValues[field.Name] = []; initialValues[field.NameOrID] = [];
} else { } else {
initialValues[field.Name] = ""; initialValues[field.NameOrID] = "";
} }
}); });
setFormValues(initialValues); setFormValues(initialValues);
@ -39,7 +57,7 @@ const DynamicForm: FC<DynamicFormProps> = ({ fields, processId, stepId }) => {
( (
field: FieldDefinition, field: FieldDefinition,
value: any, value: any,
allValues: Record<string, any> allValues: Record<string, unknown>
): string | null => { ): string | null => {
// Evaluate ShowCondition first. If not visible, no validation needed. // Evaluate ShowCondition first. If not visible, no validation needed.
if ( if (
@ -141,20 +159,20 @@ const DynamicForm: FC<DynamicFormProps> = ({ fields, processId, stepId }) => {
); );
const handleFieldChange = useCallback( const handleFieldChange = useCallback(
(fieldName: string, newValue: unknown) => { (nameOrID: string, newValue: unknown) => {
setFormValues((prevValues) => { setFormValues((prevValues) => {
const updatedValues = { const updatedValues = {
...prevValues, ...prevValues,
[fieldName]: newValue, [nameOrID]: newValue,
}; };
// Re-validate the field immediately // Re-validate the field immediately
const fieldDef = fields.find((f) => f.Name === fieldName); const fieldDef = fields.find((f) => f.NameOrID === nameOrID);
if (fieldDef) { if (fieldDef) {
const error = validateField(fieldDef, newValue, updatedValues); const error = validateField(fieldDef, newValue, updatedValues);
setFormErrors((prevErrors) => ({ setFormErrors((prevErrors) => ({
...prevErrors, ...prevErrors,
[fieldName]: error, [nameOrID]: error,
})); }));
} }
return updatedValues; return updatedValues;
@ -163,25 +181,7 @@ const DynamicForm: FC<DynamicFormProps> = ({ fields, processId, stepId }) => {
[fields, validateField] [fields, validateField]
); );
const evaluateShowCondition = useCallback(
(condition: string, currentFormValues: Record<string, any>): boolean => {
try {
// This is a simplified evaluation. In a real app, you'd use a more robust expression parser.
// Example: "FieldName === 'someValue'"
// For now, we'll just check for simple equality.
const parts = condition.split("===").map((s) => s.trim());
if (parts.length === 2) {
const fieldName = parts[0];
const expectedValue = parts[1].replace(/^['"]|['"]$/g, ""); // Remove quotes
return currentFormValues[fieldName] == expectedValue;
}
} catch (e) {
console.error("Error evaluating ShowCondition:", e);
}
return true; // Default to visible if condition cannot be parsed
},
[]
);
const visibleFields = useMemo(() => { const visibleFields = useMemo(() => {
return fields.filter((field) => { return fields.filter((field) => {
@ -200,9 +200,9 @@ const DynamicForm: FC<DynamicFormProps> = ({ fields, processId, stepId }) => {
let hasErrors = false; let hasErrors = false;
for (const field of visibleFields) { for (const field of visibleFields) {
const value = formValues[field.Name]; const value = formValues[field.NameOrID];
const error = validateField(field, value, formValues); // Pass formValues for conditional validation const error = validateField(field, value, formValues); // Pass formValues for conditional validation
newErrors[field.Name] = error; newErrors[field.NameOrID] = error;
if (error) { if (error) {
hasErrors = true; hasErrors = true;
} }
@ -219,7 +219,29 @@ const DynamicForm: FC<DynamicFormProps> = ({ fields, processId, stepId }) => {
console.error("Form submission failed:", err); console.error("Form submission failed:", err);
alert("Form submission failed."); alert("Form submission failed.");
} }
const updatedValues: any = { ...formValues };
for (const [key, value] of Object.entries(formValues)) {
if (value instanceof File && value !== null) {
const fileID = await uploadImage({
file: value,
name: "",
});
updatedValues[key] = fileID.data.data;
} }
}
const filterData = {
...updatedValues,
run_process: workflowId,
};
if (workflowId)
onChange(filterData)
}
setIsSubmitting(false); setIsSubmitting(false);
}; };
@ -248,9 +270,11 @@ const DynamicForm: FC<DynamicFormProps> = ({ fields, processId, stepId }) => {
</label> </label>
<FormField <FormField
field={field} field={field}
value={formValues[field.Name]} value={formValues[field.NameOrID]}
onChange={(newValue: any) => onChange={(newValue: any) => {
handleFieldChange(field.Name, newValue) handleFieldChange(field.NameOrID, newValue)
}
} }
allFormValues={formValues} // Pass all form values for dependent fields allFormValues={formValues} // Pass all form values for dependent fields
/> />

View File

@ -6,36 +6,42 @@ import { FieldTypes } from "@core/utils/dynamic-field.utils";
import { import {
fetchFieldIndex, fetchFieldIndex,
fetchFielSecondeIndex, fetchFielSecondeIndex,
saveFormService,
} from "@modules/dashboard/service/dynamic-form.service"; } from "@modules/dashboard/service/dynamic-form.service";
import { useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { toast } from "react-toastify";
import { getCampaignStepsService } from "../../service/campaigns.service"; import { getCampaignStepsService } from "../../service/campaigns.service";
import type { CampaignProcess, StepItems } from "../steps/step.type";
import DynamicForm from "./dynamic-form"; import DynamicForm from "./dynamic-form";
import type { CampaignProcess, FilterData, GroupedCampaign } from "./step-form.type";
const StepFormPage: FC = () => { const StepFormPage: FC = () => {
const [fields, setFields] = useState<LocalFieldDefinition[]>([]); const [fields, setFields] = useState<LocalFieldDefinition[]>([]);
const [params] = useSearchParams(); const [params] = useSearchParams();
const stageID = params.get("stageID"); const stageID = params.get("stageID");
const processID = params.get("processID"); const processID = params.get("processID");
const { data, isLoading, error } = useQuery<WorkflowResponse>({ const { data, isLoading, error } = useQuery<WorkflowResponse>({
queryKey: ["dynamic-field", stageID ?? processID], queryKey: ["dynamic-field", stageID, processID],
queryFn: () => { queryFn: () => {
if (stageID) return fetchFielSecondeIndex(stageID) ; if (stageID) return fetchFielSecondeIndex(stageID);
return fetchFieldIndex(processID!); return fetchFieldIndex(processID!);
}, },
}); });
const { data:steps } = useQuery({ const { data: steps } = useQuery({
queryKey: ["dynamic-step"], queryKey: ["dynamic-step"],
queryFn: () => getCampaignStepsService(), queryFn: () => getCampaignStepsService(),
}); });
useEffect(() => { const processedFields = useMemo(() => {
if (data && data.Fields) { if (!data || !data.Fields) {
const processedFields: LocalFieldDefinition[] = data.Fields.map((el) => { return [];
}
return data.Fields.map((el) => {
const field: any = { const field: any = {
ID: el.ID, ID: el.ID,
Name: el.Name, Name: el.Name,
@ -131,91 +137,131 @@ const StepFormPage: FC = () => {
} }
return field; return field;
}); });
setFields(processedFields);
}
}, [data]); }, [data]);
useEffect(() => {
setFields(processedFields);
}, [processedFields]);
const currentStep = useMemo(() => { const currentStep = useMemo(() => {
if (!Array.isArray(steps) || steps.length === 0) return []; if (!Array.isArray(steps) || steps.length === 0) return [];
const row = steps[0]; const row = steps[0];
if (!row || Object.keys(row).length === 0) return []; if (!row || Object.keys(row).length === 0) return [];
const processes = Object.keys(row)
.filter(
(key) =>
key.startsWith("process") &&
!key.endsWith("_id") &&
key.includes("_")
)
.map((key) => {
const index = key.match(/process(\d+)_/)?.[1];
if (!index) return null;
return { // 1) جمع‌آوری فرآیندها
processId: row[`process${index}`], const temp: CampaignProcess[] = [];
category: row[`process${index}_category`],
score: row[`process${index}_score`],
stageId: row[`process${index}_stage_id`],
status: row[`process${index}_status`],
};
})
.filter(Boolean);
// Group + Dedupe Object.keys(row).forEach(key => {
const grouped = Object.values( const match = key.match(/^process(\d+)_category$/);
processes.reduce( if (match) {
( const i = match[1];
acc: Record< temp.push({
string, processId: String(row[`process${i}`]),
{ category: String(row[`process${i}_category`]),
category: string; score: String(row[`process${i}_score`]),
processes: CampaignProcess[]; stageId: String(row[`process${i}_stage_id`]),
stageID: number; status: String(row[`process${i}_status`]),
processId: number; title: String(row[`process${i}_title`]),
selected: false,
groupIndex: 0, // placeholder
});
} }
>, });
item
) => { // 2) گروه‌بندی + حذف تکراری
if (!item || !item.category) return acc; const grouped: GroupedCampaign[] = Object.values(
temp.reduce((acc: Record<string, GroupedCampaign>, item) => {
if (!item?.category) return acc;
if (!acc[item.category]) { if (!acc[item.category]) {
acc[item.category] = { acc[item.category] = {
category: item.category, category: item.category,
processes: [], processes: [],
stageID: Number(item.stageId), groupIndex: 0,
processId: Number(item.processId),
}; };
} }
// حذف تکراری‌ها بر اساس processId
const exists = acc[item.category].processes.some( const exists = acc[item.category].processes.some(
(p) => p.processId === item.processId p => p.processId === item.processId
); );
if (!exists) { if (!exists) {
acc[item.category].processes.push({ acc[item.category].processes.push(item);
processId: String(item.processId),
category: String(item.category),
score: String(item.score),
stageId: String(item.stageId),
status: String(item.status),
});
} }
return acc; return acc;
}, }, {})
{}
)
); );
let datas:unknown // 3) ست کردن groupIndex روی گروه‌ها و process های داخلش
if(stageID) datas = grouped.find(el=> el.stageID === Number(stageID)) grouped.forEach((group, index) => {
else datas = grouped.find(el=> el.processId === Number(processID)) group.groupIndex = index;
return datas?.processes as Array<StepItems>; group.processes.forEach(proc => {
proc.groupIndex = index;
});
});
// 4) فیلتر مناسب بر اساس Stage یا Process
let filtered = grouped;
if (stageID) {
filtered = grouped.filter(g => g.processes.some(p => p.stageId === String(stageID)));
}
if (processID) {
filtered = grouped.filter(g => g.processes.some(p => p.processId === String(processID)));
}
// 5) حالا سلکت داینامیک فقط روی گروه‌های فیلترشده اجرا میشه
filtered.forEach(group => {
const arr = group.processes;
for (let i = 0; i < arr.length; i++) {
if (arr[i].status === "انجام نشده") {
arr[i].selected = true;
break;
}
}
});
return filtered.flatMap(g => g.processes);
}, [steps, stageID, processID]); }, [steps, stageID, processID]);
const saveForm = useMutation({
mutationFn: saveFormService,
onSuccess: (data) => {
if (data.resultType !== 0) {
toast.error(data.message || "خطایی رخ داد");
return;
}
toast.success("ثبت نام با موفقیت انجام شد");
},
onError: (error: any) => {
console.error("Registration error:", error);
toast.error(
"خطا در ثبت نام: " + (error?.message || "لطفاً دوباره تلاش کنید")
);
},
});
const saveFormHandler = (data: FilterData) => {
if (currentStep) {
const changeData = {
[`process${currentStep[0]?.groupIndex + 1}`]: data
}
saveForm.mutate(changeData)
}
}
if (isLoading) { if (isLoading) {
return ( return (
<div className="container mx-auto p-4 text-center font-vazir"> <div className="container mx-auto p-4 text-center font-vazir">
@ -238,17 +284,18 @@ const StepFormPage: FC = () => {
فرم پویا فرم پویا
</h1> </h1>
<div className="flex flex-row justify-center gap-10"> <div className="flex flex-row justify-center gap-10">
{currentStep.map((el, idx) => { {currentStep?.map((el, idx) => {
return ( return (
<div key={idx} className="flex flex-col justify-center mb-4 text-right"> <div key={idx} className="flex flex-col justify-center mb-4 text-right">
<div className="w-7 h-7 bg-red-950 rounded-full block m-auto"></div> <div className={`w-7 h-7 bg-red-950 rounded-full block m-auto ${el.selected ? 'bg-green-700!' : ''}`}></div>
<div className="font-semibold text-sm mb-2">{el.category}</div> <div className="font-semibold text-sm mb-2">{el.title}</div>
</div> </div>
); );
})} })}
</div> </div>
<DynamicForm fields={fields} processId={1} stepId={1} /> <DynamicForm fields={fields} processId={1} stepId={1} workflowId={steps[0]?.WorkflowID} onChange={saveFormHandler} />
</div> </div>
); );
}; };

View File

@ -4,4 +4,29 @@ export interface DynamicFormProps {
fields: FieldDefinition[]; fields: FieldDefinition[];
processId: number; processId: number;
stepId: number; stepId: number;
workflowId: number
onChange: (items: any) => void
} }
export interface FilterData {
[key: string]: any;
run_process: number;
}
export type CampaignProcess = {
processId: string;
category: string;
score: string;
stageId: string;
status: string;
title: string;
selected: boolean;
groupIndex: number;
};
export type GroupedCampaign = {
category: string;
processes: CampaignProcess[];
groupIndex: number;
};

View File

@ -3,6 +3,7 @@ import api from "@/core/service/axios";
import type { WorkflowResponse } from "@/core/types/global.type"; import type { WorkflowResponse } from "@/core/types/global.type";
import to from "await-to-js"; import to from "await-to-js";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import type { FilterData } from "../pages/step-form/step-form.type";
export const fetchFieldIndex = async ( export const fetchFieldIndex = async (
id: string id: string
@ -23,7 +24,7 @@ export const fetchFieldIndex = async (
export const fetchFielSecondeIndex = async ( export const fetchFielSecondeIndex = async (
id: string id: string
): Promise<WorkflowResponse> => { ): Promise<WorkflowResponse> => {
const [err, res] = await to(api.post(API_ADDRESS.index, id)); const [err, res] = await to(api.post(API_ADDRESS.index2, id));
if (err) { if (err) {
throw err; throw err;
} }
@ -35,3 +36,18 @@ export const fetchFielSecondeIndex = async (
const data = JSON.parse(res.data.data); const data = JSON.parse(res.data.data);
return data; return data;
}; };
export const saveFormService = async (items: FilterData) => {
const [err, res] = await to(api.post(API_ADDRESS.save, items));
if (err) {
throw err;
}
if (res.data.resultType !== 0) {
toast.error(res.data.message || "خطا در دریافت فیلدها");
throw new Error("خطا در دریافت فیلدها");
}
const data = JSON.parse(res.data.data);
return data;
}