Browse Source

fix question

master
mortezaei 8 hours ago
parent
commit
da69d7b80a
  1. 48
      src/components/Componentes/multi-select-e2e.integration.test.tsx
  2. 33
      src/components/Componentes/question-answer-storage.tsx
  3. 25
      src/components/Componentes/question-checkbox.tsx
  4. 22
      src/components/Componentes/question-dropdown.tsx
  5. 12
      src/components/Componentes/question-file.tsx
  6. 12
      src/components/Componentes/question-photo.tsx
  7. 4
      src/components/Componentes/question-renderer.tsx
  8. 125
      src/components/Componentes/question-section-flow.tsx
  9. 72
      src/components/Componentes/question-sheet.test.tsx
  10. 87
      src/components/Componentes/question-sheet.tsx
  11. 48
      src/components/Componentes/ui-config.test.tsx
  12. 16
      src/hooks/marriage/use-form-schema.ts
  13. 11
      src/lib/schema-adapter.ts
  14. 1
      src/lib/webview-actions.ts
  15. 1
      src/types/window.d.ts

48
src/components/Componentes/multi-select-e2e.integration.test.tsx

@ -291,5 +291,53 @@ describe("Multi-Select End-to-End & Integration Tests", () => {
expect(storedField.value).toEqual(["hookah_rarely", "cigarettes_rarely"]);
expect(storedField.option_id).toEqual(["hookah_rarely", "cigarettes_rarely"]);
});
it("E2E Scenario 5: QuestionCheckbox multi-select enforces max_select limit", async () => {
const { QuestionCheckbox } = await import("./question-checkbox");
let currentAnswers: any = null;
function TestCheckboxContainer({ question }: { question: QuestionField }) {
const { answers } = useQuestionAnswers();
currentAnswers = answers;
return <QuestionCheckbox question={question} />;
}
const question: QuestionField = {
id: "beliefs_lifestyle.what_are_your_prominent_personality_traits",
title: "ویژگی‌های شخصیتی شما چیست؟",
type: "checkbox",
order: 1,
required: true,
isVisible: true,
description: "",
tooltip: "",
extras: { placeHolder: "" },
options: [
{ id: "t1", value: "t1", label: "Trait 1", order: 1 },
{ id: "t2", value: "t2", label: "Trait 2", order: 2 },
{ id: "t3", value: "t3", label: "Trait 3", order: 3 },
],
ui_config: { max_select: 2 },
};
render(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="beliefs_lifestyle" questions={[question]}>
<TestCheckboxContainer question={question} />
</QuestionAnswersProvider>
</QueryClientProvider>
);
// Select trait 1 and trait 2
fireEvent.click(screen.getByText("Trait 1"));
fireEvent.click(screen.getByText("Trait 2"));
expect(currentAnswers["beliefs_lifestyle.what_are_your_prominent_personality_traits"].value).toEqual(["t1", "t2"]);
// Attempt to select trait 3 (should be ignored due to max_select: 2)
fireEvent.click(screen.getByText("Trait 3"));
expect(currentAnswers["beliefs_lifestyle.what_are_your_prominent_personality_traits"].value).toEqual(["t1", "t2"]);
});
});

33
src/components/Componentes/question-answer-storage.tsx

@ -171,24 +171,37 @@ function createQuestionField(
question.type === "dropdown" ||
question.type === "radio" ||
question.type === "checkbox" ||
question.type === "scale";
question.type === "scale" ||
question.type === "select" ||
question.type === "multi_select" ||
question.type === "multiselect" ||
question.type === "choice";
const maxSelect =
question.ui_config?.max_select ||
(question.validation?.max ? Number(question.validation.max) : undefined);
let boundedValue = value;
if (Array.isArray(boundedValue) && maxSelect && boundedValue.length > maxSelect) {
boundedValue = boundedValue.slice(0, maxSelect);
}
if (isChoiceType && question.options && Array.isArray(question.options) && question.options.length > 0) {
if (Array.isArray(value)) {
option_id = value;
} else if (typeof value === "string" && value) {
const strVal = value.trim().toLowerCase();
if (Array.isArray(boundedValue)) {
option_id = boundedValue;
} else if (typeof boundedValue === "string" && boundedValue) {
const strVal = boundedValue.trim().toLowerCase();
const selectedOpt = question.options.find(
(opt) =>
opt.id === value ||
opt.value === value ||
opt.label === value ||
opt.id === boundedValue ||
opt.value === boundedValue ||
opt.label === boundedValue ||
opt.id.toLowerCase() === strVal ||
(typeof opt.value === "string" && opt.value.toLowerCase() === strVal) ||
(typeof opt.label === "string" && opt.label.toLowerCase() === strVal) ||
opt.id.toLowerCase().endsWith(`.${strVal}`)
);
option_id = selectedOpt ? selectedOpt.id : value;
option_id = selectedOpt ? selectedOpt.id : boundedValue;
}
}
@ -198,7 +211,7 @@ function createQuestionField(
key,
label: question.title,
type: question.type,
value,
value: boundedValue,
private: question.private,
option_id: isChoiceType ? option_id : undefined,
} as MarriageField;

25
src/components/Componentes/question-checkbox.tsx

@ -100,11 +100,18 @@ export function QuestionCheckbox({
);
}
const maxSelect =
question.ui_config?.max_select ||
(question.validation?.max ? Number(question.validation.max) : undefined);
const toggleOption = (optionId: string) => {
let nextValue: string[];
if (value.includes(optionId)) {
nextValue = value.filter((v) => v !== optionId);
} else {
if (maxSelect && value.length >= maxSelect) {
return;
}
nextValue = [...value, optionId];
}
@ -137,27 +144,37 @@ export function QuestionCheckbox({
{options.map((option) => {
const optionId = `checkbox-${question.id}-${option.id}`;
const isSelected = value.includes(option.id);
const isOptionDisabled =
!isSelected && Boolean(maxSelect && value.length >= maxSelect);
return (
<label
key={option.id}
htmlFor={optionId}
className={[
"cursor-pointer rounded-[16px] font-semibold text-[15px] transition-all duration-200 active:scale-[0.99] flex items-center gap-3 border",
"rounded-[16px] font-semibold text-[15px] transition-all duration-200 flex items-center gap-3 border",
isOptionDisabled
? "opacity-35 cursor-not-allowed pointer-events-none bg-[#F9FAFB] text-[#98A2B3] border-[#F2F4F7]"
: "cursor-pointer active:scale-[0.99]",
isShortOptions
? "flex-1 min-w-[90px] px-5 py-3.5 text-center justify-center"
: "w-full px-5 py-4 text-start leading-snug",
isSelected
? "bg-[#F0445B] text-white border-transparent shadow-none"
: "bg-[#FAFAFA] text-[#181818] hover:bg-white border-[#F0EDED] shadow-none",
: !isOptionDisabled
? "bg-[#FAFAFA] text-[#181818] hover:bg-white border-[#F0EDED] shadow-none"
: "",
].join(" ")}
>
<input
type="checkbox"
id={optionId}
checked={isSelected}
disabled={disabled}
onChange={() => toggleOption(option.id)}
disabled={disabled || isOptionDisabled}
onChange={() => {
if (isOptionDisabled) return;
toggleOption(option.id);
}}
className="sr-only"
/>
{!isShortOptions && (

22
src/components/Componentes/question-dropdown.tsx

@ -248,11 +248,18 @@ export function QuestionDropdown({
? selectedList.includes(option.id)
: singleValue === option.id;
const isOptionDisabled =
isMulti &&
!isSelected &&
Boolean(maxSelect && selectedList.length >= maxSelect);
return (
<button
key={option.id}
type="button"
disabled={isOptionDisabled}
onClick={() => {
if (isOptionDisabled) return;
if (isMulti) {
toggleMultiOption(option.id);
} else {
@ -260,7 +267,12 @@ export function QuestionDropdown({
setIsOpen(false);
}
}}
className="flex w-full items-start gap-3 text-start cursor-pointer group/opt py-0.5"
className={[
"flex w-full items-start gap-3 text-start py-0.5",
isOptionDisabled
? "opacity-35 cursor-not-allowed pointer-events-none"
: "cursor-pointer group/opt",
].join(" ")}
>
{/* Option Indicator Icon */}
{isMulti ? (
@ -268,9 +280,11 @@ export function QuestionDropdown({
<div
className={[
"size-[20px] shrink-0 rounded-[6px] transition-all duration-150 mt-0.5",
isSelected
? "bg-[#F2465F] shadow-xs"
: "border-[2px] border-[#344054] bg-transparent group-hover/opt:border-[#181818]",
isOptionDisabled
? "border-[2px] border-[#D0D5DD] bg-[#F2F4F7]"
: isSelected
? "bg-[#F2465F] shadow-xs"
: "border-[2px] border-[#344054] bg-transparent group-hover/opt:border-[#181818]",
].join(" ")}
/>
) : (

12
src/components/Componentes/question-file.tsx

@ -402,11 +402,21 @@ export function QuestionFile({
pendingDeferredRef.current = { resolve: resolveFn, reject: rejectFn };
registerPendingUpload?.(question.id, uploadPromise);
const pickerSource =
question.ui_config?.source ||
question.ui_config?.picker_type ||
"file_system";
const pickerType =
question.ui_config?.picker_type ||
question.ui_config?.source ||
"file_system";
uploadFile({
requestId: String(question.id),
mediaType,
multiple: true,
source: (question.ui_config?.source as any) || "file_system",
source: pickerSource as any,
picker_type: pickerType as any,
returnAs: "upload",
uploadUrl: `${process.env.NEXT_PUBLIC_API_BASE_URL ?? ""}/upload-tmp-media/`,
fieldName: "file",

12
src/components/Componentes/question-photo.tsx

@ -155,10 +155,20 @@ export function QuestionPhoto({
pendingDeferredRef.current = { resolve: resolveFn, reject: rejectFn };
registerPendingUpload?.(question.id, uploadPromise);
const pickerSource =
question.ui_config?.source ||
question.ui_config?.picker_type ||
"gallery";
const pickerType =
question.ui_config?.picker_type ||
question.ui_config?.source ||
"gallery";
uploadFile({
requestId: String(question.id),
mediaType: "image",
source: (question.ui_config?.source as any) || "gallery",
source: pickerSource as any,
picker_type: pickerType as any,
returnAs: "upload",
uploadUrl: `${process.env.NEXT_PUBLIC_API_BASE_URL ?? ""}/upload-tmp-media/`,
fieldName: "file",

4
src/components/Componentes/question-renderer.tsx

@ -63,6 +63,10 @@ export function QuestionRenderer({
/>
);
case "dropdown":
case "select":
case "multi_select":
case "multiselect":
case "choice":
return (
<QuestionSheet
question={question}

125
src/components/Componentes/question-section-flow.tsx

@ -106,59 +106,116 @@ function SectionFlowContent({
let msg = "";
// 1. Detailed field validation errors from backend
if (data?.errors && typeof data.errors === "object") {
const errorEntries = Object.entries(data.errors);
if (errorEntries.length > 0) {
const fieldNames: string[] = [];
for (const [key] of errorEntries) {
const matchingQuestion = questions?.find(
(q) =>
q.id === key ||
q.id.endsWith(`.${key}`) ||
key.endsWith(`.${q.id}`) ||
q.id.split(".").pop() === key.split(".").pop(),
);
if (matchingQuestion?.title) {
fieldNames.push(`«${matchingQuestion.title}»`);
} else {
fieldNames.push(`«${key}»`);
}
}
// 1. Direct message / detail / error string from backend
const directMessage = data?.detail || data?.message || data?.error;
if (typeof directMessage === "string" && directMessage.trim()) {
msg = directMessage;
}
if (fieldNames.length > 0) {
const uniqueFieldNames = Array.from(new Set(fieldNames));
const template =
t["Please complete the required fields: {fields}"] ||
"Please complete the required fields: {fields}";
msg = template.replace("{fields}", uniqueFieldNames.join("، "));
}
}
// 2. Non-field errors array
if (!msg && Array.isArray(data?.non_field_errors) && data.non_field_errors.length > 0) {
msg = data.non_field_errors.join(" - ");
}
// 3. Array of error messages
if (!msg && Array.isArray(data?.errors) && data.errors.length > 0) {
msg = data.errors
.map((e: any) => (typeof e === "string" ? e : e?.message || e?.detail || JSON.stringify(e)))
.filter(Boolean)
.join(" - ");
}
// 2. Direct message / detail / error from backend
// 4. Detailed field-level validation errors from backend (data.errors, data.answers, or data directly)
if (!msg) {
const directMessage = data?.detail || data?.message || data?.error;
if (typeof directMessage === "string" && directMessage.trim()) {
msg = directMessage;
const rawFieldErrors =
(data?.errors && typeof data.errors === "object" && !Array.isArray(data.errors) ? data.errors : null) ||
(data?.answers && typeof data.answers === "object" && !Array.isArray(data.answers) ? data.answers : null) ||
(data && typeof data === "object" && !Array.isArray(data) ? data : null);
if (rawFieldErrors) {
const errorEntries = Object.entries(rawFieldErrors).filter(
([k, v]) =>
k !== "status" &&
k !== "code" &&
k !== "status_code" &&
k !== "version" &&
k !== "success" &&
v !== null &&
v !== undefined,
);
if (errorEntries.length > 0) {
const messagesWithFields: string[] = [];
const fieldNamesOnly: string[] = [];
for (const [key, val] of errorEntries) {
const matchingQuestion = questions?.find(
(q) =>
q.id === key ||
q.id.endsWith(`.${key}`) ||
key.endsWith(`.${q.id}`) ||
q.id.split(".").pop() === key.split(".").pop(),
);
const fieldTitle = matchingQuestion?.title
? `«${matchingQuestion.title}»`
: `«${key}»`;
let fieldErrMsg = "";
if (typeof val === "string" && val.trim()) {
fieldErrMsg = val;
} else if (Array.isArray(val) && val.length > 0) {
fieldErrMsg = val
.map((item) =>
typeof item === "string"
? item
: item?.message || JSON.stringify(item),
)
.join("، ");
} else if (typeof val === "object" && val !== null) {
const innerMsg =
(val as any).message ||
(val as any).detail ||
(val as any).error;
if (typeof innerMsg === "string") {
fieldErrMsg = innerMsg;
}
}
if (fieldErrMsg) {
messagesWithFields.push(`${fieldTitle}: ${fieldErrMsg}`);
} else {
fieldNamesOnly.push(fieldTitle);
}
}
if (messagesWithFields.length > 0) {
msg = messagesWithFields.join(" | ");
} else if (fieldNamesOnly.length > 0) {
const uniqueFieldNames = Array.from(new Set(fieldNamesOnly));
const template =
t["Please complete the required fields: {fields}"] ||
"Please complete the required fields: {fields}";
msg = template.replace("{fields}", uniqueFieldNames.join("، "));
}
}
}
}
// 3. Fallback for 400 Bad Request
// 5. Fallback for 400 Bad Request
if (!msg && statusCode === 400) {
msg =
t["Some required fields are missing or invalid. Please check your answers."] ||
"Some required fields are missing or invalid. Please check your answers.";
}
// 4. Fallback for 401 Unauthorized
// 6. Fallback for 401 Unauthorized
if (!msg && statusCode === 401) {
msg =
t["Session expired. Please log in again."] ||
"Session expired. Please log in again.";
}
// 5. Fallback for Network / Server Error
// 7. Fallback for Network / Server Error
if (!msg) {
msg =
t["Internet connection error. Answers could not be synced with server. Please check your connection and tap Continue again."] ||

72
src/components/Componentes/question-sheet.test.tsx

@ -598,5 +598,77 @@ describe("QuestionSheet component", () => {
expect(screen.getByText("صفت ۱, صفت ۲, صفت ۳, صفت ۴, صفت ۵")).toBeDefined();
expect(screen.queryByText(/صفت ۶/)).toBeNull();
});
it("disables unselected options when max 3 limit is reached in QuestionSheet", async () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const multiQuestion = {
id: "personality_traits",
title: "انتخاب خصوصیات اخلاقی",
type: "dropdown",
order: 1,
required: true,
isVisible: true,
description: "",
tooltip: "",
extras: { placeHolder: "انتخاب حداکثر ۳ مورد" },
options: [
{ id: "opt1", value: "opt1", label: "گزینه ۱", order: 1 },
{ id: "opt2", value: "opt2", label: "گزینه ۲", order: 2 },
{ id: "opt3", value: "opt3", label: "گزینه ۳", order: 3 },
{ id: "opt4", value: "opt4", label: "گزینه ۴", order: 4 },
{ id: "opt5", value: "opt5", label: "گزینه ۵", order: 5 },
],
ui_config: { is_multi: true, max_select: 3 },
validation: { max: 3 },
} as QuestionField;
render(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[multiQuestion]}>
<QuestionSheet question={multiQuestion} />
</QuestionAnswersProvider>
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "انتخاب حداکثر ۳ مورد" }));
const btn1 = screen.getByRole("button", { name: "گزینه ۱" });
const btn2 = screen.getByRole("button", { name: "گزینه ۲" });
const btn3 = screen.getByRole("button", { name: "گزینه ۳" });
const btn4 = screen.getByRole("button", { name: "گزینه ۴" });
const btn5 = screen.getByRole("button", { name: "گزینه ۵" });
// Initially none are disabled
expect(btn1).not.toBeDisabled();
expect(btn4).not.toBeDisabled();
// Select 1 and 2
fireEvent.click(btn1);
fireEvent.click(btn2);
expect(btn4).not.toBeDisabled();
// Select 3rd option (reaches limit of 3)
fireEvent.click(btn3);
// Selected buttons remain enabled (so user can deselect)
expect(btn1).not.toBeDisabled();
expect(btn2).not.toBeDisabled();
expect(btn3).not.toBeDisabled();
// Remaining unselected buttons are disabled
expect(btn4).toBeDisabled();
expect(btn5).toBeDisabled();
// Deselect option 2
fireEvent.click(btn2);
// Now buttons 4 and 5 become active again
expect(btn4).not.toBeDisabled();
expect(btn5).not.toBeDisabled();
});
});

87
src/components/Componentes/question-sheet.tsx

@ -70,9 +70,13 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
setIsClosing(true);
if (isMulti) {
const currentList = localSelectedListRef.current;
const trimmedList =
maxSelect && currentList.length > maxSelect
? currentList.slice(0, maxSelect)
: currentList;
setAnswerValue(
question,
currentList.length > 0 ? currentList : null,
trimmedList.length > 0 ? trimmedList : null,
);
}
window.setTimeout(() => {
@ -80,7 +84,7 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
setIsClosing(false);
setSearchQuery("");
}, EXIT_ANIMATION_MS);
}, [isMulti, question, setAnswerValue]);
}, [isMulti, maxSelect, question, setAnswerValue]);
const openSheet = useCallback(() => {
if (disabled) return;
@ -341,9 +345,13 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
const handleConfirmMulti = () => {
const currentList = localSelectedListRef.current;
const trimmedList =
maxSelect && currentList.length > maxSelect
? currentList.slice(0, maxSelect)
: currentList;
setAnswerValue(
question,
currentList.length > 0 ? currentList : null,
trimmedList.length > 0 ? trimmedList : null,
);
closeSheet();
};
@ -449,9 +457,26 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
].join(" ")}
>
<div className="flex items-center justify-between gap-3 px-5 pt-2.5 pb-3 border-b border-[#F2F4F7]">
<h3 className="flex-1 min-w-0 text-[17px] font-bold leading-snug text-[#181818] line-clamp-2 break-words text-start">
{question.title}
</h3>
<div className="flex-1 min-w-0 flex flex-col text-start">
<h3 className="flex-1 text-[17px] font-bold leading-snug text-[#181818] line-clamp-2 break-words text-start">
{question.title}
</h3>
{isMulti && Boolean(maxSelect) && (
<span className="text-xs text-[#667085] mt-0.5">
{localSelectedList.length >= (maxSelect as number)
? (locale === "fa"
? `سقف ${maxSelect} انتخاب تکمیل شد`
: locale === "ar"
? `تم اختيار الحد الأقصى (${maxSelect})`
: `Maximum of ${maxSelect} selected`)
: (locale === "fa"
? `انتخاب حداکثر ${maxSelect} مورد (${localSelectedList.length}/${maxSelect})`
: locale === "ar"
? `اختر حتى ${maxSelect} عناصر (${localSelectedList.length}/${maxSelect})`
: `Select up to ${maxSelect} (${localSelectedList.length}/${maxSelect})`)}
</span>
)}
</div>
<button
type="button"
onClick={closeSheet}
@ -546,11 +571,18 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
? localSelectedList.includes(option.id)
: singleValue === option.id;
const isOptionDisabled =
isMulti &&
!isSelected &&
Boolean(maxSelect && localSelectedList.length >= maxSelect);
return (
<button
key={option.id}
type="button"
disabled={isOptionDisabled}
onClick={() => {
if (isOptionDisabled) return;
if (isMulti) {
toggleMultiOption(option.id);
} else {
@ -558,10 +590,12 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
}
}}
className={[
"flex min-h-[52px] w-full items-start gap-3 rounded-[12px] border px-3.5 py-3 text-start transition-colors cursor-pointer",
isSelected
? "bg-[#FFF4F5] border-[#F0445B]/40 text-[#181818]"
: "bg-white hover:bg-[#F2F4F7] border-[#EAECF0] text-[#181818]",
"flex min-h-[52px] w-full items-start gap-3 rounded-[12px] border px-3.5 py-3 text-start transition-colors",
isOptionDisabled
? "opacity-35 cursor-not-allowed bg-[#F9FAFB] border-[#F2F4F7] text-[#98A2B3] pointer-events-none"
: isSelected
? "bg-[#FFF4F5] border-[#F0445B]/40 text-[#181818] cursor-pointer"
: "bg-white hover:bg-[#F2F4F7] border-[#EAECF0] text-[#181818] cursor-pointer",
].join(" ")}
>
{/* Indicator Icon */}
@ -569,9 +603,11 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
<div
className={[
"size-[20px] shrink-0 rounded-[6px] transition-all duration-150 mt-[2px] flex items-center justify-center",
isSelected
? "bg-[#F0445B] text-white shadow-xs"
: "border-[2px] border-[#98A2B3] bg-white",
isOptionDisabled
? "border-[2px] border-[#D0D5DD] bg-[#F2F4F7]"
: isSelected
? "bg-[#F0445B] text-white shadow-xs"
: "border-[2px] border-[#98A2B3] bg-white",
].join(" ")}
>
{isSelected && (
@ -615,10 +651,23 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
const description = parts.slice(1).join(" - ");
return (
<span className="flex flex-col gap-1 text-start">
<span className="font-bold text-[#181818]">
<span
className={[
"font-bold",
isOptionDisabled
? "text-[#98A2B3]"
: "text-[#181818]",
].join(" ")}
>
{title}
</span>
<ExplanationUiFont>
<ExplanationUiFont
className={
isOptionDisabled
? "text-[#98A2B3]"
: undefined
}
>
{description}
</ExplanationUiFont>
</span>
@ -627,9 +676,11 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
) : (
<span
className={
isSelected
? "font-bold text-[#181818] block"
: "font-semibold text-[#344054] block"
isOptionDisabled
? "font-normal text-[#98A2B3] block"
: isSelected
? "font-bold text-[#181818] block"
: "font-semibold text-[#344054] block"
}
>
{option.label}

48
src/components/Componentes/ui-config.test.tsx

@ -516,7 +516,55 @@ describe("UI Config based behavior", () => {
expect((reenteredCityInput as HTMLInputElement).value).toBe("یزد");
});
});
it("should normalize ui_config for photo and file questions with source and picker_type", async () => {
const { mapBackendQuestionToFrontend } = await import("@/lib/schema-adapter");
const photoBackendQuestion: any = {
id: "verification.face_photo",
type: "photo",
title: "عکس پرسنلی یا واضح",
required: true,
is_visible: true,
ui_config: {},
};
const adaptedPhoto = mapBackendQuestionToFrontend(photoBackendQuestion, 0);
expect(adaptedPhoto.ui_config?.source).toBe("gallery");
expect(adaptedPhoto.ui_config?.picker_type).toBe("gallery");
const fileBackendQuestion: any = {
id: "verification.identity_document",
type: "file",
title: "بارگذاری مدرک هویتی",
required: true,
is_visible: true,
ui_config: {},
};
const adaptedFile = mapBackendQuestionToFrontend(fileBackendQuestion, 1);
expect(adaptedFile.ui_config?.source).toBe("file_system");
expect(adaptedFile.ui_config?.picker_type).toBe("file_system");
});
it("should preserve custom source and picker_type from backend ui_config", async () => {
const { mapBackendQuestionToFrontend } = await import("@/lib/schema-adapter");
const customPhoto: any = {
id: "verification.face_photo",
type: "photo",
title: "عکس سلفی دوربین",
required: true,
is_visible: true,
ui_config: { source: "camera", picker_type: "camera" },
};
const adaptedCustomPhoto = mapBackendQuestionToFrontend(customPhoto, 0);
expect(adaptedCustomPhoto.ui_config?.source).toBe("camera");
expect(adaptedCustomPhoto.ui_config?.picker_type).toBe("camera");
});
});

16
src/hooks/marriage/use-form-schema.ts

@ -167,18 +167,22 @@ export function useFormSchemaQuery<TData = FormSchemaResponse>(
}
export interface SaveAnswersPayload {
answers: Array<{
question_id: string;
value: any;
option_id?: string | string[];
}>;
section_slug?: string;
answers:
| Array<{
question_id: string;
value: any;
option_id?: string | string[];
}>
| Record<string, any>;
version?: number;
}
export async function saveFormAnswers(
formId: string,
payload: SaveAnswersPayload,
): Promise<FormSchemaResponse> {
const { data } = await http.put<FormSchemaResponse>(
const { data } = await http.post<FormSchemaResponse>(
`/api/marriage/forms/${formId}/answers/`,
payload,
);

11
src/lib/schema-adapter.ts

@ -215,6 +215,15 @@ export function mapBackendQuestionToFrontend(
range = [Number(uiRange[0]), Number(uiRange[1])];
}
const uiConfig = { ...(bq.ui_config || {}) };
if (bq.type === "photo") {
if (!uiConfig.source) uiConfig.source = "gallery";
if (!uiConfig.picker_type) uiConfig.picker_type = uiConfig.source || "gallery";
} else if (bq.type === "file") {
if (!uiConfig.source) uiConfig.source = "file_system";
if (!uiConfig.picker_type) uiConfig.picker_type = uiConfig.source || "file_system";
}
return {
id: bq.id,
title: bq.title || "Untitled",
@ -230,7 +239,7 @@ export function mapBackendQuestionToFrontend(
? bq.is_private
: bq.ui_config?.private,
validation: bq.validation,
ui_config: bq.ui_config,
ui_config: uiConfig,
description: bq.description || "",
tooltip: bq.tooltip || "",
extras: {

1
src/lib/webview-actions.ts

@ -118,6 +118,7 @@ export interface UploadFileOptions {
requestId?: string | number;
mediaType: "image" | "video" | "image+video" | "audio" | "file";
source?: "gallery" | "camera" | "any" | "file_system";
picker_type?: "gallery" | "camera" | "any" | "file_system" | string;
multiple?: boolean;
returnAs?: "upload" | "base64";
uploadUrl?: string;

1
src/types/window.d.ts

@ -66,6 +66,7 @@ declare global {
// upload_file
mediaType?: string;
source?: string;
picker_type?: string;
files?: Array<{
url?: string;
path?: string;

Loading…
Cancel
Save