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

View File

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

View File

@ -8,6 +8,7 @@ import React, {
import { CustomButton } from "@/core/components/base/button";
import FormField from "@/core/components/base/form-field";
import { uploadImage } from "@/core/utils";
import {
FieldTypes,
FormService,
@ -16,20 +17,37 @@ import {
} from "@/core/utils/dynamic-field.utils";
import type { DynamicFormProps } from "./step-form.type";
const DynamicForm: FC<DynamicFormProps> = ({ fields, processId, stepId }) => {
const [formValues, setFormValues] = useState<Record<string, any>>({});
const DynamicForm: FC<DynamicFormProps> = ({ fields, processId, stepId, workflowId, onChange }) => {
const [formValues, setFormValues] = useState({});
const [_, setFormErrors] = useState<Record<string, string | null>>({});
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(() => {
const initialValues: Record<string, any> = {};
const initialValues: Record<string, unknown> = {};
fields.forEach((field) => {
if (field.DefaultValue !== undefined) {
initialValues[field.Name] = field.DefaultValue;
initialValues[field.NameOrID] = field.DefaultValue;
} else if (field.Type === FieldTypes.چندانتخابی) {
initialValues[field.Name] = [];
initialValues[field.NameOrID] = [];
} else {
initialValues[field.Name] = "";
initialValues[field.NameOrID] = "";
}
});
setFormValues(initialValues);
@ -39,7 +57,7 @@ const DynamicForm: FC<DynamicFormProps> = ({ fields, processId, stepId }) => {
(
field: FieldDefinition,
value: any,
allValues: Record<string, any>
allValues: Record<string, unknown>
): string | null => {
// Evaluate ShowCondition first. If not visible, no validation needed.
if (
@ -141,20 +159,20 @@ const DynamicForm: FC<DynamicFormProps> = ({ fields, processId, stepId }) => {
);
const handleFieldChange = useCallback(
(fieldName: string, newValue: unknown) => {
(nameOrID: string, newValue: unknown) => {
setFormValues((prevValues) => {
const updatedValues = {
...prevValues,
[fieldName]: newValue,
[nameOrID]: newValue,
};
// Re-validate the field immediately
const fieldDef = fields.find((f) => f.Name === fieldName);
const fieldDef = fields.find((f) => f.NameOrID === nameOrID);
if (fieldDef) {
const error = validateField(fieldDef, newValue, updatedValues);
setFormErrors((prevErrors) => ({
...prevErrors,
[fieldName]: error,
[nameOrID]: error,
}));
}
return updatedValues;
@ -163,25 +181,7 @@ const DynamicForm: FC<DynamicFormProps> = ({ fields, processId, stepId }) => {
[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(() => {
return fields.filter((field) => {
@ -200,9 +200,9 @@ const DynamicForm: FC<DynamicFormProps> = ({ fields, processId, stepId }) => {
let hasErrors = false;
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
newErrors[field.Name] = error;
newErrors[field.NameOrID] = error;
if (error) {
hasErrors = true;
}
@ -219,7 +219,29 @@ const DynamicForm: FC<DynamicFormProps> = ({ fields, processId, stepId }) => {
console.error("Form submission failed:", err);
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);
};
@ -248,9 +270,11 @@ const DynamicForm: FC<DynamicFormProps> = ({ fields, processId, stepId }) => {
</label>
<FormField
field={field}
value={formValues[field.Name]}
onChange={(newValue: any) =>
handleFieldChange(field.Name, newValue)
value={formValues[field.NameOrID]}
onChange={(newValue: any) => {
handleFieldChange(field.NameOrID, newValue)
}
}
allFormValues={formValues} // Pass all form values for dependent fields
/>

View File

@ -6,36 +6,42 @@ import { FieldTypes } from "@core/utils/dynamic-field.utils";
import {
fetchFieldIndex,
fetchFielSecondeIndex,
saveFormService,
} 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 { toast } from "react-toastify";
import { getCampaignStepsService } from "../../service/campaigns.service";
import type { CampaignProcess, StepItems } from "../steps/step.type";
import DynamicForm from "./dynamic-form";
import type { CampaignProcess, FilterData, GroupedCampaign } from "./step-form.type";
const StepFormPage: FC = () => {
const [fields, setFields] = useState<LocalFieldDefinition[]>([]);
const [params] = useSearchParams();
const stageID = params.get("stageID");
const processID = params.get("processID");
const { data, isLoading, error } = useQuery<WorkflowResponse>({
queryKey: ["dynamic-field", stageID ?? processID],
queryKey: ["dynamic-field", stageID, processID],
queryFn: () => {
if (stageID) return fetchFielSecondeIndex(stageID) ;
if (stageID) return fetchFielSecondeIndex(stageID);
return fetchFieldIndex(processID!);
},
});
const { data:steps } = useQuery({
const { data: steps } = useQuery({
queryKey: ["dynamic-step"],
queryFn: () => getCampaignStepsService(),
});
useEffect(() => {
if (data && data.Fields) {
const processedFields: LocalFieldDefinition[] = data.Fields.map((el) => {
const processedFields = useMemo(() => {
if (!data || !data.Fields) {
return [];
}
return data.Fields.map((el) => {
const field: any = {
ID: el.ID,
Name: el.Name,
@ -131,91 +137,131 @@ const StepFormPage: FC = () => {
}
return field;
});
setFields(processedFields);
}
}, [data]);
useEffect(() => {
setFields(processedFields);
}, [processedFields]);
const currentStep = useMemo(() => {
if (!Array.isArray(steps) || steps.length === 0) return [];
const row = steps[0];
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 {
processId: row[`process${index}`],
category: row[`process${index}_category`],
score: row[`process${index}_score`],
stageId: row[`process${index}_stage_id`],
status: row[`process${index}_status`],
};
})
.filter(Boolean);
// 1) جمع‌آوری فرآیندها
const temp: CampaignProcess[] = [];
// Group + Dedupe
const grouped = Object.values(
processes.reduce(
(
acc: Record<
string,
{
category: string;
processes: CampaignProcess[];
stageID: number;
processId: number;
Object.keys(row).forEach(key => {
const match = key.match(/^process(\d+)_category$/);
if (match) {
const i = match[1];
temp.push({
processId: String(row[`process${i}`]),
category: String(row[`process${i}_category`]),
score: String(row[`process${i}_score`]),
stageId: String(row[`process${i}_stage_id`]),
status: String(row[`process${i}_status`]),
title: String(row[`process${i}_title`]),
selected: false,
groupIndex: 0, // placeholder
});
}
>,
item
) => {
if (!item || !item.category) return acc;
});
// 2) گروه‌بندی + حذف تکراری
const grouped: GroupedCampaign[] = Object.values(
temp.reduce((acc: Record<string, GroupedCampaign>, item) => {
if (!item?.category) return acc;
if (!acc[item.category]) {
acc[item.category] = {
category: item.category,
processes: [],
stageID: Number(item.stageId),
processId: Number(item.processId),
groupIndex: 0,
};
}
// حذف تکراری‌ها بر اساس processId
const exists = acc[item.category].processes.some(
(p) => p.processId === item.processId
p => p.processId === item.processId
);
if (!exists) {
acc[item.category].processes.push({
processId: String(item.processId),
category: String(item.category),
score: String(item.score),
stageId: String(item.stageId),
status: String(item.status),
});
acc[item.category].processes.push(item);
}
return acc;
},
{}
)
}, {})
);
let datas:unknown
if(stageID) datas = grouped.find(el=> el.stageID === Number(stageID))
else datas = grouped.find(el=> el.processId === Number(processID))
return datas?.processes as Array<StepItems>;
// 3) ست کردن groupIndex روی گروه‌ها و process های داخلش
grouped.forEach((group, index) => {
group.groupIndex = index;
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]);
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) {
return (
<div className="container mx-auto p-4 text-center font-vazir">
@ -238,17 +284,18 @@ const StepFormPage: FC = () => {
فرم پویا
</h1>
<div className="flex flex-row justify-center gap-10">
{currentStep.map((el, idx) => {
{currentStep?.map((el, idx) => {
return (
<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="font-semibold text-sm mb-2">{el.category}</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.title}</div>
</div>
);
})}
</div>
<DynamicForm fields={fields} processId={1} stepId={1} />
<DynamicForm fields={fields} processId={1} stepId={1} workflowId={steps[0]?.WorkflowID} onChange={saveFormHandler} />
</div>
);
};

View File

@ -4,4 +4,29 @@ export interface DynamicFormProps {
fields: FieldDefinition[];
processId: 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 to from "await-to-js";
import { toast } from "react-toastify";
import type { FilterData } from "../pages/step-form/step-form.type";
export const fetchFieldIndex = async (
id: string
@ -23,7 +24,7 @@ export const fetchFieldIndex = async (
export const fetchFielSecondeIndex = async (
id: string
): 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) {
throw err;
}
@ -35,3 +36,18 @@ export const fetchFielSecondeIndex = async (
const data = JSON.parse(res.data.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;
}