From 727178d8ad3baafd6f34b3eff8867c8b407c0738 Mon Sep 17 00:00:00 2001 From: Olim Saidov Date: Wed, 30 Oct 2024 10:46:11 +0500 Subject: [PATCH] Allow showing/editing/importing questionnaire in smart ehr --- .../src/components/page.jsx | 5 +- .../src/components/pagination.jsx | 20 +- .../src/components/patient-card.jsx | 2 - .../src/components/questionnaire-builder.jsx | 51 ++++- .../src/components/questionnaire-preview.jsx | 6 +- .../src/components/user-dropdown-menu.jsx | 5 +- .../src/hooks/use-client.jsx | 2 + .../src/hooks/use-launch-context.jsx | 4 +- aidbox-forms-smart-launch/src/lib/utils.js | 48 +++- aidbox-forms-smart-launch/src/pages/error.jsx | 5 +- .../src/pages/questionnaire-editor.jsx | 1 - .../src/pages/questionnaire-response.jsx | 2 +- .../src/pages/questionnaire-responses.jsx | 34 +-- .../src/pages/questionnaires.jsx | 216 ++++++++++++++++-- 14 files changed, 324 insertions(+), 77 deletions(-) diff --git a/aidbox-forms-smart-launch/src/components/page.jsx b/aidbox-forms-smart-launch/src/components/page.jsx index 90731fd..bfd84a4 100644 --- a/aidbox-forms-smart-launch/src/components/page.jsx +++ b/aidbox-forms-smart-launch/src/components/page.jsx @@ -2,11 +2,10 @@ import { SidebarInset, SidebarProvider } from "@/ui/sidebar.jsx"; import { Header } from "@/components/header.jsx"; import { Sidebar } from "@/components/sidebar.jsx"; import * as React from "react"; +import { Suspense } from "react"; import { LaunchContextProvider } from "@/hooks/use-launch-context.jsx"; import { ClientProvider } from "@/hooks/use-client.jsx"; -import { cn } from "@/lib/utils.js"; -import { Outlet, useNavigation } from "react-router-dom"; -import { Suspense } from "react"; +import { Outlet } from "react-router-dom"; import { Loading } from "@/components/loading.jsx"; export const Page = () => { diff --git a/aidbox-forms-smart-launch/src/components/pagination.jsx b/aidbox-forms-smart-launch/src/components/pagination.jsx index 7ab94b5..91bb704 100644 --- a/aidbox-forms-smart-launch/src/components/pagination.jsx +++ b/aidbox-forms-smart-launch/src/components/pagination.jsx @@ -9,8 +9,16 @@ import { PaginationPrevious, } from "@/ui/pagination.jsx"; import * as React from "react"; +import { useSearchParams } from "react-router-dom"; export const Pagination = ({ currentPage, totalPages }) => { + const [searchParams] = useSearchParams(); + + const withPage = (page) => { + searchParams.set("page", page); + return `?${searchParams}`; + }; + if (totalPages <= 1) { return null; } @@ -22,13 +30,13 @@ export const Pagination = ({ currentPage, totalPages }) => { {paginationData.prevButtonEnabled && ( - + )} {paginationData.showFirstPageButton && ( - {1} + {1} )} @@ -40,7 +48,7 @@ export const Pagination = ({ currentPage, totalPages }) => { {paginationData.pagesBeforeCurrent.map((page) => ( - {page} + {page} ))} @@ -50,7 +58,7 @@ export const Pagination = ({ currentPage, totalPages }) => { {paginationData.pagesAfterCurrent.map((page) => ( - {page} + {page} ))} @@ -62,7 +70,7 @@ export const Pagination = ({ currentPage, totalPages }) => { {paginationData.showLastPageButton && ( - + {totalPages} @@ -70,7 +78,7 @@ export const Pagination = ({ currentPage, totalPages }) => { {paginationData.nextButtonEnabled && ( - + )} diff --git a/aidbox-forms-smart-launch/src/components/patient-card.jsx b/aidbox-forms-smart-launch/src/components/patient-card.jsx index ab95ca4..96db048 100644 --- a/aidbox-forms-smart-launch/src/components/patient-card.jsx +++ b/aidbox-forms-smart-launch/src/components/patient-card.jsx @@ -11,8 +11,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, DropdownMenuTrigger, } from "@/ui/dropdown-menu.jsx"; import { Button } from "@/ui/button.jsx"; diff --git a/aidbox-forms-smart-launch/src/components/questionnaire-builder.jsx b/aidbox-forms-smart-launch/src/components/questionnaire-builder.jsx index 9566dcb..7b2d03e 100644 --- a/aidbox-forms-smart-launch/src/components/questionnaire-builder.jsx +++ b/aidbox-forms-smart-launch/src/components/questionnaire-builder.jsx @@ -1,18 +1,57 @@ -import { useRef } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { publicBuilderClient } from "@/hooks/use-client.jsx"; +import { useEffect, useRef } from "react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { publicBuilderClient, useClient } from "@/hooks/use-client.jsx"; import { useAwaiter } from "@/hooks/use-awaiter.jsx"; +import { findQuestionnaireWithClient, saveQuestionnaire } from "@/lib/utils.js"; +import { useToast } from "@/hooks/use-toast.js"; export const QuestionnaireBuilder = ({ id }) => { const ref = useRef(); + const client = useClient(); + const { toast } = useToast(); + const toastShown = useRef(false); - const { data: questionnaire } = useQuery({ + const { + data: [usedClient, questionnaire], + } = useQuery({ queryKey: ["questionnaire", id], - queryFn: () => publicBuilderClient.request(`Questionnaire/${id}`), + queryFn: () => findQuestionnaireWithClient(client, id), + }); + + const mutation = useMutation({ + mutationFn: (questionnaire) => saveQuestionnaire(usedClient, questionnaire), + onSuccess: () => { + if (!toastShown.current) { + toastShown.current = true; + toast({ + title: "Questionnaire is autosaved", + description: "All changes are saved automatically", + }); + } + }, }); useAwaiter(ref); + useEffect(() => { + if (usedClient !== publicBuilderClient) { + const current = ref.current; + const handler = (e) => mutation.mutate(e.detail); + + current.addEventListener("change", handler); + + return () => { + current.removeEventListener("change", handler); + }; + } else { + toast({ + title: "This questionnaire is read-only", + description: + "You can't save changes to questionnaires from the library. Please import it first to your EHR to make changes.", + }); + } + }, []); + return ( { hide-edit-theme={true} hide-save-theme={true} hide-convert={true} + hide-save={true} + disable-save={true} ref={ref} value={JSON.stringify(questionnaire)} style={{ diff --git a/aidbox-forms-smart-launch/src/components/questionnaire-preview.jsx b/aidbox-forms-smart-launch/src/components/questionnaire-preview.jsx index 4e82d21..9aa3de9 100644 --- a/aidbox-forms-smart-launch/src/components/questionnaire-preview.jsx +++ b/aidbox-forms-smart-launch/src/components/questionnaire-preview.jsx @@ -1,15 +1,17 @@ -import { publicBuilderClient } from "@/hooks/use-client.jsx"; +import { useClient } from "@/hooks/use-client.jsx"; import { useQuery } from "@tanstack/react-query"; import { useEffect, useRef } from "react"; import { useToast } from "@/hooks/use-toast.js"; import { useAwaiter } from "@/hooks/use-awaiter.jsx"; +import { findQuestionnaire } from "@/lib/utils.js"; export const QuestionnairePreview = ({ id }) => { const ref = useRef(); + const client = useClient(); const { data: questionnaire } = useQuery({ queryKey: ["questionnaire", id], - queryFn: () => publicBuilderClient.request(`Questionnaire/${id}`), + queryFn: () => findQuestionnaire(client, id), }); const { toast } = useToast(); diff --git a/aidbox-forms-smart-launch/src/components/user-dropdown-menu.jsx b/aidbox-forms-smart-launch/src/components/user-dropdown-menu.jsx index bd743e4..0b4e4b2 100644 --- a/aidbox-forms-smart-launch/src/components/user-dropdown-menu.jsx +++ b/aidbox-forms-smart-launch/src/components/user-dropdown-menu.jsx @@ -6,14 +6,13 @@ import { DropdownMenuTrigger, } from "@/ui/dropdown-menu.jsx"; import { Button } from "@/ui/button.jsx"; -import { Avatar, AvatarFallback, AvatarImage } from "@/ui/avatar.jsx"; -import { ChevronDown, Cog, LogOut, Settings2, UserPen } from "lucide-react"; +import { ChevronDown, LogOut, Settings2, UserPen } from "lucide-react"; import * as React from "react"; import { UserAvatar } from "@/components/user-avatar.jsx"; import { useLaunchContext } from "@/hooks/use-launch-context.jsx"; import { constructName } from "@/lib/utils.js"; import { useClient } from "@/hooks/use-client.jsx"; -import { useHref, useNavigate } from "react-router-dom"; +import { useHref } from "react-router-dom"; export const UserDropdownMenu = () => { const { user } = useLaunchContext(); diff --git a/aidbox-forms-smart-launch/src/hooks/use-client.jsx b/aidbox-forms-smart-launch/src/hooks/use-client.jsx index b084ac1..c23e414 100644 --- a/aidbox-forms-smart-launch/src/hooks/use-client.jsx +++ b/aidbox-forms-smart-launch/src/hooks/use-client.jsx @@ -14,6 +14,8 @@ const scope = [ "launch/questionnaire", // Request Questionnaire to be included in the launch context "launch/questionnaireresponse", // Request QuestionnaireResponse to be included in the launch context + "user/Questionnaire.crus", + "patient/Patient.r", // Request read access to Patient resource "patient/QuestionnaireResponse.crus", // Request create, read, update access to QuestionnaireResponse resource ].join(" "); diff --git a/aidbox-forms-smart-launch/src/hooks/use-launch-context.jsx b/aidbox-forms-smart-launch/src/hooks/use-launch-context.jsx index b02276d..000233f 100644 --- a/aidbox-forms-smart-launch/src/hooks/use-launch-context.jsx +++ b/aidbox-forms-smart-launch/src/hooks/use-launch-context.jsx @@ -1,5 +1,5 @@ -import { createContext, useContext, useEffect, useState } from "react"; -import { useClient, publicBuilderClient } from "@/hooks/use-client.jsx"; +import { createContext, useContext } from "react"; +import { publicBuilderClient, useClient } from "@/hooks/use-client.jsx"; import { useQuery } from "@tanstack/react-query"; const readLaunchResource = async (client, resourceType) => { diff --git a/aidbox-forms-smart-launch/src/lib/utils.js b/aidbox-forms-smart-launch/src/lib/utils.js index cde4e40..235fffa 100644 --- a/aidbox-forms-smart-launch/src/lib/utils.js +++ b/aidbox-forms-smart-launch/src/lib/utils.js @@ -165,7 +165,9 @@ export function saveQuestionnaireResponse( body: JSON.stringify({ ...questionnaireResponse, // Plugging questionnaire.id in because SMART Health IT requires QRs to have Questionnaire/{id} as reference - questionnaire: `Questionnaire/${questionnaire.id}`, + questionnaire: questionnaire.url + ? questionnaire.url + : `Questionnaire/${questionnaire.id}`, meta: { ...questionnaireResponse.meta, source: "https://aidbox.github.io/examples/aidbox-forms-smart-launch", @@ -174,6 +176,32 @@ export function saveQuestionnaireResponse( }); } +export function saveQuestionnaire(client, questionnaire) { + console.log({ client }); + + let url = "Questionnaire"; + let method = "POST"; + + if (questionnaire.id) { + url += `/${questionnaire.id}`; + method = "PUT"; + } + + return client.request({ + url, + method, + headers: { "Content-Type": "application/fhir+json" }, + body: JSON.stringify(questionnaire), + }); +} + +export function deleteQuestionnaire(client, questionnaire) { + return client.request({ + url: `Questionnaire/${questionnaire.id}`, + method: "DELETE", + }); +} + export async function createQuestionnaireResponse({ client, questionnaire, @@ -231,14 +259,24 @@ export function unbundle(result) { return resource; } -export async function findQuestionnaire(client, ref) { +export async function findQuestionnaireWithClient(client, ref) { const query = ref.startsWith("http") ? `Questionnaire?url=${ref.replace(/\|.*$/, "")}` : `Questionnaire/${ref.replace(/^Questionnaire\//, "")}`; return Promise.any([ - publicBuilderClient.request(query).then(unbundle), - client.request(query).then(unbundle), - publicBuilderClient.request(ref).then(unbundle), + publicBuilderClient + .request(query) + .then((result) => [publicBuilderClient, unbundle(result)]), + client.request(query).then((result) => [client, unbundle(result)]), + publicBuilderClient + .request(ref) + .then((result) => [publicBuilderClient, unbundle(result)]), ]); } + +export async function findQuestionnaire(client, ref) { + return findQuestionnaireWithClient(client, ref).then( + ([, questionnaire]) => questionnaire, + ); +} diff --git a/aidbox-forms-smart-launch/src/pages/error.jsx b/aidbox-forms-smart-launch/src/pages/error.jsx index 7eef8d7..3424618 100644 --- a/aidbox-forms-smart-launch/src/pages/error.jsx +++ b/aidbox-forms-smart-launch/src/pages/error.jsx @@ -1,7 +1,4 @@ -import { useNavigate, useRouteError } from "react-router-dom"; -import { useEffect, useLayoutEffect } from "react"; -import { ChevronRight, Stethoscope, User } from "lucide-react"; -import { Button } from "@/ui/button.jsx"; +import { useRouteError } from "react-router-dom"; import { LaunchInstructions } from "@/components/launch-instructions.jsx"; export function Error() { diff --git a/aidbox-forms-smart-launch/src/pages/questionnaire-editor.jsx b/aidbox-forms-smart-launch/src/pages/questionnaire-editor.jsx index 4a1bf7f..8a97d2d 100644 --- a/aidbox-forms-smart-launch/src/pages/questionnaire-editor.jsx +++ b/aidbox-forms-smart-launch/src/pages/questionnaire-editor.jsx @@ -1,6 +1,5 @@ import { QuestionnaireBuilder } from "@/components/questionnaire-builder.jsx"; import { useParams } from "react-router-dom"; -import { Suspense } from "react"; export const QuestionnaireEditor = () => { const { id } = useParams(); diff --git a/aidbox-forms-smart-launch/src/pages/questionnaire-response.jsx b/aidbox-forms-smart-launch/src/pages/questionnaire-response.jsx index 77f29ea..f41ed6c 100644 --- a/aidbox-forms-smart-launch/src/pages/questionnaire-response.jsx +++ b/aidbox-forms-smart-launch/src/pages/questionnaire-response.jsx @@ -1,5 +1,5 @@ import { useParams } from "react-router-dom"; -import { publicBuilderClient, useClient } from "@/hooks/use-client.jsx"; +import { useClient } from "@/hooks/use-client.jsx"; import { useMutation, useQuery } from "@tanstack/react-query"; import { useAwaiter } from "@/hooks/use-awaiter.jsx"; import { useEffect, useRef } from "react"; diff --git a/aidbox-forms-smart-launch/src/pages/questionnaire-responses.jsx b/aidbox-forms-smart-launch/src/pages/questionnaire-responses.jsx index 37096a6..948bae3 100644 --- a/aidbox-forms-smart-launch/src/pages/questionnaire-responses.jsx +++ b/aidbox-forms-smart-launch/src/pages/questionnaire-responses.jsx @@ -1,5 +1,5 @@ -import { useMutation, useQueries, useQuery } from "@tanstack/react-query"; -import { publicBuilderClient, useClient } from "@/hooks/use-client.jsx"; +import { useQueries, useQuery } from "@tanstack/react-query"; +import { useClient } from "@/hooks/use-client.jsx"; import { DataTable } from "@/components/data-table.jsx"; import { Button } from "@/ui/button"; import { @@ -10,36 +10,12 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/ui/dropdown-menu"; -import { - Copy, - Edit, - Eye, - Loader2, - MoreHorizontal, - Plus, - Trash, - Trash2, -} from "lucide-react"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/ui/dialog.jsx"; +import { Copy, Edit, MoreHorizontal } from "lucide-react"; import * as React from "react"; -import { Suspense, useState } from "react"; -import { Link, useNavigate, useSearchParams } from "react-router-dom"; -import { QuestionnairePreview } from "@/components/questionnaire-preview.jsx"; -import { Loading } from "@/components/loading.jsx"; +import { useNavigate, useSearchParams } from "react-router-dom"; import { Pagination } from "@/components/pagination.jsx"; import { useLaunchContext } from "@/hooks/use-launch-context.jsx"; -import { - constructName, - createQuestionnaireResponse, - findQuestionnaire, -} from "@/lib/utils.js"; -import { useToast } from "@/hooks/use-toast.js"; -import { Spinner } from "@/components/spinner.jsx"; +import { constructName, findQuestionnaire } from "@/lib/utils.js"; export const QuestionnaireResponses = () => { const [searchParams] = useSearchParams(); diff --git a/aidbox-forms-smart-launch/src/pages/questionnaires.jsx b/aidbox-forms-smart-launch/src/pages/questionnaires.jsx index a442ff2..7e2e395 100644 --- a/aidbox-forms-smart-launch/src/pages/questionnaires.jsx +++ b/aidbox-forms-smart-launch/src/pages/questionnaires.jsx @@ -1,4 +1,4 @@ -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { publicBuilderClient, useClient } from "@/hooks/use-client.jsx"; import { DataTable } from "@/components/data-table.jsx"; import { Button } from "@/ui/button"; @@ -7,10 +7,21 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/ui/dropdown-menu"; -import { Copy, Edit, Eye, Loader2, MoreHorizontal, Plus } from "lucide-react"; +import { + ChevronDown, + Copy, + Edit, + Eye, + Import, + MoreHorizontal, + Plus, + Trash2, +} from "lucide-react"; import { Dialog, DialogContent, @@ -24,12 +35,21 @@ import { QuestionnairePreview } from "@/components/questionnaire-preview.jsx"; import { Loading } from "@/components/loading.jsx"; import { Pagination } from "@/components/pagination.jsx"; import { useLaunchContext } from "@/hooks/use-launch-context.jsx"; -import { constructName, createQuestionnaireResponse } from "@/lib/utils.js"; +import { + constructName, + createQuestionnaireResponse, + deleteQuestionnaire, + saveQuestionnaire, +} from "@/lib/utils.js"; import { useToast } from "@/hooks/use-toast.js"; import { Spinner } from "@/components/spinner.jsx"; +import { ToastAction } from "@/ui/toast.jsx"; export const Questionnaires = () => { - const [searchParams] = useSearchParams(); + const queryClient = useQueryClient(); + const [searchParams, setSearchParams] = useSearchParams(); + + console.log(searchParams.toString()); const { user, patient, encounter } = useLaunchContext(); const client = useClient(); const { toast } = useToast(); @@ -38,15 +58,29 @@ export const Questionnaires = () => { const currentPage = Number(searchParams.get("page")) || 1; const pageSize = 15; + const source = searchParams.get("source") || "library"; + + const setSource = (value) => { + searchParams.set("source", value); + setSearchParams(searchParams); + }; + + const currentQueryKey = ["questionnaires", source, currentPage]; + const firstPageOfEhrQueryKey = ["questionnaires", "ehr", 1]; + const { data } = useQuery({ - queryKey: ["questionnaires", currentPage], + queryKey: currentQueryKey, queryFn: () => - publicBuilderClient.request( - `Questionnaire?_count=${pageSize}&page=${currentPage}`, - ), + source === "library" + ? publicBuilderClient.request( + `Questionnaire?_count=${pageSize}&page=${currentPage}`, + ) + : client.request( + `Questionnaire?_count=${pageSize}&_getpagesoffset=${pageSize * (currentPage - 1)}`, + ), }); - const mutation = useMutation({ + const createQuestionnaireMutation = useMutation({ mutationFn: createQuestionnaireResponse, onSuccess: (qr) => { toast({ @@ -58,11 +92,114 @@ export const Questionnaires = () => { }, }); + const importQuestionnaireMutation = useMutation({ + mutationFn: (questionnaire) => saveQuestionnaire(client, questionnaire), + onMutate: async (questionnaire) => { + await queryClient.cancelQueries({ + queryKey: firstPageOfEhrQueryKey, + }); + + const previousData = queryClient.getQueryData(firstPageOfEhrQueryKey); + + queryClient.setQueryData(firstPageOfEhrQueryKey, (data) => ({ + ...data, + entry: [ + { + resource: questionnaire, + }, + ...(data?.entry || []), + ], + })); + + navigate("?source=ehr&page=1"); + + return { previousData }; + }, + onSuccess: async (data, variables, context) => { + await queryClient.cancelQueries({ + queryKey: firstPageOfEhrQueryKey, + }); + + queryClient.setQueryData(firstPageOfEhrQueryKey, { + ...context.previousData, + entry: [ + { + resource: data, + }, + ...(context.previousData?.entry || []), + ], + }); + + toast({ + title: "Questionnaire imported", + description: `Questionnaire imported successfully`, + action: ( + { + navigate(`/questionnaires/${data.id}`); + }} + > + Edit + + ), + }); + }, + onError: (err, newTodo, context) => { + queryClient.setQueryData(firstPageOfEhrQueryKey, context.previousData); + + toast({ + variant: "destructive", + title: "Import questionnaire", + description: `Unable to import questionnaire: ${err.message}`, + }); + }, + onSettled: () => { + void queryClient.invalidateQueries({ + queryKey: firstPageOfEhrQueryKey, + }); + }, + }); + + const deleteQuestionnaireMutation = useMutation({ + mutationFn: (questionnaire) => deleteQuestionnaire(client, questionnaire), + onMutate: async (questionnaire) => { + await queryClient.cancelQueries({ queryKey: currentQueryKey }); + const previousData = queryClient.getQueryData(currentQueryKey); + + queryClient.setQueryData(currentQueryKey, (data) => ({ + ...data, + entry: data.entry?.filter((x) => x.resource.id !== questionnaire.id), + })); + + return { previousData }; + }, + onSuccess: () => { + toast({ + title: "Questionnaire deleted", + description: `Questionnaire deleted successfully`, + }); + }, + + onError: (err, newTodo, context) => { + queryClient.setQueryData(currentQueryKey, context.previousData); + + toast({ + variant: "destructive", + title: "Delete questionnaire", + description: `Unable to delete questionnaire: ${err.message}`, + }); + }, + onSettled: () => { + void queryClient.invalidateQueries({ queryKey: currentQueryKey }); + }, + }); + const totalPages = Math.ceil(data.total / pageSize); const questionnaires = data.entry?.map((x) => ({ - publisher: "Health Samurai", + publisher: source === "library" ? "Health Samurai" : undefined, ...x.resource, })) || []; @@ -89,8 +226,10 @@ export const Questionnaires = () => { id: "actions", cell: ({ row: { original: questionnaire } }) => { const loading = - mutation.isPending && - mutation.variables.questionnaire.id === questionnaire.id; + (createQuestionnaireMutation.isPending && + createQuestionnaireMutation.variables.questionnaire.id === + questionnaire.id) || + questionnaire.id === undefined; // optimistically importing questionnaire return loading ? ( @@ -127,11 +266,42 @@ export const Questionnaires = () => { Edit questionnaire + {source === "library" && ( + { + importQuestionnaireMutation.mutate({ + ...questionnaire, + id: undefined, + }); + }} + > + + Import questionnaire + + )} + {source !== "library" && ( + { + deleteQuestionnaireMutation.mutate(questionnaire); + }} + > + + Delete questionnaire + + )} { - mutation.mutate({ + createQuestionnaireMutation.mutate({ client, - questionnaire: questionnaire, + questionnaire: { + ...questionnaire, + // used to link questionnaire response + url: + source === "library" + ? `${publicBuilderClient.state.serverUrl}/Questionnaire/${questionnaire.id}` + : undefined, + }, subject: patient, encounter, author: user, @@ -150,6 +320,24 @@ export const Questionnaires = () => { return (
+
+ + + + + + + + Forms Library + + EHR + + + +