diff --git a/src/components/Questionnaire/QuestionTypes/QuantityQuestion.tsx b/src/components/Questionnaire/QuestionTypes/QuantityQuestion.tsx
index 4a6bbf56d15..a357c2ce3e5 100644
--- a/src/components/Questionnaire/QuestionTypes/QuantityQuestion.tsx
+++ b/src/components/Questionnaire/QuestionTypes/QuantityQuestion.tsx
@@ -97,7 +97,8 @@ export const QuantityQuestion = memo(function QuantityQuestion({
diff --git a/src/components/Questionnaire/QuestionnaireEditor.tsx b/src/components/Questionnaire/QuestionnaireEditor.tsx
deleted file mode 100644
index 72c29173dff..00000000000
--- a/src/components/Questionnaire/QuestionnaireEditor.tsx
+++ /dev/null
@@ -1,3258 +0,0 @@
-import { zodResolver } from "@hookform/resolvers/zod";
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import {
- AArrowDown,
- AlertTriangle,
- ChevronDown,
- ChevronUp,
- ChevronsDownUp,
- ChevronsUpDown,
- SquarePenIcon,
- ViewIcon,
-} from "lucide-react";
-import { useNavigate } from "raviger";
-import { useEffect, useMemo, useRef, useState } from "react";
-import { useForm, useWatch } from "react-hook-form";
-import { useTranslation } from "react-i18next";
-import { toast } from "sonner";
-import * as z from "zod";
-
-import { cn } from "@/lib/utils";
-
-import CareIcon from "@/CAREUI/icons/CareIcon";
-
-import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
-import { Badge } from "@/components/ui/badge";
-import { Button } from "@/components/ui/button";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { Checkbox } from "@/components/ui/checkbox";
-import {
- Collapsible,
- CollapsibleContent,
- CollapsibleTrigger,
-} from "@/components/ui/collapsible";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-import { EmptyState } from "@/components/ui/empty-state";
-import {
- Form,
- FormControl,
- FormDescription,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover";
-import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { Switch } from "@/components/ui/switch";
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
-import { Textarea } from "@/components/ui/textarea";
-
-import { AnimatedWrapper } from "@/components/Common/AnimatedWrapper";
-import { DebugPreview } from "@/components/Common/DebugPreview";
-import Loading from "@/components/Common/Loading";
-import { ScrollToTopButton } from "@/components/Common/ScrollToTop";
-import {
- STRUCTURED_QUESTIONS,
- StructuredQuestionType,
-} from "@/components/Questionnaire/data/StructuredFormData";
-
-import useBreakpoints from "@/hooks/useBreakpoints";
-import useDragAndDrop from "@/hooks/useDragAndDrop";
-
-import mutate from "@/Utils/request/mutate";
-import query from "@/Utils/request/query";
-import { HTTPError } from "@/Utils/request/types";
-import { swapElements } from "@/Utils/request/utils";
-import organizationApi from "@/types/organization/organizationApi";
-import {
- AnswerOption,
- EnableWhen,
- Question,
- QuestionType,
- SUPPORTED_QUESTION_TYPES,
- TemplateConfig,
-} from "@/types/questionnaire/question";
-import { QuestionnaireRead } from "@/types/questionnaire/questionnaire";
-import questionnaireApi from "@/types/questionnaire/questionnaireApi";
-
-import { generateSlug } from "@/Utils/utils";
-import { CodingEditor } from "./CodingEditor";
-import { QuestionActions } from "./QuestionActions";
-import { QuestionnaireForm } from "./QuestionnaireForm";
-import { QuestionnaireProperties } from "./QuestionnaireProperties";
-import { SelectOrCreateValueset } from "./SelectOrCreateValueset";
-import ValueSetSelect from "./ValueSetSelect";
-import { scrollToQuestion } from "./utils";
-
-interface QuestionnaireEditorProps {
- slug?: string;
-}
-interface Organization {
- id: string;
- name: string;
- description?: string;
-}
-
-const LAYOUT_OPTIONS = [
- {
- id: "full-width",
- value: "grid grid-cols-1",
- label: "Full Width",
- preview: (
-
- ),
- },
- {
- id: "equal-split",
- value: "grid grid-cols-2",
- label: "Equal Split",
- preview: (
-
- ),
- },
- {
- id: "wide-start",
- value: "grid grid-cols-[2fr_1fr]",
- label: "Wide Start",
- preview: (
-
- ),
- },
- {
- id: "wide-end",
- value: "grid grid-cols-[1fr_2fr]",
- label: "Wide End",
- preview: (
-
- ),
- },
-] as const;
-
-interface LayoutOptionProps {
- option: (typeof LAYOUT_OPTIONS)[number];
- isSelected: boolean;
- questionId: string;
-}
-
-function LayoutOptionCard({
- option,
- isSelected,
- questionId,
-}: LayoutOptionProps) {
- const optionId = `${questionId}-${option.id}`;
- return (
-
-
-
-
- );
-}
-
-const HIDE_REPEATABLE_QUESTION_TYPES = [
- "boolean",
- "group",
- "display",
- "structured",
-];
-
-function findFirstErrorPath(errors: any, path: number[] = []): number[] | null {
- for (let i = 0; i < errors.length; i++) {
- const current = errors[i];
- const currentPath = [...path, i];
-
- if (current && typeof current === "object") {
- const hasOwnErrors = Object.entries(current).some(([key, value]) => {
- // Ignore nested question arrays (they will be traversed separately)
- if (key === "questions" && Array.isArray(value)) return false;
-
- // Any defined value (including objects holding a "message") indicates an error on the current node
- return value !== undefined;
- });
-
- if (hasOwnErrors) {
- return currentPath;
- }
-
- if (Array.isArray(current.questions)) {
- const subPath = findFirstErrorPath(current.questions, currentPath);
- if (subPath) return subPath;
- }
- }
- }
-
- return null;
-}
-
-export default function QuestionnaireEditor({
- slug,
-}: QuestionnaireEditorProps) {
- const navigate = useNavigate();
- const { t } = useTranslation();
- const [activeTab, setActiveTab] = useState<"edit" | "preview">("edit");
- const [expandedQuestions, setExpandedQuestions] = useState
>(
- new Set(),
- );
- const [selectedOrgs, setSelectedOrgs] = useState([]);
- const [orgSearchQuery, setOrgSearchQuery] = useState("");
- const [orgError, setOrgError] = useState();
- const [importUrl, setImportUrl] = useState("");
- const [showImportDialog, setShowImportDialog] = useState(false);
- const [showFileImportDialog, setShowFileImportDialog] = useState(false);
- const [selectedQuestions, setSelectedQuestions] = useState>(
- new Set(),
- );
- const [selectedImportFile, setSelectedImportFile] = useState(
- null,
- );
- const [importedData, setImportedData] = useState(
- null,
- );
- const queryClient = useQueryClient();
- const [structuredTypeErrors, setStructuredTypeErrors] = useState<
- Record
- >({});
- const { dragOver, onDragOver, onDragLeave } = useDragAndDrop();
- const [enableWhenDependencies, setEnableWhenDependencies] = useState<
- Map>
- >(new Map());
- const [expandPath, setExpandPath] = useState([]);
- const questionRefs = useRef<{ [key: string]: HTMLDivElement | null }>({});
-
- const isMobile = useBreakpoints({ default: true, md: false });
-
- const handleOnErrors = (error: HTTPError, fallbackMessage: string) => {
- const errorData = (
- error as {
- cause?: { errors: { msg: string; loc?: (string | number)[] }[] };
- }
- )?.cause;
-
- if (!errorData?.errors) {
- toast.error(fallbackMessage);
- return;
- }
-
- errorData.errors.forEach((er) => {
- let fieldPath = er.loc?.join(" > ");
- if (er.loc?.includes("questions")) {
- const questionIndices: number[] = [];
-
- for (let i = 0; i < er.loc.length; i++) {
- if (er.loc[i] === "questions" && typeof er.loc[i + 1] === "number") {
- questionIndices.push(Number(er.loc[i + 1]) + 1);
- }
- }
-
- if (questionIndices.length > 0) {
- fieldPath = `Question ${questionIndices.join(".")}`;
- }
- }
-
- const message = fieldPath ? `Error in ${fieldPath}: ${er.msg}` : er.msg;
- toast.error(message);
- });
-
- toast.error(fallbackMessage);
- };
-
- const {
- data: initialQuestionnaire,
- isLoading,
- error,
- } = useQuery({
- queryKey: ["questionnaireDetail", slug],
- queryFn: query(questionnaireApi.get, {
- pathParams: { slug: slug! },
- }),
- enabled: !!slug,
- });
-
- const { data: organizations } = useQuery({
- queryKey: ["questionnaire", slug, "organizations"],
- queryFn: query(questionnaireApi.getOrganizations, {
- pathParams: { slug: slug! },
- }),
- enabled: !!slug,
- });
-
- const {
- data: availableOrganizations,
- isLoading: isLoadingAvailableOrganizations,
- } = useQuery({
- queryKey: ["organizations", orgSearchQuery],
- queryFn: query(organizationApi.list, {
- queryParams: {
- org_type: "role",
- name: orgSearchQuery || undefined,
- },
- }),
- });
-
- const { mutate: createQuestionnaire, isPending: isCreating } = useMutation({
- mutationFn: mutate(questionnaireApi.create, {
- silent: true,
- }),
- onSuccess: (data: QuestionnaireRead) => {
- toast.success(t("questionnaire_created_successfully"));
- queryClient.invalidateQueries({
- queryKey: ["questionnaireDetail", data.slug],
- });
- navigate(`/admin/questionnaire/${data.slug}/edit`);
- },
- onError: (error) =>
- handleOnErrors(error, t("failed_to_create_questionnaire")),
- });
-
- const { mutate: updateQuestionnaire, isPending: isUpdating } = useMutation({
- mutationFn: mutate(questionnaireApi.update, {
- pathParams: { slug: slug! },
- silent: true,
- }),
- onSuccess: (data: QuestionnaireRead) => {
- toast.success(t("questionnaire_updated_successfully"));
- navigate(`/admin/questionnaire/${data.slug}/edit`);
- queryClient.invalidateQueries({
- queryKey: ["questionnaireDetail", data.slug],
- });
- },
- onError: (error) =>
- handleOnErrors(error, t("failed_to_update_questionnaire")),
- });
-
- const { mutate: importQuestionnaire, isPending: isImporting } = useMutation({
- mutationFn: async (url: string) => {
- const response = await fetch(url);
- if (!response.ok) throw new Error("Failed to fetch questionnaire");
- return response.json();
- },
- onSuccess: (data) => {
- setImportedData(data);
- toast.success(t("questionnaire_imported_successfully"));
- },
- onError: () => {
- toast.error(t("failed_to_import_questionnaire"));
- },
- });
-
- const urlSchema = z.url(t("please enter a valid url"));
-
- const QuestionnaireFormPartialSchema = z.object({
- title: z.string().trim().min(1, t("field_required")),
- slug: z
- .string()
- .trim()
- .min(5, t("character_count_validation", { min: 5, max: 25 }))
- .max(25, t("character_count_validation", { min: 5, max: 25 }))
- .regex(/^[-\w]+$/, {
- message: t("slug_format_message"),
- }),
- description: z.string().optional(),
- questions: z.array(
- z.object({
- text: z.string().trim().min(1, t("field_required")),
- link_id: z.string().trim().min(1, t("field_required")),
- description: z.string().optional(),
- code: z
- .object({
- system: z.string().optional(),
- code: z.string().optional(),
- display: z.string().optional(),
- })
- .optional(),
-
- unit: z
- .object({
- system: z.string().optional(),
- code: z.string().optional(),
- display: z.string().optional(),
- })
- .optional(),
- }),
- ),
- });
-
- const [questionnaire, setQuestionnaire] = useState(
- () => {
- if (!slug) {
- return {
- id: "",
- title: "",
- description: "",
- status: "draft",
- version: "1.0",
- subject_type: "encounter",
- questions: [],
- slug: "",
- };
- }
- return null;
- },
- );
-
- const form = useForm({
- resolver: zodResolver(QuestionnaireFormPartialSchema),
- defaultValues: {
- title: questionnaire?.title ?? "",
- slug: questionnaire?.slug ?? "",
- description: questionnaire?.description ?? "",
- questions: questionnaire?.questions,
- status: questionnaire?.status,
- subject_type: questionnaire?.subject_type,
- version: questionnaire?.version,
- },
- mode: "onChange",
- });
-
- const { isDirty } = form.formState;
-
- useEffect(() => {
- if (initialQuestionnaire) {
- const formValues = {
- title: initialQuestionnaire.title || "",
- slug: initialQuestionnaire.slug || "",
- description: initialQuestionnaire.description || "",
- questions: initialQuestionnaire.questions,
- status: initialQuestionnaire.status,
- subject_type: initialQuestionnaire.subject_type,
- version: initialQuestionnaire.version,
- };
-
- setQuestionnaire(initialQuestionnaire);
- form.reset(formValues);
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [initialQuestionnaire]);
-
- const handleToggleSelection = (questionId: string) => {
- setSelectedQuestions((prev) => {
- const next = new Set(prev);
- if (next.has(questionId)) {
- next.delete(questionId);
- } else {
- next.add(questionId);
- }
- return next;
- });
- };
- const rootQuestions: Question[] = useWatch({
- control: form.control,
- name: "questions",
- });
-
- useEffect(() => {
- if (!rootQuestions) return;
- const newEnableWhenDependencies = new Map<
- string,
- Set<{ question: Question; path: string[] }>
- >();
- const processQuestions = (
- questions: Question[],
- currentPath: string[] = [],
- ) => {
- questions.forEach((question) => {
- question.enable_when?.forEach(({ question: dependentQuestionId }) => {
- const deps =
- newEnableWhenDependencies.get(dependentQuestionId) || new Set();
- deps.add({
- question: question,
- path: [...currentPath, question.link_id],
- });
- newEnableWhenDependencies.set(dependentQuestionId, deps);
- });
- if (question.questions?.length) {
- processQuestions(question.questions, [
- ...currentPath,
- question.link_id,
- ]);
- }
- });
- };
-
- processQuestions(rootQuestions);
- setEnableWhenDependencies(newEnableWhenDependencies);
- }, [rootQuestions]);
-
- const handleEnableWhenDependentClick = (path: string[], targetId: string) => {
- const rootQuestionId = path[0];
- toggleQuestionExpanded(rootQuestionId, false);
- setExpandPath(path.slice(1));
- setTimeout(() => {
- const element = document.getElementById(`question-${targetId}`);
- if (element) element.scrollIntoView();
- setExpandPath([]);
- }, 100);
- };
-
- if (slug && isLoading) return ;
-
- if (error) {
- return (
-
-
- {t("error")}
- {t("questionniare_load_error")}
-
- );
- }
- if (!questionnaire) {
- return (
-
-
- {t("not_found")}
-
- {t("no_requested_questionnaires_found")}
-
-
- );
- }
-
- const updateQuestionnaireField = (
- field: keyof QuestionnaireRead,
- value: unknown,
- ) => {
- form.setValue(field, value, {
- shouldValidate: true,
- shouldDirty: true,
- shouldTouch: true,
- });
- };
- const handleValidatedChange = (
- field: keyof QuestionnaireRead,
- value: QuestionnaireRead[keyof QuestionnaireRead],
- ) => {
- let finalValue = value;
- if (field === "slug" && typeof value === "string") {
- finalValue = value.toLowerCase().replace(/[^a-z0-9_-]/g, "");
- }
-
- form.setValue(field as "title" | "description" | "slug", finalValue, {
- shouldValidate: true,
- shouldDirty: true,
- });
-
- if (field === "title") {
- const next = generateSlug((value as string) || "", 25);
- form.setValue("slug", next, {
- shouldValidate: true,
- shouldDirty: false,
- });
- }
- };
-
- const updateQuestions = (newQuestions: Question[]) => {
- form.setValue("questions", newQuestions, {
- shouldValidate: true,
- shouldDirty: true,
- });
- };
-
- const validateOrganizations = (): boolean => {
- if (slug) {
- if (!organizations?.results || organizations.results.length === 0) {
- setOrgError(t("organization_selection_required"));
- return false;
- }
- return true;
- }
- if (selectedOrgs.length === 0) {
- setOrgError(t("organization_selection_required"));
- return false;
- }
- setOrgError(undefined);
- return true;
- };
-
- const validateStructuredType = (): boolean => {
- let hasError = false;
- const updatedErrors: Record = {};
-
- rootQuestions.forEach((q) => {
- if (q.type === "structured" && !q.structured_type) {
- updatedErrors[q.id] = t("field_required");
- hasError = true;
- } else {
- updatedErrors[q.id] = undefined;
- }
- });
-
- setStructuredTypeErrors(updatedErrors);
-
- return !hasError;
- };
-
- const handleSave = async () => {
- let isValid = await form.trigger();
- const hasOrganizations = validateOrganizations();
- const hasValidStructuredType = validateStructuredType();
-
- const validateQuestions = (questions: Question[], path = "questions") => {
- questions.forEach((question, idx) => {
- const currentPath = `${path}.${idx}`;
-
- if (question.code && !question.code?.display) {
- form.setError(`${currentPath}.code.display`, {
- type: "manual",
- message: t("code_verification_required"),
- });
- isValid = false;
- }
-
- if (question.type === "group" && Array.isArray(question.questions)) {
- validateQuestions(question.questions, `${currentPath}.questions`);
- if (question.questions.length === 0) {
- form.setError(`${currentPath}.questions`, {
- type: "manual",
- message: t("group_must_have_sub_questions"),
- });
- isValid = false;
- }
- }
- });
- };
- validateQuestions(rootQuestions);
-
- if (!isValid || !hasOrganizations || !hasValidStructuredType) {
- setTimeout(() => {
- const errorEntries = Object.entries(form.formState.errors);
-
- for (const [fieldName, error] of errorEntries) {
- if (fieldName !== "questions") {
- const el = document.querySelector(`[name="${fieldName}"]`);
- if (el) {
- el.scrollIntoView();
- break;
- }
- } else {
- const errorPath = findFirstErrorPath(error);
- if (errorPath) {
- // Expand parent groups
- for (let i = 0; i < errorPath.length; i++) {
- const question = getQuestionByPath(
- rootQuestions,
- errorPath.slice(0, i + 1),
- );
- if (question?.link_id) {
- setExpandedQuestions((prev) =>
- new Set(prev).add(question.link_id),
- );
- }
- }
-
- // After expanding, scroll to the error question
- setTimeout(() => {
- const errorQuestion = getQuestionByPath(
- rootQuestions,
- errorPath,
- );
- if (
- errorQuestion?.link_id &&
- questionRefs.current[errorQuestion.link_id]
- ) {
- questionRefs.current[errorQuestion.link_id]?.scrollIntoView();
- }
- }, 200);
- }
- }
- }
- }, 0); // delay lets react-hook-form update `formState.errors`
- return;
- }
-
- if (slug) {
- updateQuestionnaire({
- ...form.getValues(),
- version: String(questionnaire.version), //TODO: remove when backend is fixed
- questions: rootQuestions,
- });
- } else {
- createQuestionnaire({
- ...form.getValues(),
- questions: rootQuestions,
- organizations: selectedOrgs.map((o) => o.id),
- });
- }
- };
-
- const handleCancel = () => {
- navigate("/admin/questionnaire");
- };
-
- const handleDownload = () => {
- const dataStr = JSON.stringify(form.getValues(), null, 2);
- const dataUri = `data:application/json;charset=utf-8,${encodeURIComponent(dataStr)}`;
- const exportFileDefaultName = `${form.getValues("slug") || "questionnaire"}.json`;
-
- const linkElement = document.createElement("a");
- linkElement.setAttribute("href", dataUri);
- linkElement.setAttribute("download", exportFileDefaultName);
- linkElement.click();
- };
-
- const handleImport = async () => {
- if (!importUrl) {
- toast.error(t("url_required"));
- return;
- }
-
- try {
- urlSchema.parse(importUrl);
- importQuestionnaire(importUrl);
- } catch (error) {
- if (error instanceof z.ZodError) {
- toast.error(error.issues[0].message);
- }
- }
- };
-
- const handleImportConfirm = () => {
- if (!importedData) return;
-
- // Map only the necessary fields, ignoring id, created_by, tags etc.
- const mappedData: Partial = {
- title: importedData.title,
- description: importedData.description,
- status: importedData.status,
- version: "1.0",
- subject_type: importedData.subject_type || "encounter",
- questions:
- importedData.questions?.map((q: Question) => ({
- ...q,
- id: crypto.randomUUID(), // Generate new IDs for questions
- questions: q.questions?.map((sq: Question) => ({
- ...sq,
- id: crypto.randomUUID(), // Generate new IDs for sub-questions
- })),
- })) || [],
- slug: importedData.slug,
- };
-
- setQuestionnaire({
- ...form.getValues(),
- ...mappedData,
- });
-
- form.reset({
- title: mappedData.title || "",
- slug: mappedData.slug || "",
- description: mappedData.description || "",
- status: mappedData.status || "draft",
- version: mappedData.version || "1.0",
- subject_type: mappedData.subject_type || "encounter",
- });
- updateQuestions(mappedData.questions || []);
-
- form.trigger();
-
- setShowImportDialog(false);
- setImportUrl("");
- setImportedData(null);
- toast.success(t("questionnaire_imported_successfully"));
- };
-
- const toggleQuestionExpanded = (
- questionLinkId: string,
- allowCollapse: boolean = true,
- ) => {
- setExpandedQuestions((prev) => {
- const next = new Set(prev);
- if (next.has(questionLinkId) && allowCollapse) {
- next.delete(questionLinkId);
- } else {
- next.add(questionLinkId);
- }
- return next;
- });
- };
-
- const handleToggleOrganization = (orgId: string) => {
- const newOrg = availableOrganizations?.results.find((o) => o.id === orgId);
- setSelectedOrgs((current) => {
- const newSelection = current.some((o) => o.id === orgId)
- ? current.filter((o) => o.id !== orgId)
- : newOrg
- ? [...current, newOrg]
- : current;
-
- // Clear error if at least one organization is selected
- if (newSelection.length > 0) {
- setOrgError(undefined);
- }
-
- return newSelection;
- });
- };
-
- const handleAddQuestionAtIndex = (index: number) => {
- const newQuestion: Question = {
- id: crypto.randomUUID(),
- link_id: `Q-${Date.now()}`,
- text: "New Question",
- type: "string",
- questions: [],
- };
- const newQuestions = [
- ...rootQuestions.slice(0, index),
- newQuestion,
- ...rootQuestions.slice(index),
- ];
- updateQuestions(newQuestions);
- setExpandedQuestions((prev) => new Set([...prev, newQuestion.link_id]));
- setTimeout(() => {
- scrollToQuestion(newQuestion.link_id);
- }, 100);
- };
-
- const handleAddQuestion = (e: React.MouseEvent) => {
- e.preventDefault();
- handleAddQuestionAtIndex(rootQuestions.length);
- };
-
- return (
-
-
-
-
-
- {slug
- ? t("edit") + " " + form.watch("title")
- : t("create_questionnaire")}
-
-
{form.watch("description")}
-
-
-
- {slug && (
-
- )}
- {!slug && (
-
-
-
-
-
- setShowImportDialog(true)}>
-
- {t("import_from_url")}
-
- setShowFileImportDialog(true)}>
-
- {t("import_from_file")}
-
-
-
- )}
-
-
-
-
-
setActiveTab(v as "edit" | "preview")}
- >
-
-
-
- {t("edit_form")}
-
-
-
- {t("form_preview")}
-
-
-
-
-
-
- {t("navigation")}
-
-
-
-
-
- {isMobile && (
-
-
-
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
- {t("preview")}
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-type OptionFieldsProps = {
- opt: AnswerOption;
- idx: number;
- annotatedAnswerOptions: AnswerOption[];
- updateField: (
- field: K,
- value: Question[K],
- additionalFields?: Partial,
- ) => void;
-};
-
-const OptionFields = ({
- opt,
- idx,
- annotatedAnswerOptions,
- updateField,
-}: OptionFieldsProps) => {
- const { t } = useTranslation();
- const [inputPosition, setInputPosition] = useState("");
- return (
- <>
-
- {
- const newOptions = [...annotatedAnswerOptions];
- newOptions[idx] = {
- ...opt,
- value: e.target.value,
- };
- updateField("answer_option", newOptions);
- }}
- placeholder={t("option_value")}
- />
-
-
- {
- const newOptions = [...annotatedAnswerOptions];
- newOptions[idx] = {
- ...opt,
- display: e.target.value,
- };
- updateField("answer_option", newOptions);
- }}
- placeholder={t("display_text_placeholder")}
- />
-
-
-
-
-
-
-
-
-
-
-
- {t("move_item")}
-
-
- {t("position")} {inputPosition ? inputPosition : idx + 1}
-
-
-
-
- {t("quick_actions")}
-
-
-
-
-
-
-
-
-
-
- {t("move_to_specific_position")}
-
-
- setInputPosition(e.target.value)}
- placeholder={t("enter_position")}
- />
-
-
-
- {t("range")}: {1} {t("to")}
- {annotatedAnswerOptions.length}
-
-
-
-
-
-
-
-
-
- >
- );
-};
-
-interface QuestionEditorProps {
- name: string;
- form: ReturnType>;
- index: number;
- question: Question;
- onChange: (updated: Question) => void;
- onDelete: () => void;
- addQuestionAtIndex?: (targetIndex: number) => void;
- isExpanded: boolean;
- onToggleExpand: () => void;
- depth: number;
- parentId?: string;
- onMoveUp?: () => void;
- onMoveDown?: () => void;
- isFirst?: boolean;
- isLast?: boolean;
- structuredTypeError?: string;
- setStructuredTypeError?: (error: string | undefined) => void;
- onToggleSelection: (id: string) => void;
- selectedQuestions: Set;
- enableWhenDependencies: Map<
- string,
- Set<{ question: Question; path: string[] }>
- >;
- handleEnableWhenDependentClick: (path: string[], targetId: string) => void;
- expandPath?: string[];
- questionRefs: React.RefObject<{ [key: string]: HTMLDivElement | null }>;
- totalSiblings?: number;
-}
-
-function QuestionEditor({
- name,
- form,
- question,
- onChange,
- onDelete,
- addQuestionAtIndex,
- isExpanded,
- onToggleExpand,
- depth,
- parentId,
- onMoveUp,
- onMoveDown,
- isFirst,
- isLast,
- index,
- structuredTypeError,
- setStructuredTypeError,
- onToggleSelection,
- selectedQuestions,
- enableWhenDependencies,
- handleEnableWhenDependentClick,
- expandPath,
- questionRefs,
- totalSiblings,
-}: QuestionEditorProps): React.ReactElement {
- const { t } = useTranslation();
- const {
- text,
- type,
- structured_type,
- required,
- repeats,
- answer_option,
- questions,
- code,
- unit,
- } = question;
-
- const rootQuestions = useWatch({
- control: form.control,
- name: "questions",
- }) as Question[];
- // Memoize answer options to ensure unique IDs to avoid unnecessary re-renders in value field of AnwserOption
-
- const annotatedAnswerOptions = useMemo(() => {
- return (
- answer_option?.map((option: any) => ({
- ...option,
- _id: option._id || crypto.randomUUID(),
- })) || []
- );
- }, [answer_option]);
-
- const [expandedSubQuestions, setExpandedSubQuestions] = useState>(
- new Set(),
- );
- const [enableWhenQuestionAnswers, setEnableWhenQuestionAnswers] = useState<
- Record
- >({});
-
- const updateField = (
- field: K,
- value: Question[K],
- additionalFields?: Partial,
- ) => {
- onChange({ ...question, [field]: value, ...additionalFields });
- };
-
- // Clear structured type if not structured, voluntarily doing this way, so that
- // form is made dirty and user's can simply open and save the form to clear the error.
- useEffect(() => {
- if (question.structured_type && question.type !== "structured") {
- updateField("structured_type", undefined);
- }
- }, [question.structured_type, question.type]);
-
- const toggleSubQuestionExpanded = (
- questionLinkId: string,
- allowCollapse: boolean = true,
- ) => {
- setExpandedSubQuestions((prev) => {
- const next = new Set(prev);
- if (next.has(questionLinkId) && allowCollapse) {
- next.delete(questionLinkId);
- } else {
- next.add(questionLinkId);
- }
- return next;
- });
- };
-
- const getQuestionPath = () => {
- return parentId ? `${parentId}-${question.id}` : question.id;
- };
-
- const findQuestionPath = (
- questions: Question[],
- targetId: string,
- ): Question[] | null => {
- const pathStack: [Question, Question[]][] = questions
- .filter((q) => !!q && !!q.text)
- .map((q) => [q, []]);
-
- while (pathStack.length > 0) {
- const [current, path] = pathStack.pop()!;
-
- if (current.link_id === targetId) {
- return [...path, current];
- }
-
- if (
- current.type === "group" &&
- current.questions &&
- current.questions.length > 0
- ) {
- current.questions.forEach((q) => {
- pathStack.push([q, [...path, current]]);
- });
- }
- }
- return null;
- };
-
- useEffect(() => {
- if (question.enable_when && question.enable_when.length > 0) {
- question.enable_when.forEach((condition, idx) => {
- const path = findQuestionPath(rootQuestions, condition.question);
- if (path) {
- setEnableWhenQuestionAnswers((prev) => ({
- ...prev,
- [idx]: path,
- }));
- }
- });
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [question.enable_when]);
-
- useEffect(() => {
- if (
- expandPath?.length &&
- expandPath.length > 0 &&
- type === "group" &&
- questions
- ) {
- const nextQuestionId = expandPath[0];
- const hasQuestion = questions.some((q) => q.link_id === nextQuestionId);
- if (hasQuestion) {
- toggleSubQuestionExpanded(nextQuestionId, false);
- }
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [expandPath]);
-
- const getOperatorChoices = (index: number) => {
- const currentEnableWhenArr = enableWhenQuestionAnswers[index];
- const currentEnableWhen =
- currentEnableWhenArr?.[currentEnableWhenArr.length - 1];
-
- switch (currentEnableWhen?.type) {
- case "boolean":
- case "text":
- case "string":
- case "url":
- case "choice":
- return ["equals", "not_equals", "exists"];
- default:
- return [
- "equals",
- "not_equals",
- "exists",
- "greater",
- "less",
- "greater_or_equals",
- "less_or_equals",
- ];
- }
- };
-
- const getAnswerChoices = (index: number, condition: EnableWhen) => {
- const currentEnableWhenArr = enableWhenQuestionAnswers[index];
- const currentEnableWhen =
- currentEnableWhenArr?.[currentEnableWhenArr.length - 1];
- switch (currentEnableWhen?.type) {
- case "boolean": {
- // temp fix for boolean answers in existing questionnaires
- let answer = condition.answer.toString();
- if (answer === "true") {
- answer = "Yes";
- } else if (answer === "false") {
- answer = "No";
- }
- return (
-
- );
- }
- case "choice":
- return (
-
- );
- default:
- return (
- {
- const newConditions = [...(question.enable_when || [])];
- const value = e.target.value;
- let newCondition;
- if (
- [
- "greater",
- "less",
- "greater_or_equals",
- "less_or_equals",
- ].includes(condition.operator)
- ) {
- newCondition = {
- question: condition.question,
- operator: condition.operator as
- "greater" | "less" | "greater_or_equals" | "less_or_equals",
- answer: Number(value),
- };
- } else {
- newCondition = {
- question: condition.question,
- operator: condition.operator as "equals" | "not_equals",
- answer: value,
- };
- }
-
- newConditions[index] = newCondition;
- updateField("enable_when", newConditions);
- }}
- placeholder={t("answer_value")}
- />
- );
- }
- };
- const UNIT_TYPES = ["quantity", "choice", "decimal", "integer"];
-
- const handleAddSubQuestionAtIndex = (targetIndex: number) => {
- const newQuestion: Question = {
- id: crypto.randomUUID(),
- link_id: `Q-${Date.now()}`,
- text: "New Sub-Question",
- type: "string",
- questions: [],
- };
- const subQuestions = questions || [];
- const newQuestions = [
- ...subQuestions.slice(0, targetIndex),
- newQuestion,
- ...subQuestions.slice(targetIndex),
- ];
- updateField("questions", newQuestions);
- setExpandedSubQuestions((prev) => new Set([...prev, newQuestion.link_id]));
- setTimeout(() => {
- scrollToQuestion(newQuestion.link_id);
- }, 100);
- };
-
- return (
-
-
- {depth > 0 && (
-
onToggleSelection(question.id)}
- onChange={(e) => e.stopPropagation()}
- className="mb-6 mr-2"
- />
- )}
-
-
-
- {index + 1}. {text || t("untitled_question")}
-
-
- {type}
- {required && {t("required")}}
- {repeats && {t("repeatable")}}
- {type === "group" && questions && questions.length > 0 && (
-
- {t("sub_questions_count", { count: questions.length })}
-
- )}
-
-
-
- {isExpanded ? (
-
- ) : (
-
- )}
-
-
-
-
-
-
- {!isFirst && (
- {
- e.stopPropagation();
- onMoveUp?.();
- }}
- >
-
- {t("move_up")}
-
- )}
- {!isLast && (
- {
- e.stopPropagation();
- onMoveDown?.();
- }}
- >
-
- {t("move_down")}
-
- )}
- {addQuestionAtIndex && (
- {
- e.stopPropagation();
- addQuestionAtIndex(index);
- }}
- >
-
- {t("add_question_above")}
-
- )}
- {addQuestionAtIndex && (
- {
- e.stopPropagation();
- addQuestionAtIndex(index + 1);
- }}
- >
-
- {t("add_question_below")}
-
- )}
- {!(depth > 0 && totalSiblings === 1) && (
- <>
-
- {
- e.stopPropagation();
- onDelete();
- }}
- className="text-destructive"
- >
-
- {t("delete")}
-
- >
- )}
-
-
-
-
-
-
-
-
- (
-
- {t("question_text")}
-
- {
- updateField("text", e.target.value);
- form.setValue(`${name}.text`, e.target.value, {
- shouldValidate: true,
- shouldDirty: true,
- });
- }}
- />
-
-
-
- )}
- />
-
-
-
-
- (
-
- {t("description")}
-
-
-
-
- )}
- />
-
-
- {(enableWhenDependencies.get(question.link_id)?.size || 0) > 0 && (
- <>
-
- {t("questionnaire_question_dependent")}
-
- {Array.from(
- enableWhenDependencies.get(question.link_id) || [],
- ).map(({ question, path }) => (
-
- ))}
-
- {t("ensure_conditions_are_valid")}
-
- >
- )}
-
-
-
-
-
-
-
-
- {type === "structured" && (
-
-
-
- {structuredTypeError && (
-
- {structuredTypeError}
-
- )}
-
- )}
-
-
- {UNIT_TYPES.includes(type) && (
-
(
-
- {t("unit")}
-
- {
- updateField("unit", code);
- form.setValue(`${name}.unit`, code, {
- shouldValidate: true,
- shouldDirty: true,
- });
- }}
- />
-
-
-
- )}
- />
- )}
- {type !== "structured" && (
- updateField("code", newCode)}
- />
- )}
-
-
-
-
-
- {t("question_settings")}
-
-
- {t("question_settings_description")}
-
-
-
-
- updateField("required", val)}
- id={`required-${getQuestionPath()}`}
- />
-
-
-
- {!HIDE_REPEATABLE_QUESTION_TYPES.includes(question.type) && (
-
- updateField("repeats", val)}
- id={`repeats-${getQuestionPath()}`}
- />
-
-
- )}
-
-
- updateField("read_only", val)}
- id={`read_only-${getQuestionPath()}`}
- />
-
-
-
-
-
-
-
-
- {t("data_collection_details")}
-
-
- {t("data_collection_details_description")}
-
-
-
- {type === "group" && (
-
-
- updateField("is_component", val)
- }
- id={`is_component-${getQuestionPath()}`}
- />
-
-
- )}
-
-
-
- updateField("collect_time", val)
- }
- id={`collect_time-${getQuestionPath()}`}
- />
-
-
-
-
-
- updateField("collect_performer", val)
- }
- id={`collect_performer-${getQuestionPath()}`}
- />
-
-
-
-
-
- updateField("collect_body_site", val)
- }
- id={`collect_body_site-${getQuestionPath()}`}
- />
-
-
-
-
-
- updateField("collect_method", val)
- }
- id={`collect_method-${getQuestionPath()}`}
- />
-
-
-
-
-
-
-
- {type === "group" && (
-
-
-
- {t("group_layout_options")}
-
-
- {t("choose_layout_style")}
-
-
{
- updateField("styling_metadata", {
- ...question.styling_metadata,
- containerClasses: val,
- });
- }}
- className="grid grid-cols-4 gap-4"
- >
- {LAYOUT_OPTIONS.map((option) => {
- const currentLayout =
- question.styling_metadata?.containerClasses;
- return (
-
- );
- })}
-
-
-
- )}
-
- {(type === "choice" || type === "quantity") && (
-
-
- {question.type === "choice" && (
- <>
-
-
-
- {t("answer_options")}
-
-
- {t("answer_options_description")}
-
-
-
-
- >
- )}
-
- {question.type === "quantity" && (
-
-
-
- {t("quantity")}
-
-
- {t("quantity_question_description")}
-
-
-
- )}
-
- {question.type === "choice" && !question.answer_value_set ? (
-
- {annotatedAnswerOptions.length !== 0 && (
- <>
-
-
-
- {
- annotatedAnswerOptions.filter(
- (opt) => opt.initial_selected,
- ).length
- }{" "}
- {question.repeats
- ? t("defaults_selected")
- : t("default_selected")}
-
- {annotatedAnswerOptions.some(
- (opt) => opt.initial_selected,
- ) && (
-
- )}
-
-
-
-
-
-
-
- {t("default")}
-
-
- {t("value")}
-
-
- {t("display_text")}
-
-
- {t("actions")}
-
-
-
- {question.repeats ? (
- annotatedAnswerOptions.map((opt, idx) => (
-
-
-
- {
- const newOptions =
- annotatedAnswerOptions.map(
- (o, i) =>
- i === idx
- ? {
- ...o,
- initial_selected:
- !!checked,
- }
- : o,
- );
- updateField(
- "answer_option",
- newOptions,
- );
- }}
- />
-
-
-
-
- ))
- ) : (
-
o.initial_selected,
- )?.value
- }
- onValueChange={(selectedValue) => {
- const newOptions = annotatedAnswerOptions.map(
- (o) => ({
- ...o,
- initial_selected:
- o.value === selectedValue,
- }),
- );
- updateField("answer_option", newOptions);
- }}
- >
- {annotatedAnswerOptions.map((opt, idx) => (
-
-
-
- ))}
-
- )}
-
-
- >
- )}
-
-
-
- ) : (
-
-
- updateField("answer_value_set", val)
- }
- value={
- question.answer_value_set === "valueset"
- ? ""
- : (question.answer_value_set ?? "")
- }
- />
-
- )}
-
-
- )}
-
- {type === "group" && (
-
-
-
-
-
-
}
- />
-
- {(questions || []).map((subQuestion, idx) => (
-
{
- questionRefs.current[subQuestion.link_id] = el;
- }}
- >
- {
- const newQuestions = [...(questions || [])];
- newQuestions[idx] = updated;
- updateField("questions", newQuestions);
- }}
- onDelete={() => {
- const newQuestions = questions?.filter(
- (_, i) => i !== idx,
- );
- updateField("questions", newQuestions);
- }}
- isExpanded={expandedSubQuestions.has(subQuestion.link_id)}
- onToggleExpand={() =>
- toggleSubQuestionExpanded(subQuestion.link_id)
- }
- depth={depth + 1}
- parentId={getQuestionPath()}
- onMoveUp={() => {
- if (idx > 0) {
- const newQuestions = swapElements(
- questions || [],
- idx,
- idx - 1,
- );
- updateField("questions", newQuestions);
- }
- }}
- onMoveDown={() => {
- if (idx < (questions?.length || 0) - 1) {
- const newQuestions = swapElements(
- questions || [],
- idx,
- idx + 1,
- );
- updateField("questions", newQuestions);
- }
- }}
- addQuestionAtIndex={handleAddSubQuestionAtIndex}
- isFirst={idx === 0}
- isLast={idx === (questions?.length || 0) - 1}
- expandPath={expandPath?.slice(1)}
- questionRefs={questionRefs}
- totalSiblings={questions?.length || 0}
- />
-
- ))}
-
-
- )}
-
-
-
-
- {(question.enable_when || []).length > 0 && (
-
-
-
-
- )}
- {(question.enable_when || []).map((condition, idx) => (
-
-
-
- {t("condition")} {idx + 1}
-
-
-
-
-
-
-
-
- {enableWhenQuestionAnswers[idx]?.map((q, index) => {
- if (q.type !== "group" || q.questions?.length === 0) {
- return null;
- }
- return (
-
- );
- })}
-
-
-
-
-
-
-
-
- {condition.operator !== "exists" && (
-
- )}
- {condition.operator === "exists" ? (
-
- ) : (
- getAnswerChoices(idx, condition)
- )}
-
-
-
-
- ))}
-
-
-
-
- {(question.type === "string" || question.type === "text") && (
-
-
-
- {question.templates?.map((template, idx) => (
-
-
-
- {t("template")} {idx + 1}
-
-
-
-
-
{
- e.preventDefault();
- const newTemplates = [...(question.templates || [])];
- newTemplates[idx] = {
- ...template,
- name: e.target.value,
- };
- updateField("templates", newTemplates);
- }}
- />
-
-
- ))}
-
-
-
-
- )}
-
-
-
- );
-}
-
-function getQuestionByPath(questions: any, path: number[]) {
- let q = questions[path[0]];
- for (let i = 1; i < path.length; i++) {
- q = q?.questions?.[path[i]];
- }
- return q;
-}
diff --git a/src/components/Questionnaire/QuestionnaireForm.tsx b/src/components/Questionnaire/QuestionnaireForm.tsx
index c9d6dd209a5..71987337601 100644
--- a/src/components/Questionnaire/QuestionnaireForm.tsx
+++ b/src/components/Questionnaire/QuestionnaireForm.tsx
@@ -80,7 +80,7 @@ interface ServerValidationError {
}
export interface QuestionnaireFormProps {
- questionnaireSlug?: string;
+ questionnaireId?: string;
patientId: string;
encounterId?: string;
subjectType?: string;
@@ -367,7 +367,7 @@ const initializeResponses = (
};
export function QuestionnaireForm({
- questionnaireSlug,
+ questionnaireId,
patientId,
encounterId,
subjectType,
@@ -393,11 +393,11 @@ export function QuestionnaireForm({
isLoading: isQuestionnaireLoading,
error: questionnaireError,
} = useQuery({
- queryKey: ["questionnaireDetail", questionnaireSlug],
+ queryKey: ["questionnaireDetail", questionnaireId],
queryFn: query(questionnaireApi.get, {
- pathParams: { slug: questionnaireSlug ?? "" },
+ pathParams: { id: questionnaireId ?? "" },
}),
- enabled: !!questionnaireSlug && !FIXED_QUESTIONNAIRES[questionnaireSlug],
+ enabled: !!questionnaireId && !FIXED_QUESTIONNAIRES[questionnaireId],
});
// Fetch draft if continue_draft query param is present
@@ -416,6 +416,9 @@ export function QuestionnaireForm({
});
const { mutate: submitBatch, isPending: isSubmitPending } = useMutation({
+ // TODO: migrate to useBatchRequest once it can take pre-built batch entries
+ // (these requests carry raw urls) and can opt out of the global error toast.
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
mutationFn: mutate(batchApi.batchRequest, { silent: true }),
onSuccess: () => {
setServerErrors(undefined);
@@ -555,7 +558,7 @@ export function QuestionnaireForm({
return false;
}
- if (!questionnaireSlug || questionnaireForms.length > 1) {
+ if (!questionnaireId || questionnaireForms.length > 1) {
return false;
}
@@ -576,16 +579,16 @@ export function QuestionnaireForm({
return !questionnaireForms.some((form) =>
findStructuredQuestions(form.questionnaire.questions),
);
- }, [questionnaireSlug, questionnaireForms]);
+ }, [questionnaireId, questionnaireForms]);
// TODO: Use useBlocker hook after switching to tanstack router
// https://tanstack.com/router/latest/docs/framework/react/guide/navigation-blocking#how-do-i-use-navigation-blocking
useNavigationPrompt(isDirty && !import.meta.env.DEV, t("unsaved_changes"));
useEffect(() => {
- if (!isInitialized && questionnaireSlug) {
+ if (!isInitialized && questionnaireId) {
const questionnaire =
- FIXED_QUESTIONNAIRES[questionnaireSlug] || questionnaireData;
+ FIXED_QUESTIONNAIRES[questionnaireId] || questionnaireData;
// If we have a draft to continue, wait for it to load
if (continueDraftId) {
@@ -629,7 +632,7 @@ export function QuestionnaireForm({
}, [
questionnaireData,
isInitialized,
- questionnaireSlug,
+ questionnaireId,
continueDraftId,
draftData,
isDraftFetching,
@@ -863,7 +866,7 @@ export function QuestionnaireForm({
);
if (validResponses.length > 0) {
requests.push({
- url: `/api/v1/questionnaire/${form.questionnaire.slug}/submit/`,
+ url: `/api/v1/questionnaire/${form.questionnaire.id}/submit/`,
method: "POST",
reference_id: form.questionnaire.id,
body: {
@@ -1025,7 +1028,7 @@ export function QuestionnaireForm({
)}
- {form.questionnaire.slug !== questionnaireSlug && (
+ {form.questionnaire.id !== questionnaireId && (
- );
-}
-
-export function QuestionnaireProperties({
- form,
- updateQuestionnaireField,
- slug,
- organizations,
- organizationSelection,
-}: QuestionnairePropertiesProps) {
- const { t } = useTranslation();
- const status = useWatch({ control: form.control, name: "status" });
- const subjectType = useWatch({ control: form.control, name: "subject_type" });
-
- return (
-
- );
-}
diff --git a/src/components/Questionnaire/QuestionnaireSearch.tsx b/src/components/Questionnaire/QuestionnaireSearch.tsx
index 64b5feaacfa..cda77a061cd 100644
--- a/src/components/Questionnaire/QuestionnaireSearch.tsx
+++ b/src/components/Questionnaire/QuestionnaireSearch.tsx
@@ -2,7 +2,7 @@ import { CaretSortIcon } from "@radix-ui/react-icons";
import { useQuery } from "@tanstack/react-query";
import { Plus } from "lucide-react";
import { navigate } from "raviger";
-import { useEffect, useState } from "react";
+import { useState } from "react";
import { useTranslation } from "react-i18next";
import CareIcon from "@/CAREUI/icons/CareIcon";
@@ -49,7 +49,7 @@ export function QuestionnaireSearch({
placeholder,
trigger,
size = "default",
- onSelect = (selected) => navigate(`questionnaire/${selected.slug}`),
+ onSelect = (selected) => navigate(`questionnaire/${selected.id}`),
subjectType,
disabled,
}: QuestionnaireSearchProps) {
@@ -72,11 +72,12 @@ export function QuestionnaireSearch({
enabled: isOpen,
});
- useEffect(() => {
- if (isOpen) {
+ const handleOpenChange = (open: boolean) => {
+ if (open) {
setSearch("");
}
- }, [isOpen]);
+ setIsOpen(open);
+ };
const content = (