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:
parent
d725c1b7d7
commit
6634ecfda7
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}, [isOpen]);
|
||||
} else {
|
||||
// When closing, reset internal options to initial if not fetching
|
||||
if (!fetchOptions) {
|
||||
setInternalOptions(options);
|
||||
}
|
||||
}
|
||||
}, [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>
|
||||
{isLoading ? (
|
||||
<div className="p-4 text-center text-sm text-gray-500">
|
||||
در حال بارگذاری...
|
||||
</div>
|
||||
) : (
|
||||
<ul className="max-h-48 overflow-auto">
|
||||
{filteredOptions.map((option) => (
|
||||
{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.label}
|
||||
{option.value}
|
||||
</li>
|
||||
))}
|
||||
))
|
||||
) : (
|
||||
<li className="px-4 py-2 text-sm text-gray-500">
|
||||
موردی یافت نشد.
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
379
src/core/components/base/form-field.tsx
Normal file
379
src/core/components/base/form-field.tsx
Normal 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;
|
||||
50
src/core/components/base/person-search-input.tsx
Normal file
50
src/core/components/base/person-search-input.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
239
src/core/utils/dynamic-field.utils.ts
Normal file
239
src/core/utils/dynamic-field.utils.ts
Normal 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)
|
||||
);
|
||||
},
|
||||
};
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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: "پایه دوازدهم" },
|
||||
]
|
||||
: []
|
||||
}
|
||||
|
|
|
|||
270
src/modules/dashboard/pages/step-form/dynamic-form.tsx
Normal file
270
src/modules/dashboard/pages/step-form/dynamic-form.tsx
Normal 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;
|
||||
144
src/modules/dashboard/pages/step-form/index.tsx
Normal file
144
src/modules/dashboard/pages/step-form/index.tsx
Normal 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;
|
||||
7
src/modules/dashboard/pages/step-form/step-form.type.ts
Normal file
7
src/modules/dashboard/pages/step-form/step-form.type.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import type { FieldDefinition } from "@/core/utils/dynamic-field.utils";
|
||||
|
||||
export interface DynamicFormProps {
|
||||
fields: FieldDefinition[];
|
||||
processId: number;
|
||||
stepId: number;
|
||||
}
|
||||
|
|
@ -5,4 +5,5 @@ export const DASHBOARD_ROUTE = {
|
|||
campaigns: "campaigns",
|
||||
campaing: "campaing",
|
||||
joinToCampaing: "join-to-campaing",
|
||||
daynamicPage: "dynamic-page",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 />,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
|
|||
19
src/modules/dashboard/service/dynamic-form.service.ts
Normal file
19
src/modules/dashboard/service/dynamic-form.service.ts
Normal 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;
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user