feat: Introduce new dynamic field properties and refine form handling

This commit introduces new capabilities for dynamic forms and refactors related components for improved type safety and data fetching.

- **`src/core/utils/dynamic-field.utils.ts`**:
    - Added `Description` and `Required` properties to the `FieldDefinition` interface to allow for more detailed field configurations and validation.
    - Narrowed the `Type` property from `number | string` to `number` for stricter type enforcement.
- **`src/core/components/base/form-field.tsx`**:
    - Applied non-null assertion operators (`!`) and optional chaining (`?.`) to `field.MinValue`, `field.MaxValue`, `field.Length`, and `field.Type`. This improves type safety and handles cases where these properties might be undefined according to the updated `FieldDefinition`.
- **`src/core/service/api-address.ts`**:
    - Added `index2: "workflow/index2"` to `API_ADDRESS` to support a new workflow data endpoint.
- **`src/modules/dashboard/pages/campaigns/detail.tsx`**:
    - Updated the campaign detail page to display `campaign.user_id_nickname` instead of `campaign.nickname` for accurate user identification.
- **`src/modules/dashboard/pages/step-form/index.tsx`**:
    - Refactored dynamic form data fetching to use `fetchFieldIndex` and `fetchFielSecondeIndex` services.
    - Switched from `useParams` to `useSearchParams` for more flexible parameter handling in the step form.
This commit is contained in:
MehrdadAdabi 2025-11-26 18:15:48 +03:30
parent 6634ecfda7
commit bbb6bfb7f7
14 changed files with 338 additions and 20 deletions

View File

@ -63,10 +63,10 @@ const FormField: FC<FormFieldProps> = ({
field.Type === FieldTypes.پول) &&
typeof currentValue === "number"
) {
if (field.MinValue !== undefined && currentValue < field?.MinValue) {
if (field.MinValue !== undefined && currentValue < field?.MinValue!) {
errorMessage = `مقدار باید بزرگتر یا مساوی ${field.MinValue} باشد.`;
}
if (field.MaxValue !== undefined && currentValue > field?.MaxValue) {
if (field.MaxValue !== undefined && currentValue > field?.MaxValue!) {
errorMessage = `مقدار باید کوچکتر یا مساوی ${field.MaxValue} باشد.`;
}
}
@ -130,7 +130,7 @@ const FormField: FC<FormFieldProps> = ({
const handleChange = (
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
) => {
const newValue = normalizeInput(field.Type, e.target.value);
const newValue = normalizeInput(field?.Type, e.target.value);
onChange(newValue);
};
@ -163,7 +163,7 @@ const FormField: FC<FormFieldProps> = ({
<CustomInput
{...commonProps}
type="text"
maxLength={field.Length}
maxLength={field.Length!}
placeholder={field.Name}
/>
);
@ -181,7 +181,7 @@ const FormField: FC<FormFieldProps> = ({
return (
<TextAreaField
{...commonProps}
maxLength={field.Length}
maxLength={field.Length!}
placeholder={field.Name}
/>
);
@ -361,7 +361,7 @@ const FormField: FC<FormFieldProps> = ({
<CustomInput
{...commonProps}
type="text"
maxLength={field.Length}
maxLength={field.Length!}
placeholder={field.Name}
/>
);

View File

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

View File

@ -49,6 +49,7 @@ export interface FieldDefinition {
Description_HasSaving: boolean;
Description_IsParser: boolean;
Description_Text: string;
Description: string;
Document: string;
@ -79,6 +80,7 @@ export interface FieldDefinition {
Option_IsChips: boolean;
Option_Options: string; // JSON string
Required: boolean;
OrderNumber: number;
@ -112,7 +114,7 @@ export interface FieldDefinition {
Time_HasSecond: boolean;
Type: number | string; // → FieldTypes enum عددی تو داری
Type: number; // → FieldTypes enum عددی تو داری
Unit: string;

View File

@ -175,7 +175,8 @@ export function CampaignDetailPage() {
</h1>
<p className="text-slate-600 text-right mb-6">
توسط: <span className="font-semibold">{campaign.nickname}</span>
توسط:{" "}
<span className="font-semibold">{campaign.user_id_nickname}</span>
</p>
<div className="flex items-center justify-end gap-8 py-4 border-t border-b border-gray-200 mb-6">

View File

@ -3,17 +3,25 @@ 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 {
fetchFieldIndex,
fetchFielSecondeIndex,
} from "@modules/dashboard/service/dynamic-form.service";
import { useQuery } from "@tanstack/react-query";
import { useParams } from "react-router-dom";
import { useSearchParams } from "react-router-dom";
import DynamicForm from "./dynamic-form";
const StepFormPage: FC = () => {
const [fields, setFields] = useState<LocalFieldDefinition[]>([]);
const { id } = useParams<{ id: string }>();
const [params] = useSearchParams();
const stageID = params.get("stageID");
const processID = params.get("processID");
const { data, isLoading, error } = useQuery<WorkflowResponse>({
queryKey: ["dynamic-field", id],
queryFn: fetchFieldService,
queryKey: ["dynamic-field", stageID ?? processID],
queryFn: () => {
if (stageID) return fetchFielSecondeIndex(stageID);
return fetchFieldIndex(processID!);
},
});
useEffect(() => {
@ -54,6 +62,7 @@ const StepFormPage: FC = () => {
Description_HasSaving: el.Description_HasSaving,
Description_IsParser: el.Description_IsParser,
Description_Text: el.Description_Text,
Description: el.Description_Text,
Document: el.Document,
File_Extentions: el.File_Extentions,
File_MaxSize: el.File_MaxSize,
@ -62,6 +71,8 @@ const StepFormPage: FC = () => {
LockFirstValue: el.LockFirstValue,
NameOrID: el.NameOrID,
Option_IsChips: el.Option_IsChips,
// Option_Options: el.Option_Options,
// Required: el.Required,
OrderNumber: el.OrderNumber,
Parent_CanBeSave: el.Parent_CanBeSave,
Parent_DependentFields: el.Parent_DependentFields,
@ -83,7 +94,7 @@ const StepFormPage: FC = () => {
Time_HasSecond: el.Time_HasSecond,
Unit: el.Unit,
ViewPermissionCount: el.ViewPermissionCount,
typeValue: el.Type as FieldTypes, // Map number to enum
typeValue: el.Type as FieldTypes,
};
if (el.Option_Options) {

View File

@ -0,0 +1,224 @@
import { DASHBOARD_ROUTE } from "@modules/dashboard/routes/route.constant";
import { getCampaignStepsService } from "@modules/dashboard/service/campaigns.service";
import { useQuery } from "@tanstack/react-query";
import { MoveLeft } from "lucide-react";
import { useEffect, useState, type FC } from "react";
import { useNavigate, useParams } from "react-router-dom";
import type { CampaignProcess, GroupedCampaign } from "./step.type";
const StepsPage: FC = () => {
const { campaignId } = useParams<{ campaignId: string }>();
const navigate = useNavigate();
const [steps, setSteps] = useState<GroupedCampaign[]>([]);
const [selectedStepId, setSelectedStepId] = useState<number | null>(null);
const { data, isLoading, error } = useQuery<Record<string, any>>({
queryKey: ["dynamic-step"],
queryFn: () => getCampaignStepsService(campaignId!),
});
useEffect(() => {
if (data && Object.keys(data).length > 0) {
const processes: CampaignProcess[] = Object.keys(data[0])
.filter(
(key) =>
key.startsWith("process") &&
key.endsWith("_id") === false &&
key.includes("_")
)
.map((key) => {
const index = key.match(/process(\d+)_/)?.[1];
if (!index) return null;
return {
processId: data[0][`process${index}`],
category: data[0][`process${index}_category`],
score: data[0][`process${index}_score`],
stageId: data[0][`process${index}_stage_id`],
status: data[0][`process${index}_status`],
};
})
.filter(Boolean) as CampaignProcess[];
const grouped: GroupedCampaign[] = Object.values(
processes.reduce((acc, item) => {
if (!acc[item.category]) {
acc[item.category] = {
category: item.category,
processes: [],
stageID: Number(item.stageId),
processId: Number(item.processId),
};
}
acc[item.category].processes.push(item);
return acc;
}, {} as Record<string, GroupedCampaign>)
);
setSteps(grouped);
}
}, [data]);
const handleBack = () => {
navigate(
`${DASHBOARD_ROUTE.sub}/${DASHBOARD_ROUTE.campaigns}/${campaignId}`
);
};
// navigate(`${DASHBOARD_ROUTE.dynamicForm}?stageID=${stageID}`);
// navigate(`${DASHBOARD_ROUTE.dynamicForm}?processID=${processID}`);
const handleStepClick = (step: GroupedCampaign) => {
// setSelectedStepId(step.processId);
if (
step.stageID !== null &&
step.stageID !== 0 &&
step.processId !== null &&
step.processId !== 0
) {
navigate(
`${DASHBOARD_ROUTE.sub}/${DASHBOARD_ROUTE.dynamicForm}?stageID=${step.stageID}`,
{
replace: true,
}
);
}
if (
(step.processId != 0 &&
step.processId != null &&
step.processId != undefined &&
step.stageID === null) ||
step.stageID === undefined
) {
navigate(
`${DASHBOARD_ROUTE.sub}/${DASHBOARD_ROUTE.dynamicForm}?processID=${step.processId}`,
{
replace: true,
}
);
}
if (
(step.stageID != 0 &&
step.stageID != null &&
step.stageID != undefined &&
step.processId === null) ||
step.processId === undefined
) {
navigate(
`${DASHBOARD_ROUTE.sub}/${DASHBOARD_ROUTE.dynamicForm}?stageID=${step.stageID}`,
{
replace: true,
}
);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen bg-gray-100">
<div className="text-lg font-medium text-gray-700">
در حال بارگزاری ....
</div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center min-h-screen bg-red-100 text-red-700">
<div className="text-lg font-medium">{error.message}</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 p-4 sm:p-6 lg:p-8">
{/* Back Button */}
<div className="mb-6">
<button
onClick={handleBack}
className="flex items-center gap-2 text-blue-600 hover:text-blue-800 transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 rounded-md p-2"
>
<MoveLeft className="rotate-180" size={20} />
بازگشت به صفحه قبل
</button>
</div>
<h1 className="text-3xl sm:text-4xl font-extrabold text-gray-900 mb-8 text-center">
مراحل جزیره
</h1>
<div className="max-w-2xl mx-auto">
{steps.length === 0 ? (
<p className="text-center text-gray-600 text-lg">
مرحله ای یافت نشد.
</p>
) : (
<ul className="space-y-4">
{steps.map((step, index) => (
<li
key={`${step.category}-${index}`}
className={`
relative flex items-center gap-2.5 p-4 sm:p-6 rounded-lg shadow-md
bg-white border border-gray-200
hover:shadow-lg hover:border-blue-300
focus-within:ring-2 focus-within:ring-blue-500 focus-within:ring-opacity-50
transition-all duration-300 ease-in-out transform
${
selectedStepId === step.stageID
? "bg-blue-50 border-blue-500 ring-2 ring-blue-500 ring-opacity-75 scale-105"
: "hover:scale-102"
}
`}
onClick={() => handleStepClick(step)}
tabIndex={0} // Make list item focusable
>
<div
className={`
flex items-center justify-center
w-10 h-10 sm:w-12 sm:h-12 rounded-full
font-bold text-lg sm:text-xl
mr-4 sm:mr-6 shrink-0
${
selectedStepId === step.stageID
? "bg-blue-600 text-white"
: "bg-gray-200 text-gray-700"
}
transition-all duration-300 ease-in-out
`}
>
{index + 1}
</div>
<div className="grow">
<h2
className={`
text-lg sm:text-xl font-semibold
${
selectedStepId === step.stageID
? "text-blue-800"
: "text-gray-800"
}
transition-colors duration-300 ease-in-out
`}
>
{step.category}
</h2>
{step.category &&
step.category &&
step.category !== step.category && (
<p className="text-sm text-gray-500 mt-1">
{step.category}
</p>
)}
</div>
</li>
))}
</ul>
)}
</div>
</div>
);
};
export default StepsPage;

View File

@ -0,0 +1,14 @@
export interface CampaignProcess {
processId: string;
category: string;
score: string;
stageId: string;
status: string;
}
export interface GroupedCampaign {
stageID: number;
processId: number;
category: string;
processes: CampaignProcess[];
}

View File

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

View File

@ -6,6 +6,7 @@ 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 StepsPage from "../pages/steps";
import { DASHBOARD_ROUTE } from "./route.constant";
export const dashboardRoutes: AppRoute[] = [
@ -34,9 +35,13 @@ export const dashboardRoutes: AppRoute[] = [
element: <JoinToCampaing />,
},
{
path: `${DASHBOARD_ROUTE.daynamicPage}/:id`,
path: DASHBOARD_ROUTE.dynamicForm,
element: <StepFormPage />,
},
{
path: `${DASHBOARD_ROUTE.steps}`,
element: <StepsPage />,
},
],
},
];

View File

@ -323,3 +323,32 @@ export const createCircleService = async (
toast.success("محفل با موفقیت ایجاد شد");
return res.data;
};
export const getCampaignStepsService = async (
campaignId: string
): Promise<Record<string, any>> => {
const query = {
ProcessName: "run_process",
OutputFields: ["*"],
conditions: [
["campaign_id", "=", "18909", "and"],
["group_id", "=", "18950"],
],
};
const [err, res] = await to(api.post(API_ADDRESS.select, query));
if (err) {
throw err;
}
if (res.data.resultType !== 0) {
toast.error("خطا در دریافت مراحل کارزار");
throw new Error("خطا در دریافت مراحل کارزار");
}
const data = JSON.parse(res.data.data);
if (!data || Object.keys(data).length === 0) {
return {};
}
return data;
};

View File

@ -4,8 +4,26 @@ 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"));
export const fetchFieldIndex = async (
id: string
): Promise<WorkflowResponse> => {
const [err, res] = await to(api.post(API_ADDRESS.index, id));
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;
};
export const fetchFielSecondeIndex = async (
id: string
): Promise<WorkflowResponse> => {
const [err, res] = await to(api.post(API_ADDRESS.index, id));
if (err) {
throw err;
}

View File

@ -0,0 +1,12 @@
export interface CampaignStep {
id: string;
orderNumber: number;
stepName: string;
title: string; // Assuming title is also available, as per description
isSelected?: boolean; // Optional field for highlighting
}
export interface GetCampaignStepsResponse {
data: CampaignStep[];
// Add any other relevant fields from the API response
}

View File

@ -27,7 +27,7 @@
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"erasableSyntaxOnly": false,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},

View File

@ -18,7 +18,7 @@
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"erasableSyntaxOnly": false,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},