feat(dropdown): Add async option fetching and improve search

This commit introduces the ability to fetch dropdown options asynchronously, enhancing the component's flexibility for large datasets or dynamic content.

Key changes include:
- **`fetchOptions` prop:** A new prop `fetchOptions` is added to allow external functions to provide options based on the current search query.
- **Internal state for options:** `internalOptions` state is introduced to manage options, which can be populated either from the initial `options` prop or by `fetchOptions`.
- **Loading state:** `isLoading` state is added to indicate when options are being fetched.
- **Improved search handling:** The `handleSearchInputChange` function now triggers `fetchOptions` when available, allowing real-time filtering from an external source.
- **Option type update:** The `Option` type now uses `name` instead of `label` for consistency.
- **Selected option display:** The displayed selected option now uses `value` instead of `label` for consistency.

These changes make the `BaseDropdown` component more robust and adaptable to various data sources, especially for scenarios requiring dynamic or remote option loading.
This commit is contained in:
MehrdadAdabi 2025-11-25 16:59:12 +03:30
parent d725c1b7d7
commit 6634ecfda7
14 changed files with 1401 additions and 39 deletions

View File

@ -2,13 +2,14 @@ import { cn } from "@/core/lib/utils";
import { ChevronDown, Search } from "lucide-react";
import {
forwardRef,
type InputHTMLAttributes,
useEffect,
useRef,
useState,
type ChangeEvent,
type InputHTMLAttributes,
} from "react";
type Option = { value: string; label: string };
type Option = { value: string; name: string };
type BaseDropdownProps = Omit<
InputHTMLAttributes<HTMLInputElement>,
@ -22,6 +23,7 @@ type BaseDropdownProps = Omit<
value?: string;
onChange?: (value: string) => void;
onInputChange?: (inputValue: string) => void;
fetchOptions?: (inputValue: string) => Promise<Option[]>; // New prop for fetching options
};
export const BaseDropdown = forwardRef<HTMLDivElement, BaseDropdownProps>(
@ -37,15 +39,20 @@ export const BaseDropdown = forwardRef<HTMLDivElement, BaseDropdownProps>(
onChange,
disabled,
onInputChange,
fetchOptions, // Destructure new prop
},
ref
) => {
const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [internalOptions, setInternalOptions] = useState<Option[]>(options); // State for options, can be updated by fetchOptions
const [isLoading, setIsLoading] = useState(false); // Loading state for async options
const dropdownRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const selectedOption = options.find((option) => option.value === value);
const selectedOption = internalOptions.find(
(option) => option.value === value
);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@ -66,8 +73,30 @@ export const BaseDropdown = forwardRef<HTMLDivElement, BaseDropdownProps>(
useEffect(() => {
if (isOpen) {
setTimeout(() => inputRef.current?.focus(), 0);
if (fetchOptions) {
setIsLoading(true);
fetchOptions(searchQuery)
.then((fetchedOpts) => {
setInternalOptions(fetchedOpts);
})
.finally(() => {
setIsLoading(false);
});
}
} else {
// When closing, reset internal options to initial if not fetching
if (!fetchOptions) {
setInternalOptions(options);
}
}
}, [isOpen]);
}, [isOpen, fetchOptions]);
// Update internal options if the options prop changes and no fetchOptions is provided
useEffect(() => {
if (!fetchOptions) {
setInternalOptions(options);
}
}, [options, fetchOptions]);
const handleSelect = (option: Option) => {
if (onChange) {
@ -77,8 +106,26 @@ export const BaseDropdown = forwardRef<HTMLDivElement, BaseDropdownProps>(
setSearchQuery("");
};
const filteredOptions = options.filter((option) =>
option.label.toLowerCase().includes(searchQuery.toLowerCase())
const handleSearchInputChange = (e: ChangeEvent<HTMLInputElement>) => {
const newSearchQuery = e.target.value;
setSearchQuery(newSearchQuery);
if (onInputChange) {
onInputChange(newSearchQuery);
}
if (fetchOptions) {
setIsLoading(true);
fetchOptions(newSearchQuery)
.then((fetchedOpts) => {
setInternalOptions(fetchedOpts);
})
.finally(() => {
setIsLoading(false);
});
}
};
const filteredOptions = internalOptions.filter((option) =>
option.value.toLowerCase().includes(searchQuery.toLowerCase())
);
const hasError = !!error;
@ -107,7 +154,7 @@ export const BaseDropdown = forwardRef<HTMLDivElement, BaseDropdownProps>(
)}
>
<span className={selectedOption ? "text-black" : "text-gray-400"}>
{selectedOption ? selectedOption.label : placeholder}
{selectedOption ? selectedOption.value : placeholder}
</span>
<ChevronDown
className={cn(
@ -128,27 +175,34 @@ export const BaseDropdown = forwardRef<HTMLDivElement, BaseDropdownProps>(
placeholder="جستجو..."
className="w-full rounded-md border border-gray-300 px-3 py-2 pl-8 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
if (onInputChange) {
onInputChange(e.target.value);
}
}}
onChange={handleSearchInputChange}
/>
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
</div>
</div>
<ul className="max-h-48 overflow-auto">
{filteredOptions.map((option) => (
<li
key={option.value}
onClick={() => handleSelect(option)}
className="cursor-pointer px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
{option.label}
</li>
))}
</ul>
{isLoading ? (
<div className="p-4 text-center text-sm text-gray-500">
در حال بارگذاری...
</div>
) : (
<ul className="max-h-48 overflow-auto">
{filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<li
key={option.value}
onClick={() => handleSelect(option)}
className="cursor-pointer px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
{option.value}
</li>
))
) : (
<li className="px-4 py-2 text-sm text-gray-500">
موردی یافت نشد.
</li>
)}
</ul>
)}
</div>
)}
</div>

View File

@ -0,0 +1,379 @@
import type { FieldDefinition } from "@/core/utils/dynamic-field.utils";
import {
fieldTypeMap,
FieldTypes,
FormService,
normalizeInput,
regexValidators,
} from "@/core/utils/dynamic-field.utils";
import { useEffect, useState, type ChangeEvent, type FC } from "react";
import { BaseDropdown } from "./base-drop-down";
import { CustomCheckbox } from "./checkbox";
import { ImageUploader } from "./image-uploader";
import { CustomInput } from "./input";
import { PersonSearchInput } from "./person-search-input"; // Import PersonSearchInput
import { CustomRadio } from "./radio";
import TextAreaField from "./text-area";
interface FormFieldProps {
field: FieldDefinition;
value: any;
onChange: (value: any) => void;
allFormValues: Record<string, any>; // New prop to access all form values for dependent fields
}
const FormField: FC<FormFieldProps> = ({
field,
value,
onChange,
allFormValues,
}) => {
const [error, setError] = useState<string | null>(null);
useEffect(() => {
validateField(value);
}, [value]);
const validateField = (currentValue: any) => {
let errorMessage: string | null = null;
// Required validation
if (
!field.AllowNull &&
(currentValue === null ||
currentValue === "" ||
(Array.isArray(currentValue) && currentValue.length === 0))
) {
errorMessage = "این فیلد الزامی است.";
}
// Length validation for text fields
if (
field.Length &&
typeof currentValue === "string" &&
currentValue.length > field.Length
) {
errorMessage = `حداکثر طول مجاز ${field.Length} کاراکتر است.`;
}
// Min/Max validation for numeric fields
if (
(field.Type === FieldTypes.عدد_صحیح ||
field.Type === FieldTypes.عدد_اعشاری ||
field.Type === FieldTypes.پول) &&
typeof currentValue === "number"
) {
if (field.MinValue !== undefined && currentValue < field?.MinValue) {
errorMessage = `مقدار باید بزرگتر یا مساوی ${field.MinValue} باشد.`;
}
if (field.MaxValue !== undefined && currentValue > field?.MaxValue) {
errorMessage = `مقدار باید کوچکتر یا مساوی ${field.MaxValue} باشد.`;
}
}
// Regex validation
switch (field.Type) {
case FieldTypes.رشتهارسی:
if (currentValue && !regexValidators.persian.test(currentValue)) {
errorMessage = "فقط حروف فارسی مجاز است.";
}
break;
case FieldTypes.رشتهاتین:
if (currentValue && !regexValidators.latin.test(currentValue)) {
errorMessage = "فقط حروف انگلیسی و اعداد مجاز است.";
}
break;
case FieldTypes.تلفن_همراه:
if (currentValue && !regexValidators.mobile.test(currentValue)) {
errorMessage = "شماره تلفن همراه معتبر نیست.";
}
break;
case FieldTypes.کدملی:
if (currentValue && !regexValidators.nationalId.test(currentValue)) {
errorMessage = "کدملی معتبر نیست.";
}
break;
case FieldTypes.رایانامه:
if (currentValue && !regexValidators.email.test(currentValue)) {
errorMessage = "آدرس رایانامه معتبر نیست.";
}
break;
case FieldTypes.آدرسایگاه_اینترنتی:
if (currentValue && !regexValidators.url.test(currentValue)) {
errorMessage = "آدرس پایگاه اینترنتی معتبر نیست.";
}
break;
case FieldTypes.پلاکاشین:
case FieldTypes.پلاک_خودرو:
if (currentValue && !regexValidators.carPlate.test(currentValue)) {
errorMessage = "فرمت پلاک خودرو معتبر نیست.";
}
break;
default:
break;
}
// Custom ParsedValidation
if (field.parsedValidationRules && currentValue) {
for (const validationRule of field.parsedValidationRules) {
const regex = new RegExp(validationRule.regex);
if (!regex.test(currentValue)) {
errorMessage = validationRule.message;
break;
}
}
}
setError(errorMessage);
};
const handleChange = (
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
) => {
const newValue = normalizeInput(field.Type, e.target.value);
onChange(newValue);
};
const handleMultiSelectChange = (option: string, isChecked: boolean) => {
let newValues = Array.isArray(value) ? [...value] : [];
if (isChecked) {
newValues.push(option);
} else {
newValues = newValues.filter((item) => item !== option);
}
onChange(newValues);
};
const renderField = () => {
const inputType = fieldTypeMap(field.Type);
const commonProps = {
id: field.ID.toString(),
name: field.Name,
value: value || "",
onChange: handleChange,
required: field.Required,
className: `w-full p-2 border rounded-md text-right ${
error ? "border-red-500" : "border-gray-300"
}`,
dir: "rtl",
};
switch (inputType) {
case "text":
return (
<CustomInput
{...commonProps}
type="text"
maxLength={field.Length}
placeholder={field.Name}
/>
);
case "person-search": // New case for person search
return (
<PersonSearchInput
value={value}
onChange={onChange}
placeholder={field.Name}
className={commonProps.className}
dir="rtl"
/>
);
case "textarea":
return (
<TextAreaField
{...commonProps}
maxLength={field.Length}
placeholder={field.Name}
/>
);
case "number":
return (
<CustomInput
{...commonProps}
type="number"
min={field.MinValue ?? undefined}
max={field.MaxValue ?? undefined}
placeholder={field.Name}
/>
);
case "decimal":
return (
<CustomInput
{...commonProps}
type="text" // Use text for custom decimal handling
inputMode="decimal"
min={field.MinValue ?? undefined}
max={field.MaxValue ?? undefined}
placeholder={field.Name}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
if (!isNaN(Number(val)) || val === "") {
onChange(val);
}
}}
/>
);
case "currency":
return (
<CustomInput
{...commonProps}
type="text"
inputMode="numeric"
placeholder={field.Name}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const val = e.target.value.replace(/[^0-9.]/g, ""); // Remove non-numeric except dot
if (!isNaN(Number(val)) || val === "") {
onChange(val);
}
}}
value={value ? new Intl.NumberFormat("fa-IR").format(value) : ""}
/>
);
case "date":
return (
<CustomInput
{...commonProps}
type="date"
value={value ? new Date(value).toISOString().split("T")[0] : ""}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
onChange(e.target.value)
}
/>
);
case "time":
return (
<CustomInput
{...commonProps}
type="time"
value={value || ""}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
onChange(e.target.value)
}
/>
);
case "select":
const isDependent = field.Parent_FieldID !== undefined;
const parentFieldValue =
isDependent && field.Parent_FieldID !== undefined
? allFormValues[field.Parent_FieldID.toString()]
: undefined;
const fetchDependentOptions = isDependent
? async (inputValue: string) => {
if (!parentFieldValue || field.Parent_FieldID === undefined)
return []; // Don't fetch if parent value is not set or Parent_FieldID is undefined
const fetched = await FormService.fetchDependentOptions(
field.Parent_FieldID!, // Non-null assertion
parentFieldValue
);
return fetched.filter((option) =>
option.label.toLowerCase().includes(inputValue.toLowerCase())
);
}
: undefined;
return (
<BaseDropdown
label={field.Name}
error={error || undefined}
options={field.parsedOptions || []}
value={
field.parsedOptions?.find((opt) => opt.value === value)?.value ||
undefined
}
onChange={(optionValue: string) => onChange(optionValue)}
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">
{field.parsedOptions?.map((option) => (
<CustomRadio
key={`${field.ID}-${option.value}`}
id={`${field.ID}-${option.value}`}
name={field.Name}
label={option.label}
value={option.value}
checked={value === option.value}
onChange={() => onChange(option.value)}
/>
))}
</div>
);
case "multi-select":
return (
<div className="flex flex-wrap gap-2" dir="rtl">
{field.parsedOptions?.map((option) => (
<CustomCheckbox
key={`${field.ID}-${option.value}`}
id={`${field.ID}-${option.value}`}
label={option.label}
checked={Array.isArray(value) && value.includes(option.value)}
onChange={(e: boolean) =>
handleMultiSelectChange(option.value, e)
}
/>
))}
</div>
);
case "image-upload":
return (
<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"
}`}
/>
);
case "file-upload":
return (
<CustomInput
{...commonProps}
type="file"
onChange={(e: ChangeEvent<HTMLInputElement>) =>
onChange(e.target.files ? e.target.files[0] : null)
}
value={""} // File inputs are uncontrolled, value should be empty
/>
);
case "html-editor":
// Placeholder for a more complex HTML editor component
return (
<TextAreaField
{...commonProps}
placeholder={field.Name}
className={`${commonProps.className} h-32`}
/>
);
case "description":
return (
<p className="text-sm text-gray-700 text-right" dir="rtl">
{field.Description || field.Name}
</p>
);
default:
return (
<CustomInput
{...commonProps}
type="text"
maxLength={field.Length}
placeholder={field.Name}
/>
);
}
};
return (
<div className="mb-4 font-vazir" dir="rtl">
{renderField()}
{error && <p className="text-xs text-red-500 mt-1 text-right">{error}</p>}
</div>
);
};
export default FormField;

View File

@ -0,0 +1,50 @@
import { type ChangeEvent, type FC, useState } from "react";
import { CustomInput } from "./input";
interface PersonSearchInputProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
dir?: "rtl" | "ltr";
}
export const PersonSearchInput: FC<PersonSearchInputProps> = ({
value,
onChange,
placeholder = "جستجوی شخص...",
className,
dir = "rtl",
}) => {
const [searchTerm, setSearchTerm] = useState(value);
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setSearchTerm(newValue);
onChange(newValue); // Propagate change immediately
// In a real scenario, you would trigger a search API call here
// and manage search results, perhaps in a dropdown below the input.
};
return (
<div className="relative w-full" dir={dir}>
<CustomInput
type="text"
value={searchTerm}
onChange={handleInputChange}
placeholder={placeholder}
className={className}
dir={dir}
/>
{/* Placeholder for search results dropdown */}
{/* {searchTerm && (
<div className="absolute z-10 w-full bg-white border border-gray-300 rounded-md shadow-lg mt-1">
<ul className="py-1">
<li className="px-3 py-2 text-sm text-gray-700">نتیجه ۱</li>
<li className="px-3 py-2 text-sm text-gray-700">نتیجه ۲</li>
</ul>
</div>
)} */}
</div>
);
};

View File

@ -8,6 +8,7 @@ export const API_ADDRESS = {
save: "/api/save",
delete: "/api/delete",
uploadImage: "/workflow/uploadImage",
index: "workflow/index",
// LOGIN: "/auth/login",
// VERIFY_OTP: "/auth/verify-otp",
// REGISTER: "/auth/register",

View File

@ -4,3 +4,154 @@ export interface TokenInterface {
RefreshToken: string;
ExpRefreshToken: string;
}
// ---------------------------
// Root Interface
// ---------------------------
export interface WorkflowResponse {
ChildFieldNameOrID: string | null;
Fields: FieldDefinition[];
ParentStageID: number | null;
Process: ProcessInfo | null;
Stage: StageInfo | null;
StageHistory: StageHistoryInfo | null;
Step: StepInfo | null;
ValueID: number;
Values: Record<string, any> | null;
Workflow: WorkflowInfo | null;
}
// ---------------------------
// Field Definition Interface
// ---------------------------
export interface FieldDefinition {
AllowNull: boolean;
CalculationField_Async: boolean;
CalculationField_IsFunctionOutput: boolean;
CalculationField_ReCalculateAfterSave: boolean;
Calculation_Formula: string;
Calculation_ParsedFormula: string;
Calculation_ParsedFormulaL2: string;
Child_OpenAgain: boolean;
Child_ProcessID: number;
Child_ShowCalendar: boolean;
Date_Type: string;
DefaultValue: string;
Description_HasSaving: boolean;
Description_IsParser: boolean;
Description_Text: string;
Document: string;
FieldStyle_3Col: boolean;
FieldStyle_4Col: boolean;
File_Extentions: string;
File_MaxSize: string;
Help: string;
ID: number;
IsDashboardField: boolean;
IsNumeric: boolean;
IsUnique: boolean;
LatinName: string;
Length: number | null;
LockFirstValue: boolean;
MaxValue: number | null;
MinValue: number | null;
Name: string;
NameOrID: string;
Option_IsChips: boolean;
Option_Options: string; // JSON string
OrderNumber: number;
Parent_CanBeSave: boolean;
Parent_DependentFields: string; // JSON string
Parent_FieldID: number;
Parent_Formula: string;
Parent_IsMultiSelection: boolean;
Parent_IsPersonParent: boolean;
Parent_LoadAll: boolean;
Parent_OpenSearchList: boolean;
Parent_ParsedFormula: string;
Parent_ProcessID: number;
Parent_SelectRemainOption: boolean;
Parent_ShowInTree: boolean;
Parent_Type: string;
ParsedValidation: string; // JSON string
ParsedValidationL2: string;
ShowCondition: string;
ShowInChildTable: boolean;
Status: boolean;
StepID: number;
StepName: string;
TabTitle: string;
Time_HasSecond: boolean;
Type: number; // → FieldTypes enum عددی تو داری
Unit: string;
Validation: string; // JSON string
ViewPermissionCount: number;
}
// ---------------------------
// Process Information
// ---------------------------
export interface ProcessInfo {
ID: number;
ProcessName: string;
ProcessLatinName: string;
HasWorkflowsTable: boolean;
ShowInCalendar: boolean;
// می‌تونم اگر خواستی کامل‌ترش کنم
}
// ---------------------------
// Step Information
// ---------------------------
export interface StepInfo {
XID: string;
TableName: string;
IsFinalStep: boolean;
HasChildField: boolean;
ProcessTitle: string;
// سایر چیزها قابل اضافه شدن هستند
}
// ---------------------------
// Optional Structures
// ---------------------------
export interface StageInfo {
[key: string]: any;
}
export interface StageHistoryInfo {
[key: string]: any;
}
export interface WorkflowInfo {
[key: string]: any;
}

View File

@ -0,0 +1,239 @@
export enum FieldTypes {
رشتهادی = 0,
رشتهارسی = 1,
رشتهاتین = 2,
رشته_چندخطی = 3,
عدد_صحیح = 4,
عدد_اعشاری = 5,
تاریخ = 6,
ساعت = 7,
پول = 8,
کدملی = 9,
تلفن_همراه = 10,
پلاکاشین = 11,
رایانامه = 12,
آدرسایگاه_اینترنتی = 13,
کشویی = 14,
رادیویی = 15,
چندانتخابی = 16,
تصویر = 17,
فایل = 18,
توضیحات = 19,
محاسباتی = 20,
فرآیندالد = 21,
فرآیند_فرزند = 22,
ویرایشگرارسر = 23,
چارتازمانی = 24,
پلاک_خودرو = 25,
جستجوی_شخص = 26,
}
export interface FieldDefinition {
AllowNull: boolean;
CalculationField_Async: boolean;
CalculationField_IsFunctionOutput: boolean;
CalculationField_ReCalculateAfterSave: boolean;
Calculation_Formula: string;
Calculation_ParsedFormula: string;
Calculation_ParsedFormulaL2: string;
Child_OpenAgain: boolean;
Child_ProcessID: number;
Child_ShowCalendar: boolean;
Date_Type: string;
DefaultValue: string;
Description_HasSaving: boolean;
Description_IsParser: boolean;
Description_Text: string;
Document: string;
FieldStyle_3Col: boolean;
FieldStyle_4Col: boolean;
File_Extentions: string;
File_MaxSize: string;
Help: string;
ID: number;
IsDashboardField: boolean;
IsNumeric: boolean;
IsUnique: boolean;
LatinName: string;
Length: number | null;
LockFirstValue: boolean;
MaxValue: number | null;
MinValue: number | null;
Name: string;
NameOrID: string;
Option_IsChips: boolean;
Option_Options: string; // JSON string
OrderNumber: number;
Parent_CanBeSave: boolean;
Parent_DependentFields: string; // JSON string
Parent_FieldID: number;
Parent_Formula: string;
Parent_IsMultiSelection: boolean;
Parent_IsPersonParent: boolean;
Parent_LoadAll: boolean;
Parent_OpenSearchList: boolean;
Parent_ParsedFormula: string;
Parent_ProcessID: number;
Parent_SelectRemainOption: boolean;
Parent_ShowInTree: boolean;
Parent_Type: string;
ParsedValidation: string; // JSON string
ParsedValidationL2: string;
ShowCondition: string;
ShowInChildTable: boolean;
Status: boolean;
StepID: number;
StepName: string;
TabTitle: string;
Time_HasSecond: boolean;
Type: number | string; // → FieldTypes enum عددی تو داری
Unit: string;
Validation: string; // JSON string
ViewPermissionCount: number;
// Add typeValue for client-side enum mapping
typeValue?: FieldTypes;
// Add parsed options and validation for client-side use
parsedOptions?: { label: string; value: string }[];
parsedValidationRules?: { regex: string; message: string }[];
}
// Regex Validators
export const regexValidators = {
persian: /^[\u0600-\u06FF\s]+$/, // Persian characters and spaces
latin: /^[a-zA-Z0-9\s]+$/, // English characters, numbers, and spaces
mobile: /^09[0-9]{9}$/, // Iranian mobile number format
nationalId: /^[0-9]{10}$/, // Iranian national ID (Kodemeli)
email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, // Basic email validation
url: /^(https?|ftp):\/\/[^\s/$.?#].[^\s]*$/, // Basic URL validation
carPlate: /^(\d{2}[آ-ی]\d{3}|\d{3}[آ-ی]\d{2})$/, // Example: 12ب345 or 123ب45 (simplified)
};
// Input Normalization (can be expanded as needed)
export const normalizeInput = (type: FieldTypes, value: any): any => {
if (value === null || value === undefined) return value;
switch (type) {
case FieldTypes.عدد_صحیح:
case FieldTypes.عدد_اعشاری:
case FieldTypes.پول:
return Number(value);
default:
return value;
}
};
// Field type mapping (can be expanded to map to specific components)
export const fieldTypeMap = (type: number): string => {
switch (type) {
case FieldTypes.رشتهادی:
case FieldTypes.رشتهارسی:
case FieldTypes.رشتهاتین:
case FieldTypes.کدملی:
case FieldTypes.تلفن_همراه:
case FieldTypes.رایانامه:
case FieldTypes.آدرسایگاه_اینترنتی:
case FieldTypes.پلاکاشین:
case FieldTypes.پلاک_خودرو:
case FieldTypes.جستجوی_شخص: // Map new type to text input
return "text";
case FieldTypes.رشته_چندخطی:
return "textarea";
case FieldTypes.عدد_صحیح:
return "number";
case FieldTypes.عدد_اعشاری:
return "decimal"; // Custom type for decimal input
case FieldTypes.تاریخ:
return "date";
case FieldTypes.ساعت:
return "time";
case FieldTypes.پول:
return "currency"; // Custom type for currency input
case FieldTypes.کشویی:
return "select";
case FieldTypes.رادیویی:
return "radio";
case FieldTypes.چندانتخابی:
return "multi-select";
case FieldTypes.تصویر:
return "image-upload";
case FieldTypes.فایل:
return "file-upload";
case FieldTypes.ویرایشگرارسر:
return "html-editor";
case FieldTypes.توضیحات:
return "description";
default:
return "text";
}
};
// Placeholder for FormService
export const FormService = {
submitDynamicForm: async (
processId: number,
stepId: number,
values: Record<string, any>
) => {
console.log("Submitting form:", { processId, stepId, values });
// In a real application, this would make an API call
return new Promise((resolve) =>
setTimeout(() => resolve({ success: true }), 1000)
);
},
fetchDependentOptions: async (
parentFieldId: number,
parentFieldValue: any
): Promise<{ label: string; value: string }[]> => {
console.log("Fetching dependent options for:", {
parentFieldId,
parentFieldValue,
});
// Simulate an API call to fetch options based on parentFieldValue
return new Promise((resolve) =>
setTimeout(() => {
if (parentFieldValue === "Option1") {
resolve([
{ label: "SubOption A", value: "SubOptionA" },
{ label: "SubOption B", value: "SubOptionB" },
]);
} else if (parentFieldValue === "Option2") {
resolve([
{ label: "SubOption C", value: "SubOptionC" },
{ label: "SubOption D", value: "SubOptionD" },
]);
} else {
resolve([]);
}
}, 500)
);
},
};

View File

@ -110,12 +110,17 @@ export function CampaignsPage() {
const handleJoin = (campaign: Campaign) => {
navigate(
`/${DASHBOARD_ROUTE.sub}/${DASHBOARD_ROUTE.joinToCampaing}?id=${campaign.WorkflowID}`
`${DASHBOARD_ROUTE.sub}/${DASHBOARD_ROUTE.joinToCampaing}?id=${campaign.WorkflowID}`,
{
replace: true,
}
);
};
const showCampaing = (cId: number) => {
navigate(`/${DASHBOARD_ROUTE.sub}/${DASHBOARD_ROUTE.campaigns}/${cId}`);
navigate(`${DASHBOARD_ROUTE.sub}/${DASHBOARD_ROUTE.campaing}/${cId}`, {
replace: true,
});
};
const renderSkeleton = () => (
@ -235,6 +240,9 @@ export function CampaignsPage() {
<p className="text-sm text-gray-500 mt-1">
{Number(campaign.signature_count)} عضو
</p>
<p className="text-sm text-gray-500 mt-1">
{Number(campaign.comment_count)} کامنت
</p>
</div>
<div className="flex flex-col gap-2">

View File

@ -158,9 +158,7 @@ export function RegisterPage() {
},
});
const handleInputChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => {
const handleInputChange = (e: any) => {
const { name, value } = e.target;
setFormData((prev) => {
@ -185,6 +183,30 @@ export function RegisterPage() {
}));
};
const handleDropDownChange = (e: { name: string; value: string }) => {
const { name, value } = e;
setFormData((prev) => {
if (name === "education_level") {
return {
...prev,
education_level: value as "متوسطه اول" | "متوسطه دوم" | "",
base: "",
};
}
return {
...prev,
[name]: value,
};
});
setErrors((prev) => ({
...prev,
[name]: "",
...(name === "education_level" && { base: "" }),
}));
};
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
@ -272,11 +294,17 @@ export function RegisterPage() {
label="مقطع تحصیلی"
name="education_level"
value={formData.education_level}
onChange={handleInputChange}
onChange={(e) => {
const model = {
name: "education_level",
value: e,
};
handleDropDownChange(model);
}}
error={errors.education_level}
options={[
{ value: "متوسطه اول", label: "متوسطه اول" },
{ value: "متوسطه دوم", label: "متوسطه دوم" },
{ value: "متوسطه اول", name: "متوسطه اول" },
{ value: "متوسطه دوم", name: "متوسطه دوم" },
]}
/>
@ -284,20 +312,26 @@ export function RegisterPage() {
label="پایه تحصیلی"
name="base"
value={formData.base}
onChange={handleInputChange}
onChange={(e) => {
const model = {
name: "base",
value: e,
};
handleDropDownChange(model);
}}
error={errors.base}
options={
formData.education_level === "متوسطه اول"
? [
{ value: "هفتم", label: "پایه هفتم" },
{ value: "هشتم", label: "پایه هشتم" },
{ value: "نهم", label: "پایه نهم" },
{ value: "هفتم", name: "پایه هفتم" },
{ value: "هشتم", name: "پایه هشتم" },
{ value: "نهم", name: "پایه نهم" },
]
: formData.education_level === "متوسطه دوم"
? [
{ value: "دهم", label: "پایه دهم" },
{ value: "یازدهم", label: "پایه یازدهم" },
{ value: "دوازدهم", label: "پایه دوازدهم" },
{ value: "دهم", name: "پایه دهم" },
{ value: "یازدهم", name: "پایه یازدهم" },
{ value: "دوازدهم", name: "پایه دوازدهم" },
]
: []
}

View File

@ -0,0 +1,270 @@
import React, {
useCallback,
useEffect,
useMemo,
useState,
type FC,
} from "react";
import { CustomButton } from "@/core/components/base/button";
import FormField from "@/core/components/base/form-field";
import {
FieldTypes,
FormService,
regexValidators,
type FieldDefinition,
} 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 [_, setFormErrors] = useState<Record<string, string | null>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
const initialValues: Record<string, any> = {};
fields.forEach((field) => {
if (field.DefaultValue !== undefined) {
initialValues[field.Name] = field.DefaultValue;
} else if (field.Type === FieldTypes.چندانتخابی) {
initialValues[field.Name] = [];
} else {
initialValues[field.Name] = "";
}
});
setFormValues(initialValues);
}, [fields]);
const validateField = useCallback(
(
field: FieldDefinition,
value: any,
allValues: Record<string, any>
): string | null => {
// Evaluate ShowCondition first. If not visible, no validation needed.
if (
field.ShowCondition &&
!evaluateShowCondition(field.ShowCondition, allValues)
) {
return null;
}
// Required validation
if (
!field.AllowNull &&
(value === null ||
value === "" ||
(Array.isArray(value) && value.length === 0))
) {
return "این فیلد الزامی است.";
}
// Length validation for text fields
if (
field.Length &&
typeof value === "string" &&
value.length > field.Length
) {
return `حداکثر طول مجاز ${field.Length} کاراکتر است.`;
}
// Min/Max validation for numeric fields
if (
(field.Type === FieldTypes.عدد_صحیح ||
field.Type === FieldTypes.عدد_اعشاری ||
field.Type === FieldTypes.پول) &&
typeof value === "number"
) {
if (field && field.MinValue !== undefined && value < field?.MinValue) {
return `مقدار باید بزرگتر یا مساوی ${field.MinValue} باشد.`;
}
if (field && field.MaxValue !== undefined && value > field?.MaxValue) {
return `مقدار باید کوچکتر یا مساوی ${field.MaxValue} باشد.`;
}
}
// Regex validation (from dynamic-field.utils.ts)
switch (field.Type) {
case FieldTypes.رشتهارسی:
if (value && !regexValidators.persian.test(value)) {
return "فقط حروف فارسی مجاز است.";
}
break;
case FieldTypes.رشتهاتین:
if (value && !regexValidators.latin.test(value)) {
return "فقط حروف انگلیسی و اعداد مجاز است.";
}
break;
case FieldTypes.تلفن_همراه:
if (value && !regexValidators.mobile.test(value)) {
return "شماره تلفن همراه معتبر نیست.";
}
break;
case FieldTypes.کدملی:
if (value && !regexValidators.nationalId.test(value)) {
return "کدملی معتبر نیست.";
}
break;
case FieldTypes.رایانامه:
if (value && !regexValidators.email.test(value)) {
return "آدرس رایانامه معتبر نیست.";
}
break;
case FieldTypes.آدرسایگاه_اینترنتی:
if (value && !regexValidators.url.test(value)) {
return "آدرس پایگاه اینترنتی معتبر نیست.";
}
break;
case FieldTypes.پلاکاشین:
case FieldTypes.پلاک_خودرو:
if (value && !regexValidators.carPlate.test(value)) {
return "فرمت پلاک خودرو معتبر نیست.";
}
break;
default:
break;
}
// Custom ParsedValidation
if (field.parsedValidationRules && value) {
for (const validationRule of field.parsedValidationRules) {
const regex = new RegExp(validationRule.regex);
if (!regex.test(value)) {
return validationRule.message;
}
}
}
return null;
},
[]
);
const handleFieldChange = useCallback(
(fieldName: string, newValue: unknown) => {
setFormValues((prevValues) => {
const updatedValues = {
...prevValues,
[fieldName]: newValue,
};
// Re-validate the field immediately
const fieldDef = fields.find((f) => f.Name === fieldName);
if (fieldDef) {
const error = validateField(fieldDef, newValue, updatedValues);
setFormErrors((prevErrors) => ({
...prevErrors,
[fieldName]: error,
}));
}
return updatedValues;
});
},
[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) => {
if (field.ShowCondition) {
return evaluateShowCondition(field.ShowCondition, formValues);
}
return true;
});
}, [fields, evaluateShowCondition, formValues]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
const newErrors: Record<string, string | null> = {};
let hasErrors = false;
for (const field of visibleFields) {
const value = formValues[field.Name];
const error = validateField(field, value, formValues); // Pass formValues for conditional validation
newErrors[field.Name] = error;
if (error) {
hasErrors = true;
}
}
setFormErrors(newErrors);
if (!hasErrors) {
try {
await FormService.submitDynamicForm(processId, stepId, formValues);
alert("Form submitted successfully!");
// Optionally reset form or navigate
} catch (err) {
console.error("Form submission failed:", err);
alert("Form submission failed.");
}
}
setIsSubmitting(false);
};
return (
<form
onSubmit={handleSubmit}
className="p-4 md:p-6 lg:p-8 bg-background rounded-lg shadow-md font-vazir"
dir="rtl"
>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-12 gap-4">
{visibleFields.map((field) => {
const colSpanClass = field.FieldStyle_4Col
? "lg:col-span-3" // 4 columns in a 12-column grid
: field.FieldStyle_3Col
? "lg:col-span-4" // 3 columns in a 12-column grid
: "lg:col-span-6"; // Default to 2 columns in a 12-column grid
return (
<div
key={field.ID}
className={`col-span-1 md:col-span-1 ${colSpanClass}`}
>
<label className="text-right text-sm font-medium">
{field.Name}{" "}
{!field.AllowNull && <span className="text-red-500">*</span>}
</label>
<FormField
field={field}
value={formValues[field.Name]}
onChange={(newValue: any) =>
handleFieldChange(field.Name, newValue)
}
allFormValues={formValues} // Pass all form values for dependent fields
/>
</div>
);
})}
</div>
<div className="mt-6 text-left">
<CustomButton type="submit" disabled={isSubmitting}>
{isSubmitting ? "در حال ارسال..." : "ارسال فرم"}
</CustomButton>
</div>
</form>
);
};
export default DynamicForm;

View File

@ -0,0 +1,144 @@
import { useEffect, useState, type FC } from "react";
import type { WorkflowResponse } from "@/core/types/global.type";
import type { FieldDefinition as LocalFieldDefinition } from "@core/utils/dynamic-field.utils";
import { FieldTypes } from "@core/utils/dynamic-field.utils";
import { fetchFieldService } from "@modules/dashboard/service/dynamic-form.service";
import { useQuery } from "@tanstack/react-query";
import { useParams } from "react-router-dom";
import DynamicForm from "./dynamic-form";
const StepFormPage: FC = () => {
const [fields, setFields] = useState<LocalFieldDefinition[]>([]);
const { id } = useParams<{ id: string }>();
const { data, isLoading, error } = useQuery<WorkflowResponse>({
queryKey: ["dynamic-field", id],
queryFn: fetchFieldService,
});
useEffect(() => {
if (data && data.Fields) {
const processedFields: LocalFieldDefinition[] = data.Fields.map((el) => {
const field: LocalFieldDefinition = {
ID: el.ID,
Name: el.Name,
LatinName: el.LatinName,
Type: el.Type,
DefaultValue: el.DefaultValue,
Length: el.Length,
MinValue: el.MinValue,
MaxValue: el.MaxValue,
Option_Options: el.Option_Options,
IsNumeric: el.IsNumeric,
Validation: el.Validation,
ParsedValidation: el.ParsedValidation,
Help: el.Help,
Parent_ProcessID: el.Parent_ProcessID,
Parent_FieldID: el.Parent_FieldID,
FieldStyle_3Col: el.FieldStyle_3Col,
FieldStyle_4Col: el.FieldStyle_4Col,
ShowCondition: el.ShowCondition,
AllowNull: el.AllowNull,
CalculationField_Async: el.CalculationField_Async,
CalculationField_IsFunctionOutput:
el.CalculationField_IsFunctionOutput,
CalculationField_ReCalculateAfterSave:
el.CalculationField_ReCalculateAfterSave,
Calculation_Formula: el.Calculation_Formula,
Calculation_ParsedFormula: el.Calculation_ParsedFormula,
Calculation_ParsedFormulaL2: el.Calculation_ParsedFormulaL2,
Child_OpenAgain: el.Child_OpenAgain,
Child_ProcessID: el.Child_ProcessID,
Child_ShowCalendar: el.Child_ShowCalendar,
Date_Type: el.Date_Type,
Description_HasSaving: el.Description_HasSaving,
Description_IsParser: el.Description_IsParser,
Description_Text: el.Description_Text,
Document: el.Document,
File_Extentions: el.File_Extentions,
File_MaxSize: el.File_MaxSize,
IsDashboardField: el.IsDashboardField,
IsUnique: el.IsUnique,
LockFirstValue: el.LockFirstValue,
NameOrID: el.NameOrID,
Option_IsChips: el.Option_IsChips,
OrderNumber: el.OrderNumber,
Parent_CanBeSave: el.Parent_CanBeSave,
Parent_DependentFields: el.Parent_DependentFields,
Parent_Formula: el.Parent_Formula,
Parent_IsMultiSelection: el.Parent_IsMultiSelection,
Parent_IsPersonParent: el.Parent_IsPersonParent,
Parent_LoadAll: el.Parent_LoadAll,
Parent_OpenSearchList: el.Parent_OpenSearchList,
Parent_ParsedFormula: el.Parent_ParsedFormula,
Parent_SelectRemainOption: el.Parent_SelectRemainOption,
Parent_ShowInTree: el.Parent_ShowInTree,
Parent_Type: el.Parent_Type,
ParsedValidationL2: el.ParsedValidationL2,
ShowInChildTable: el.ShowInChildTable,
Status: el.Status,
StepID: el.StepID,
StepName: el.StepName,
TabTitle: el.TabTitle,
Time_HasSecond: el.Time_HasSecond,
Unit: el.Unit,
ViewPermissionCount: el.ViewPermissionCount,
typeValue: el.Type as FieldTypes, // Map number to enum
};
if (el.Option_Options) {
try {
field.parsedOptions = JSON.parse(el.Option_Options);
} catch (e) {
console.error(
`Error parsing Option_Options for field ${el.ID}:`,
e
);
field.parsedOptions = [];
}
}
if (el.ParsedValidation) {
try {
field.parsedValidationRules = JSON.parse(el.ParsedValidation);
} catch (e) {
console.error(
`Error parsing ParsedValidation for field ${el.ID}:`,
e
);
field.parsedValidationRules = [];
}
}
return field;
});
setFields(processedFields);
}
}, [data]);
if (isLoading) {
return (
<div className="container mx-auto p-4 text-center font-vazir">
در حال بارگذاری فرم...
</div>
);
}
if (error) {
return (
<div className="container mx-auto p-4 text-center text-red-500 font-vazir">
خطا در بارگذاری فرم: {error.message}
</div>
);
}
return (
<div className="container mx-auto p-4">
<h1 className="text-2xl font-bold mb-6 text-right font-vazir">
فرم پویا
</h1>
<DynamicForm fields={fields} processId={1} stepId={1} />
</div>
);
};
export default StepFormPage;

View File

@ -0,0 +1,7 @@
import type { FieldDefinition } from "@/core/utils/dynamic-field.utils";
export interface DynamicFormProps {
fields: FieldDefinition[];
processId: number;
stepId: number;
}

View File

@ -5,4 +5,5 @@ export const DASHBOARD_ROUTE = {
campaigns: "campaigns",
campaing: "campaing",
joinToCampaing: "join-to-campaing",
daynamicPage: "dynamic-page",
};

View File

@ -5,6 +5,7 @@ import CampaignDetailPage from "../pages/campaigns/detail";
import JoinToCampaing from "../pages/join-to-campaing";
import DashboardPage from "../pages/main-page";
import ProfilePage from "../pages/profile";
import StepFormPage from "../pages/step-form";
import { DASHBOARD_ROUTE } from "./route.constant";
export const dashboardRoutes: AppRoute[] = [
@ -32,6 +33,10 @@ export const dashboardRoutes: AppRoute[] = [
path: `${DASHBOARD_ROUTE.joinToCampaing}/:id`,
element: <JoinToCampaing />,
},
{
path: `${DASHBOARD_ROUTE.daynamicPage}/:id`,
element: <StepFormPage />,
},
],
},
];

View File

@ -0,0 +1,19 @@
import { API_ADDRESS } from "@/core/service/api-address";
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";
export const fetchFieldService = async (): Promise<WorkflowResponse> => {
const [err, res] = await to(api.post(API_ADDRESS.index, "1224"));
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;
};