Browse Source

fix question

master
mortezaei 9 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.value).toEqual(["hookah_rarely", "cigarettes_rarely"]);
expect(storedField.option_id).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 === "dropdown" ||
question.type === "radio" || question.type === "radio" ||
question.type === "checkbox" || 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 (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( const selectedOpt = question.options.find(
(opt) => (opt) =>
opt.id === value ||
opt.value === value ||
opt.label === value ||
opt.id === boundedValue ||
opt.value === boundedValue ||
opt.label === boundedValue ||
opt.id.toLowerCase() === strVal || opt.id.toLowerCase() === strVal ||
(typeof opt.value === "string" && opt.value.toLowerCase() === strVal) || (typeof opt.value === "string" && opt.value.toLowerCase() === strVal) ||
(typeof opt.label === "string" && opt.label.toLowerCase() === strVal) || (typeof opt.label === "string" && opt.label.toLowerCase() === strVal) ||
opt.id.toLowerCase().endsWith(`.${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, key,
label: question.title, label: question.title,
type: question.type, type: question.type,
value,
value: boundedValue,
private: question.private, private: question.private,
option_id: isChoiceType ? option_id : undefined, option_id: isChoiceType ? option_id : undefined,
} as MarriageField; } 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) => { const toggleOption = (optionId: string) => {
let nextValue: string[]; let nextValue: string[];
if (value.includes(optionId)) { if (value.includes(optionId)) {
nextValue = value.filter((v) => v !== optionId); nextValue = value.filter((v) => v !== optionId);
} else { } else {
if (maxSelect && value.length >= maxSelect) {
return;
}
nextValue = [...value, optionId]; nextValue = [...value, optionId];
} }
@ -137,27 +144,37 @@ export function QuestionCheckbox({
{options.map((option) => { {options.map((option) => {
const optionId = `checkbox-${question.id}-${option.id}`; const optionId = `checkbox-${question.id}-${option.id}`;
const isSelected = value.includes(option.id); const isSelected = value.includes(option.id);
const isOptionDisabled =
!isSelected && Boolean(maxSelect && value.length >= maxSelect);
return ( return (
<label <label
key={option.id} key={option.id}
htmlFor={optionId} htmlFor={optionId}
className={[ 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 isShortOptions
? "flex-1 min-w-[90px] px-5 py-3.5 text-center justify-center" ? "flex-1 min-w-[90px] px-5 py-3.5 text-center justify-center"
: "w-full px-5 py-4 text-start leading-snug", : "w-full px-5 py-4 text-start leading-snug",
isSelected isSelected
? "bg-[#F0445B] text-white border-transparent shadow-none" ? "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(" ")} ].join(" ")}
> >
<input <input
type="checkbox" type="checkbox"
id={optionId} id={optionId}
checked={isSelected} checked={isSelected}
disabled={disabled}
onChange={() => toggleOption(option.id)}
disabled={disabled || isOptionDisabled}
onChange={() => {
if (isOptionDisabled) return;
toggleOption(option.id);
}}
className="sr-only" className="sr-only"
/> />
{!isShortOptions && ( {!isShortOptions && (

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

@ -248,11 +248,18 @@ export function QuestionDropdown({
? selectedList.includes(option.id) ? selectedList.includes(option.id)
: singleValue === option.id; : singleValue === option.id;
const isOptionDisabled =
isMulti &&
!isSelected &&
Boolean(maxSelect && selectedList.length >= maxSelect);
return ( return (
<button <button
key={option.id} key={option.id}
type="button" type="button"
disabled={isOptionDisabled}
onClick={() => { onClick={() => {
if (isOptionDisabled) return;
if (isMulti) { if (isMulti) {
toggleMultiOption(option.id); toggleMultiOption(option.id);
} else { } else {
@ -260,7 +267,12 @@ export function QuestionDropdown({
setIsOpen(false); 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 */} {/* Option Indicator Icon */}
{isMulti ? ( {isMulti ? (
@ -268,9 +280,11 @@ export function QuestionDropdown({
<div <div
className={[ className={[
"size-[20px] shrink-0 rounded-[6px] transition-all duration-150 mt-0.5", "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(" ")} ].join(" ")}
/> />
) : ( ) : (

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

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

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

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

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

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

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

@ -106,59 +106,116 @@ function SectionFlowContent({
let msg = ""; 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) { 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) { if (!msg && statusCode === 400) {
msg = msg =
t["Some required fields are missing or invalid. Please check your answers."] || t["Some required fields are missing or invalid. Please check your answers."] ||
"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) { if (!msg && statusCode === 401) {
msg = msg =
t["Session expired. Please log in again."] || t["Session expired. Please log in again."] ||
"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) { if (!msg) {
msg = msg =
t["Internet connection error. Answers could not be synced with server. Please check your connection and tap Continue again."] || 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.getByText("صفت ۱, صفت ۲, صفت ۳, صفت ۴, صفت ۵")).toBeDefined();
expect(screen.queryByText(/صفت ۶/)).toBeNull(); 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); setIsClosing(true);
if (isMulti) { if (isMulti) {
const currentList = localSelectedListRef.current; const currentList = localSelectedListRef.current;
const trimmedList =
maxSelect && currentList.length > maxSelect
? currentList.slice(0, maxSelect)
: currentList;
setAnswerValue( setAnswerValue(
question, question,
currentList.length > 0 ? currentList : null,
trimmedList.length > 0 ? trimmedList : null,
); );
} }
window.setTimeout(() => { window.setTimeout(() => {
@ -80,7 +84,7 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
setIsClosing(false); setIsClosing(false);
setSearchQuery(""); setSearchQuery("");
}, EXIT_ANIMATION_MS); }, EXIT_ANIMATION_MS);
}, [isMulti, question, setAnswerValue]);
}, [isMulti, maxSelect, question, setAnswerValue]);
const openSheet = useCallback(() => { const openSheet = useCallback(() => {
if (disabled) return; if (disabled) return;
@ -341,9 +345,13 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
const handleConfirmMulti = () => { const handleConfirmMulti = () => {
const currentList = localSelectedListRef.current; const currentList = localSelectedListRef.current;
const trimmedList =
maxSelect && currentList.length > maxSelect
? currentList.slice(0, maxSelect)
: currentList;
setAnswerValue( setAnswerValue(
question, question,
currentList.length > 0 ? currentList : null,
trimmedList.length > 0 ? trimmedList : null,
); );
closeSheet(); closeSheet();
}; };
@ -449,9 +457,26 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
].join(" ")} ].join(" ")}
> >
<div className="flex items-center justify-between gap-3 px-5 pt-2.5 pb-3 border-b border-[#F2F4F7]"> <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 <button
type="button" type="button"
onClick={closeSheet} onClick={closeSheet}
@ -546,11 +571,18 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
? localSelectedList.includes(option.id) ? localSelectedList.includes(option.id)
: singleValue === option.id; : singleValue === option.id;
const isOptionDisabled =
isMulti &&
!isSelected &&
Boolean(maxSelect && localSelectedList.length >= maxSelect);
return ( return (
<button <button
key={option.id} key={option.id}
type="button" type="button"
disabled={isOptionDisabled}
onClick={() => { onClick={() => {
if (isOptionDisabled) return;
if (isMulti) { if (isMulti) {
toggleMultiOption(option.id); toggleMultiOption(option.id);
} else { } else {
@ -558,10 +590,12 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
} }
}} }}
className={[ 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(" ")} ].join(" ")}
> >
{/* Indicator Icon */} {/* Indicator Icon */}
@ -569,9 +603,11 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
<div <div
className={[ className={[
"size-[20px] shrink-0 rounded-[6px] transition-all duration-150 mt-[2px] flex items-center justify-center", "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(" ")} ].join(" ")}
> >
{isSelected && ( {isSelected && (
@ -615,10 +651,23 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
const description = parts.slice(1).join(" - "); const description = parts.slice(1).join(" - ");
return ( return (
<span className="flex flex-col gap-1 text-start"> <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} {title}
</span> </span>
<ExplanationUiFont>
<ExplanationUiFont
className={
isOptionDisabled
? "text-[#98A2B3]"
: undefined
}
>
{description} {description}
</ExplanationUiFont> </ExplanationUiFont>
</span> </span>
@ -627,9 +676,11 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
) : ( ) : (
<span <span
className={ 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} {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("یزد"); 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 { 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( export async function saveFormAnswers(
formId: string, formId: string,
payload: SaveAnswersPayload, payload: SaveAnswersPayload,
): Promise<FormSchemaResponse> { ): Promise<FormSchemaResponse> {
const { data } = await http.put<FormSchemaResponse>(
const { data } = await http.post<FormSchemaResponse>(
`/api/marriage/forms/${formId}/answers/`, `/api/marriage/forms/${formId}/answers/`,
payload, payload,
); );

11
src/lib/schema-adapter.ts

@ -215,6 +215,15 @@ export function mapBackendQuestionToFrontend(
range = [Number(uiRange[0]), Number(uiRange[1])]; 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 { return {
id: bq.id, id: bq.id,
title: bq.title || "Untitled", title: bq.title || "Untitled",
@ -230,7 +239,7 @@ export function mapBackendQuestionToFrontend(
? bq.is_private ? bq.is_private
: bq.ui_config?.private, : bq.ui_config?.private,
validation: bq.validation, validation: bq.validation,
ui_config: bq.ui_config,
ui_config: uiConfig,
description: bq.description || "", description: bq.description || "",
tooltip: bq.tooltip || "", tooltip: bq.tooltip || "",
extras: { extras: {

1
src/lib/webview-actions.ts

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

1
src/types/window.d.ts

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

Loading…
Cancel
Save