From 7a144e288e64d719757009bc02f0cdf746803cf8 Mon Sep 17 00:00:00 2001 From: Gigin George Date: Wed, 12 Feb 2025 21:23:10 +0530 Subject: [PATCH 01/12] Valuesets WIP --- eslint.config.mjs | 4 +- public/locale/en.json | 4 + src/Routers/AppRouter.tsx | 21 +- src/Routers/routes/adminRoutes.tsx | 19 + src/Routers/routes/questionnaireRoutes.tsx | 14 - src/components/Common/DebugPreview.tsx | 28 + .../Questionnaire/CloneQuestionnaireSheet.tsx | 2 +- src/components/Questionnaire/CodingEditor.tsx | 157 +++++ .../Questionnaire/QuestionnaireEditor.tsx | 650 +++++++----------- .../Questionnaire/QuestionnaireForm.tsx | 15 +- .../{index.tsx => QuestionnaireList.tsx} | 17 +- src/components/Questionnaire/show.tsx | 9 +- src/components/ValueSet/ValueSetEditor.tsx | 112 +++ src/components/ValueSet/ValueSetForm.tsx | 497 +++++++++++++ src/components/ValueSet/ValueSetList.tsx | 118 ++++ src/components/ui/sidebar/admin-nav.tsx | 33 + src/components/ui/sidebar/app-sidebar.tsx | 4 + src/pages/UserDashboard.tsx | 8 +- src/types/questionnaire/question.ts | 7 + src/types/valueset/valueset.ts | 70 ++ src/types/valueset/valuesetApi.ts | 49 ++ 21 files changed, 1395 insertions(+), 443 deletions(-) create mode 100644 src/Routers/routes/adminRoutes.tsx delete mode 100644 src/Routers/routes/questionnaireRoutes.tsx create mode 100644 src/components/Common/DebugPreview.tsx create mode 100644 src/components/Questionnaire/CodingEditor.tsx rename src/components/Questionnaire/{index.tsx => QuestionnaireList.tsx} (89%) create mode 100644 src/components/ValueSet/ValueSetEditor.tsx create mode 100644 src/components/ValueSet/ValueSetForm.tsx create mode 100644 src/components/ValueSet/ValueSetList.tsx create mode 100644 src/components/ui/sidebar/admin-nav.tsx create mode 100644 src/types/valueset/valueset.ts create mode 100644 src/types/valueset/valuesetApi.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 35fd03f40b7..a44c623b55d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -66,7 +66,7 @@ export default [ rules: { ...tseslint.configs.recommended.rules, "@typescript-eslint/no-unused-vars": [ - "error", + "warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_", @@ -74,7 +74,7 @@ export default [ }, ], "@typescript-eslint/no-unused-expressions": [ - "error", + "warn", { allowShortCircuit: true, allowTernary: true }, ], "@typescript-eslint/no-explicit-any": "warn", diff --git a/public/locale/en.json b/public/locale/en.json index 7a455f5a214..a47296d3bb1 100644 --- a/public/locale/en.json +++ b/public/locale/en.json @@ -633,6 +633,7 @@ "create_consultation": "Create Consultation", "create_encounter": "Create Encounter", "create_facility": "Create Facility", + "create_new": "Create New", "create_new_asset": "Create New Asset", "create_new_encounter": "Create a new encounter to get started", "create_new_facility": "Create a new facility and add it to the organization.", @@ -2003,6 +2004,7 @@ "skills": "Skills", "slot_configuration": "Slot Configuration", "slots_left": "slots left", + "slug": "Slug", "social_profile": "Social Profile", "social_profile_detail": "Include occupation, ration card category, socioeconomic status, and domestic healthcare support for a complete profile.", "socioeconomic_status": "Socioeconomic status", @@ -2059,6 +2061,7 @@ "symptom": "Symptom", "symptoms": "Symptoms", "symptoms_empty_message": "No symptoms recorded", + "system": "System", "systolic": "Systolic", "tachycardia": "Tachycardia", "tag_name": "Tag Name", @@ -2247,6 +2250,7 @@ "valid_otp_found": "Valid OTP found, Navigating to Appointments", "valid_to": "Valid Till", "valid_year_of_birth": "Please enter a valid year of birth (YYYY)", + "valuesets": "Valuesets", "vehicle_preference": "Vehicle preference", "vendor_name": "Vendor Name", "ventilator_interface": "Respiratory Support Type", diff --git a/src/Routers/AppRouter.tsx b/src/Routers/AppRouter.tsx index 179cded03e1..5461309083a 100644 --- a/src/Routers/AppRouter.tsx +++ b/src/Routers/AppRouter.tsx @@ -4,7 +4,7 @@ import { Redirect, useRedirect, useRoutes } from "raviger"; import IconIndex from "@/CAREUI/icons/Index"; import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; -import { AppSidebar } from "@/components/ui/sidebar/app-sidebar"; +import { AppSidebar, SidebarFor } from "@/components/ui/sidebar/app-sidebar"; import ErrorBoundary from "@/components/Common/ErrorBoundary"; import ErrorPage from "@/components/ErrorPages/DefaultErrorPage"; @@ -25,7 +25,7 @@ import { PlugConfigList } from "@/pages/Apps/PlugConfigList"; import UserDashboard from "@/pages/UserDashboard"; import OrganizationRoutes from "./routes/OrganizationRoutes"; -import QuestionnaireRoutes from "./routes/questionnaireRoutes"; +import AdminRoutes from "./routes/adminRoutes"; // List of paths where the sidebar should be hidden const PATHS_WITHOUT_SIDEBAR = ["/", "/session-expired"]; @@ -55,7 +55,6 @@ const Routes: AppRoutes = { ...ScheduleRoutes, ...UserRoutes, ...OrganizationRoutes, - ...QuestionnaireRoutes, "/session-expired": () => , "/not-found": () => , @@ -69,6 +68,10 @@ const Routes: AppRoutes = { "/login": () => , }; +const AdminRouter: AppRoutes = { + ...AdminRoutes, +}; + export default function AppRouter() { const pluginRoutes = usePluginRoutes(); let routes = Routes; @@ -81,7 +84,13 @@ export default function AppRouter() { ...routes, }; - const pages = useRoutes(routes) || ; + const appPages = useRoutes(routes); + const adminPages = useRoutes(AdminRouter); + + const sidebarFor = appPages ? SidebarFor.FACILITY : SidebarFor.ADMIN; + + const pages = appPages || adminPages || ; + const user = useAuthUser(); const currentPath = window.location.pathname; const shouldShowSidebar = !PATHS_WITHOUT_SIDEBAR.includes(currentPath); @@ -92,7 +101,9 @@ export default function AppRouter() { userPermissions={user?.permissions || []} isSuperAdmin={user?.is_superuser || false} > - {shouldShowSidebar && } + {shouldShowSidebar && ( + + )}
, + "/admin/questionnaire/create": () => , + "/admin/questionnaire/:id": ({ id }) => , + "/admin/questionnaire/:id/edit": ({ id }) => , + "/admin/valuesets": () => , + "/admin/valuesets/create": () => , + "/admin/valuesets/:slug/edit": ({ slug }) => , +}; + +export default AdminRoutes; diff --git a/src/Routers/routes/questionnaireRoutes.tsx b/src/Routers/routes/questionnaireRoutes.tsx deleted file mode 100644 index 670861016a7..00000000000 --- a/src/Routers/routes/questionnaireRoutes.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { QuestionnaireList } from "@/components/Questionnaire"; -import QuestionnaireEditor from "@/components/Questionnaire/QuestionnaireEditor"; -import { QuestionnaireShow } from "@/components/Questionnaire/show"; - -import { AppRoutes } from "@/Routers/AppRouter"; - -const QuestionnaireRoutes: AppRoutes = { - "/questionnaire": () => , - "/questionnaire/create": () => , - "/questionnaire/:id": ({ id }) => , - "/questionnaire/:id/edit": ({ id }) => , -}; - -export default QuestionnaireRoutes; diff --git a/src/components/Common/DebugPreview.tsx b/src/components/Common/DebugPreview.tsx new file mode 100644 index 00000000000..ef0e794ab4d --- /dev/null +++ b/src/components/Common/DebugPreview.tsx @@ -0,0 +1,28 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +interface DebugPreviewProps { + data: unknown; + title?: string; + className?: string; +} + +export function DebugPreview({ data, title, className }: DebugPreviewProps) { + if (!import.meta.env.DEV) { + return null; + } + + return ( + + + + {title || "Debug Preview"} + + + +
+          {JSON.stringify(data, null, 2)}
+        
+
+
+ ); +} diff --git a/src/components/Questionnaire/CloneQuestionnaireSheet.tsx b/src/components/Questionnaire/CloneQuestionnaireSheet.tsx index 3a61ce1910a..b3de7c4e7fb 100644 --- a/src/components/Questionnaire/CloneQuestionnaireSheet.tsx +++ b/src/components/Questionnaire/CloneQuestionnaireSheet.tsx @@ -64,7 +64,7 @@ export default function CloneQuestionnaireSheet({ silent: true, }), onSuccess: async (data: QuestionnaireDetail) => { - navigate(`/questionnaire/${data.slug}`); + navigate(`/admin/questionnaire/${data.slug}`); setOpen(false); }, onError: (error: any) => { diff --git a/src/components/Questionnaire/CodingEditor.tsx b/src/components/Questionnaire/CodingEditor.tsx new file mode 100644 index 00000000000..b74ac6ebb61 --- /dev/null +++ b/src/components/Questionnaire/CodingEditor.tsx @@ -0,0 +1,157 @@ +import { UpdateIcon } from "@radix-ui/react-icons"; +import { toast } from "sonner"; + +import CareIcon from "@/CAREUI/icons/CareIcon"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +import mutate from "@/Utils/request/mutate"; +import { Code } from "@/types/questionnaire/code"; +import { TERMINOLOGY_SYSTEMS } from "@/types/valueset/valueset"; +import valuesetApi from "@/types/valueset/valuesetApi"; + +interface CodingEditorProps { + code?: Code; + onChange: (code: Code | undefined) => void; +} + +export function CodingEditor({ code, onChange }: CodingEditorProps) { + if (!code) { + return ( +
+ +
+ ); + } + + return ( + + +
+ + +
+
+ + +
+ + +
+ +
+
+ + { + onChange({ + ...code, + code: e.target.value, + display: "", + }); + }} + placeholder="Enter code" + /> +
+
+ + +
+
+ +
+
+
+
+ ); +} diff --git a/src/components/Questionnaire/QuestionnaireEditor.tsx b/src/components/Questionnaire/QuestionnaireEditor.tsx index 2637d5c9880..387351be463 100644 --- a/src/components/Questionnaire/QuestionnaireEditor.tsx +++ b/src/components/Questionnaire/QuestionnaireEditor.tsx @@ -1,9 +1,13 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { UpdateIcon } from "@radix-ui/react-icons"; import { useMutation, useQuery } from "@tanstack/react-query"; import { ChevronDown, ChevronUp } from "lucide-react"; import { Building, Check, Loader2, X } from "lucide-react"; import { useNavigate } from "raviger"; import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; import { toast } from "sonner"; +import * as z from "zod"; import CareIcon from "@/CAREUI/icons/CareIcon"; @@ -44,6 +48,7 @@ import { Switch } from "@/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; +import { DebugPreview } from "@/components/Common/DebugPreview"; import Loading from "@/components/Common/Loading"; import mutate from "@/Utils/request/mutate"; @@ -52,6 +57,7 @@ import organizationApi from "@/types/organization/organizationApi"; import { AnswerOption, EnableWhen, + ObservationType, Question, QuestionType, StructuredQuestionType, @@ -62,7 +68,10 @@ import { SubjectType, } from "@/types/questionnaire/questionnaire"; import questionnaireApi from "@/types/questionnaire/questionnaireApi"; +import { TERMINOLOGY_SYSTEMS } from "@/types/valueset/valueset"; +import valuesetApi from "@/types/valueset/valuesetApi"; +import { CodingEditor } from "./CodingEditor"; import ManageQuestionnaireOrganizationsSheet from "./ManageQuestionnaireOrganizationsSheet"; import { QuestionnaireForm } from "./QuestionnaireForm"; @@ -89,6 +98,7 @@ export default function QuestionnaireEditor({ id }: QuestionnaireEditorProps) { ); const [selectedOrgIds, setSelectedOrgIds] = useState([]); const [orgSearchQuery, setOrgSearchQuery] = useState(""); + const [observation, setObservation] = useState(); const { data: initialQuestionnaire, @@ -117,7 +127,7 @@ export default function QuestionnaireEditor({ id }: QuestionnaireEditorProps) { mutationFn: mutate(questionnaireApi.create), onSuccess: (data: QuestionnaireDetail) => { toast.success("Questionnaire created successfully"); - navigate(`/questionnaire/${data.slug}`); + navigate(`/admin/questionnaire/${data.slug}`); }, onError: (_error) => { toast.error("Failed to create questionnaire"); @@ -193,6 +203,7 @@ export default function QuestionnaireEditor({ id }: QuestionnaireEditorProps) { const handleSave = () => { if (id) { + console.log("Update Questionnaire", questionnaire); updateQuestionnaire(questionnaire); } else { createQuestionnaire({ @@ -203,7 +214,7 @@ export default function QuestionnaireEditor({ id }: QuestionnaireEditorProps) { }; const handleCancel = () => { - navigate(id ? `/questionnaire/${id}` : "/questionnaire"); + navigate(id ? `/admin/questionnaire/${id}` : "/admin/questionnaire"); }; const toggleQuestionExpanded = (questionId: string) => { @@ -632,6 +643,11 @@ export default function QuestionnaireEditor({ id }: QuestionnaireEditorProps) { + @@ -690,6 +706,7 @@ function QuestionEditor({ repeats, answer_option, questions, + code, } = question; const [expandedSubQuestions, setExpandedSubQuestions] = useState>( @@ -824,136 +841,181 @@ function QuestionEditor({ /> -
-
- - -
- - {type === "structured" && ( +
+
- +
- )} -
-
-
- updateField("required", val)} - id={`required-${getQuestionPath()}`} - /> - + {type === "structured" && ( +
+ + +
+ )}
-
- updateField("repeats", val)} - id={`repeats-${getQuestionPath()}`} + {type !== "structured" && type !== "group" && ( + updateField("code", newCode)} /> - -
+ )} +
-
- updateField("collect_time", val)} - id={`collect_time-${getQuestionPath()}`} - /> - -
+
+
+

Question Settings

+

+ Configure the basic behavior: mark as required, allow multiple + entries, or set as read only. +

+
+
+
+ updateField("required", val)} + id={`required-${getQuestionPath()}`} + /> + +
-
- updateField("collect_performer", val)} - id={`collect_performer-${getQuestionPath()}`} - /> - -
+
+ updateField("repeats", val)} + id={`repeats-${getQuestionPath()}`} + /> + +
-
- updateField("collect_body_site", val)} - id={`collect_body_site-${getQuestionPath()}`} - /> - +
+ updateField("read_only", val)} + id={`read_only-${getQuestionPath()}`} + /> + +
+
+
-
- updateField("collect_method", val)} - id={`collect_method-${getQuestionPath()}`} - /> - -
+
+

+ Data Collection Details +

+

+ Specify key collection info: time, performer, body site, and + method. +

+
+
+
+ + updateField("collect_time", val) + } + id={`collect_time-${getQuestionPath()}`} + /> + +
-
- updateField("read_only", val)} - id={`read_only-${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()}`} + /> + +
+
+
@@ -988,9 +1050,16 @@ function QuestionEditor({ {type === "choice" && (
-
- -
+ + +
+ + Answer Options + +

+ Define possible answers for this question +

+
-
+ + {(!question.answer_value_set || question.answer_value_set === "custom") && ( -
+ {(answer_option || []).map((opt, idx) => ( -
- { - const newOptions = [...(answer_option || [])]; - newOptions[idx] = { ...opt, value: e.target.value }; - updateField("answer_option", newOptions); - }} - placeholder="Option value" - /> -
- { - const newOptions = [...(answer_option || [])]; - newOptions[idx] = { - ...opt, - display: e.target.value, - }; - updateField("answer_option", newOptions); - }} - placeholder="Display text (optional)" - /> - +
+
+
+ + { + const newOptions = answer_option + ? [...answer_option] + : []; + newOptions[idx] = { + ...opt, + value: e.target.value, + }; + updateField("answer_option", newOptions); + }} + placeholder="Option value" + /> +
+
+
+ + { + const newOptions = answer_option + ? [...answer_option] + : []; + newOptions[idx] = { + ...opt, + display: e.target.value, + }; + updateField("answer_option", newOptions); + }} + placeholder="Display text (optional)" + /> +
+ +
))} + -
+ )} -
- -
- -
- {(question.enable_when || []).length > 0 && ( -
- - -
- )} - {(question.enable_when || []).map((condition, idx) => ( -
-
- - { - const newConditions = [ - ...(question.enable_when || []), - ]; - newConditions[idx] = { - ...condition, - question: e.target.value, - }; - updateField("enable_when", newConditions); - }} - placeholder="Question Link ID" - /> -
-
- - -
-
-
- - {condition.operator === "exists" ? ( - - ) : ( - { - 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[idx] = newCondition; - updateField("enable_when", newConditions); - }} - placeholder="Answer value" - /> - )} -
- -
-
- ))} - -
-
+
)} diff --git a/src/components/Questionnaire/QuestionnaireForm.tsx b/src/components/Questionnaire/QuestionnaireForm.tsx index 6c8716058de..a02062c2325 100644 --- a/src/components/Questionnaire/QuestionnaireForm.tsx +++ b/src/components/Questionnaire/QuestionnaireForm.tsx @@ -11,6 +11,7 @@ import CareIcon from "@/CAREUI/icons/CareIcon"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; +import { DebugPreview } from "@/components/Common/DebugPreview"; import Loading from "@/components/Common/Loading"; import { PLUGIN_Component } from "@/PluginEngine"; @@ -527,15 +528,11 @@ export function QuestionnaireForm({ setFormState={setQuestionnaireForms} /> - {/* Add a Preview of the QuestionnaireForm */} - {import.meta.env.DEV && ( -
-

QuestionnaireForm

-
-              {JSON.stringify(questionnaireForms, null, 2)}
-            
-
- )} +
); diff --git a/src/components/Questionnaire/index.tsx b/src/components/Questionnaire/QuestionnaireList.tsx similarity index 89% rename from src/components/Questionnaire/index.tsx rename to src/components/Questionnaire/QuestionnaireList.tsx index 54353743df9..54d511a1b05 100644 --- a/src/components/Questionnaire/index.tsx +++ b/src/components/Questionnaire/QuestionnaireList.tsx @@ -1,4 +1,5 @@ import { useQuery } from "@tanstack/react-query"; +import { t } from "i18next"; import { useNavigate } from "raviger"; import { Badge } from "@/components/ui/badge"; @@ -30,8 +31,8 @@ export function QuestionnaireList() {

Questionnaires

Manage and view questionnaires

-
@@ -40,16 +41,16 @@ export function QuestionnaireList() { - Title + {t("title")} - Description + {t("description")} - Status + {t("status")} - Slug + {t("slug")} @@ -57,7 +58,9 @@ export function QuestionnaireList() { {questionnaireList.map((questionnaire: QuestionnaireDetail) => ( navigate(`/questionnaire/${questionnaire.slug}`)} + onClick={() => + navigate(`/admin/questionnaire/${questionnaire.slug}`) + } className="cursor-pointer hover:bg-gray-50" > diff --git a/src/components/Questionnaire/show.tsx b/src/components/Questionnaire/show.tsx index 4534ce5734b..c655aa6bc94 100644 --- a/src/components/Questionnaire/show.tsx +++ b/src/components/Questionnaire/show.tsx @@ -103,7 +103,7 @@ export function QuestionnaireShow({ id }: QuestionnaireShowProps) { pathParams: { id }, }), onSuccess: () => { - navigate("/questionnaire"); + navigate("/admin/questionnaire"); }, }); @@ -147,11 +147,14 @@ export function QuestionnaireShow({ id }: QuestionnaireShowProps) {

{questionnaire.description}

- - diff --git a/src/components/ValueSet/ValueSetEditor.tsx b/src/components/ValueSet/ValueSetEditor.tsx new file mode 100644 index 00000000000..9fbc8ed5f5e --- /dev/null +++ b/src/components/ValueSet/ValueSetEditor.tsx @@ -0,0 +1,112 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useNavigate } from "raviger"; +import { toast } from "sonner"; + +import { Skeleton } from "@/components/ui/skeleton"; + +import mutate from "@/Utils/request/mutate"; +import query from "@/Utils/request/query"; +import { + CreateValuesetModel, + UpdateValuesetModel, + ValuesetBase, + ValuesetFormType, +} from "@/types/valueset/valueset"; +import valuesetApi from "@/types/valueset/valuesetApi"; + +import { ValueSetForm } from "./ValueSetForm"; + +interface ValueSetEditorProps { + slug?: string; // If provided, we're editing an existing valueset +} + +function FormSkeleton() { + return ( +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ ); +} + +export function ValueSetEditor({ slug }: ValueSetEditorProps) { + const navigate = useNavigate(); + + // Fetch existing valueset if we're editing + const { data: existingValueset, isLoading } = useQuery({ + queryKey: ["valueset", slug], + queryFn: query(valuesetApi.get, { + pathParams: { slug: slug! }, + }), + enabled: !!slug, + }) as { data: ValuesetBase | undefined; isLoading: boolean }; + + // Create mutation + const createMutation = useMutation({ + mutationFn: mutate(valuesetApi.create), + onSuccess: (data: ValuesetBase) => { + toast.success("ValueSet created successfully"); + navigate(`/valuesets/${data.slug}`); + }, + }); + + // Update mutation + const updateMutation = useMutation({ + mutationFn: mutate(valuesetApi.update, { + pathParams: { slug: slug! }, + }), + onSuccess: (data: ValuesetBase) => { + toast.success("ValueSet updated successfully"); + navigate(`/admin/valuesets`); + }, + }); + + const handleSubmit = (data: ValuesetFormType) => { + if (slug && existingValueset) { + const updateData: UpdateValuesetModel = { + ...data, + id: existingValueset.id, + }; + updateMutation.mutate(updateData); + } else { + const createData: CreateValuesetModel = data; + createMutation.mutate(createData); + } + }; + + return ( +
+

+ {slug ? "Edit ValueSet" : "Create New ValueSet"} +

+ + {slug && isLoading ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/components/ValueSet/ValueSetForm.tsx b/src/components/ValueSet/ValueSetForm.tsx new file mode 100644 index 00000000000..a177c4b9fb1 --- /dev/null +++ b/src/components/ValueSet/ValueSetForm.tsx @@ -0,0 +1,497 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { PlusIcon, TrashIcon, UpdateIcon } from "@radix-ui/react-icons"; +import { useMutation } from "@tanstack/react-query"; +import { useFieldArray, useForm } from "react-hook-form"; +import { toast } from "sonner"; +import * as z from "zod"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; + +import mutate from "@/Utils/request/mutate"; +import { + TERMINOLOGY_SYSTEMS, + ValuesetFormType, +} from "@/types/valueset/valueset"; +import valuesetApi from "@/types/valueset/valuesetApi"; + +// Create a schema for form validation +const valuesetFormSchema = z.object({ + name: z.string().min(1, "Name is required"), + slug: z.string().min(1, "Slug is required"), + description: z.string(), + status: z.enum(["active", "inactive"]), + is_system_defined: z.boolean(), + compose: z.object({ + include: z.array( + z.object({ + system: z.string(), + concept: z + .array( + z.object({ + code: z.string(), + display: z.string(), + }), + ) + .optional(), + filter: z + .array( + z.object({ + property: z.string(), + op: z.string(), + value: z.string(), + }), + ) + .optional(), + }), + ), + exclude: z.array( + z.object({ + system: z.string(), + concept: z + .array( + z.object({ + code: z.string(), + display: z.string(), + }), + ) + .optional(), + filter: z + .array( + z.object({ + property: z.string(), + op: z.string(), + value: z.string(), + }), + ) + .optional(), + }), + ), + }), +}); + +interface ValueSetFormProps { + initialData?: ValuesetFormType; + onSubmit: (data: ValuesetFormType) => void; + isSubmitting?: boolean; +} + +interface LookupVariables { + system: string; + code: string; + index: number; +} + +function ConceptFields({ + nestIndex, + type, + parentForm, +}: { + nestIndex: number; + type: "include" | "exclude"; + parentForm: ReturnType>; +}) { + const { fields, append, remove } = useFieldArray({ + control: parentForm.control, + name: `compose.${type}.${nestIndex}.concept`, + }); + + const lookupMutation = useMutation({ + mutationFn: async ({ system, code, index }: LookupVariables) => { + const response = await mutate( + valuesetApi.lookup, + )({ + system, + code, + }); + return { response, index }; + }, + onSuccess: ({ response, index }) => { + if (response.metadata) { + parentForm.setValue( + `compose.${type}.${nestIndex}.concept.${index}.display`, + response.metadata.display, + { shouldValidate: true }, + ); + toast.success("Code verified successfully"); + } + }, + onError: (error, { index }) => { + toast.error("Failed to verify code"); + }, + }); + + const handleVerify = async (index: number) => { + const system = parentForm.getValues(`compose.${type}.${nestIndex}.system`); + const code = parentForm.getValues( + `compose.${type}.${nestIndex}.concept.${index}.code`, + ); + + if (!system || !code) { + toast.error("Please select a system and enter a code first"); + return; + } + + lookupMutation.mutate({ + system, + code, + index, + }); + }; + + return ( +
+
+

Concepts

+ +
+ {fields.map((field, index) => ( +
+ ( + + + { + field.onChange(e); + // Clear display and set isVerified to false when code changes + parentForm.setValue( + `compose.${type}.${nestIndex}.concept.${index}.display`, + "", + { shouldValidate: true }, + ); + }} + /> + + + )} + /> + ( + + + + + + )} + /> + + +
+ ))} +
+ ); +} + +function FilterFields({ + nestIndex, + type, +}: { + nestIndex: number; + type: "include" | "exclude"; +}) { + const form = useForm(); + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: `compose.${type}.${nestIndex}.filter`, + }); + + return ( +
+
+

Filters

+ +
+ {fields.map((field, index) => ( +
+ ( + + + + + + )} + /> + ( + + + + + + )} + /> + ( + + + + + + )} + /> + +
+ ))} +
+ ); +} + +function RuleFields({ + type, + form, +}: { + type: "include" | "exclude"; + form: ReturnType>; +}) { + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: `compose.${type}`, + }); + + return ( + + + + {type === "include" ? "Include Rules" : "Exclude Rules"} + + + + + {fields.map((field, index) => ( +
+
+ ( + + System + + + )} + /> + +
+ + +
+ ))} +
+
+ ); +} + +export function ValueSetForm({ + initialData, + onSubmit, + isSubmitting, +}: ValueSetFormProps) { + const form = useForm({ + resolver: zodResolver(valuesetFormSchema), + defaultValues: { + name: initialData?.name || "", + slug: initialData?.slug || "", + description: initialData?.description || "", + status: initialData?.status || "active", + is_system_defined: initialData?.is_system_defined || false, + compose: { + include: initialData?.compose?.include || [], + exclude: initialData?.compose?.exclude || [], + }, + }, + }); + + return ( +
+ + ( + + Name + + + + + + )} + /> + + ( + + Slug + + + + + + )} + /> + + ( + + Description + +