Skip to content

Commit

Permalink
Add questionnaire response list
Browse files Browse the repository at this point in the history
  • Loading branch information
olimsaidov committed Nov 22, 2024
1 parent 5aaedf5 commit b1dc6c7
Show file tree
Hide file tree
Showing 6 changed files with 357 additions and 6 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import { getCurrentAidbox } from "@/lib/server/smart";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { PageHeader } from "@/components/page-header";
import { PageSizeSelect } from "@/components/page-size-select";
import { Pager } from "@/components/pager";
import {
Bundle,
Patient,
Practitioner,
Questionnaire,
QuestionnaireResponse,
} from "fhir/r4";
import {
constructName,
getFirst,
isDefined,
typeSafeObjectFromEntries,
} from "@/lib/utils";
import { decidePageSize } from "@/lib/server/utils";
import { revalidatePath } from "next/cache";
import { QuestionnaireResponsesActions } from "@/components/questionnaire-responses-actions";

interface PageProps {
searchParams: Promise<{
page?: string;
pageSize?: string;
}>;
}

export default async function QuestionnaireResponsesPage({
searchParams,
}: PageProps) {
const aidbox = await getCurrentAidbox();
const params = await searchParams;

const pageSize = await decidePageSize(params.pageSize);
const page = Number(params.page) || 1;

const response = await aidbox
.get(
"fhir/QuestionnaireResponse?_include=QuestionnaireResponse.questionnaire",
{
searchParams: {
_count: pageSize,
_page: page,
},
},
)
.json<Bundle<QuestionnaireResponse>>();

const resources =
response.entry?.map((entry) => entry.resource)?.filter(isDefined) || [];

const questionnaires = typeSafeObjectFromEntries(
await Promise.all(
resources
.map((resource) => resource.questionnaire)
.filter(isDefined)
.map(async (canonical) => {
const [url, version] = canonical.split("|");

return [
canonical,
await aidbox
.get(
`fhir/Questionnaire?url=${url}${version ? `&version=${version}` : ""}`,
)
.json<Questionnaire | Bundle<Questionnaire>>()
.then(getFirst)
.catch(() => undefined),
];
}),
),
);

const patients = typeSafeObjectFromEntries(
await Promise.all(
resources
.map((resource) => resource.subject?.reference)
.filter(isDefined)
.map(async (reference) => {
return [
reference,
await aidbox
.get(`fhir/${reference}`)
.json<Patient>()
.catch(() => undefined),
];
}),
),
);

const authors = typeSafeObjectFromEntries(
await Promise.all(
resources
.map((resource) => resource.author?.reference)
.filter(isDefined)
.map(async (reference) => [
reference,
await aidbox
.get(`fhir/${reference}`)
.json<Practitioner>()
.catch(() => null),
]),
),
);

const total = response.total || 0;
const totalPages = Math.ceil(total / pageSize);

async function deleteQuestionnaireResponse({ id }: QuestionnaireResponse) {
"use server";

const aidbox = await getCurrentAidbox();
await aidbox.delete(`fhir/QuestionnaireResponse/${id}`).json();
revalidatePath("/questionnaire-responses");
}

return (
<>
<PageHeader
items={[{ href: "/", label: "Home" }, { label: "Questionnaires" }]}
/>
<div className="flex-1 p-6">
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="pl-6">Patient</TableHead>
<TableHead>Questionnaire</TableHead>
<TableHead>Author</TableHead>
<TableHead>Status</TableHead>
<TableHead>Last modified</TableHead>
<TableHead className="w-[1%] pr-6">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{resources.map((resource) => {
const subject = resource.subject?.reference
? patients[resource.subject.reference]
: undefined;

const questionnaire = resource.questionnaire
? questionnaires[resource.questionnaire]
: undefined;

const author = resource.author?.reference
? authors[resource.author.reference]
: undefined;

return (
<TableRow key={resource.id}>
<TableCell className="pl-6">
{constructName(subject?.name)}
</TableCell>
<TableCell>{questionnaire?.title}</TableCell>
<TableCell>{constructName(author?.name)}</TableCell>
<TableCell>{resource.status}</TableCell>
<TableCell>
{resource.meta?.lastUpdated &&
new Date(resource.meta.lastUpdated).toLocaleString()}
</TableCell>
<TableCell className="text-right pr-6">
<QuestionnaireResponsesActions
questionnaire={questionnaire}
questionnaireResponse={resource}
onDeleteAction={deleteQuestionnaireResponse}
/>
</TableCell>
</TableRow>
);
})}
{!resources.length && (
<TableRow>
<TableCell colSpan={4} className="text-center py-4">
No questionnaire responses found
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>

<div className="flex items-center justify-between space-x-2 py-4">
<div className="flex items-center gap-4">
{total ? (
<div className="text-sm text-muted-foreground">{`Showing ${
(page - 1) * pageSize + 1
}-${Math.min(
page * pageSize,
total,
)} of ${total} practitioners`}</div>
) : null}
<PageSizeSelect currentSize={pageSize} />
</div>
<Pager currentPage={page} totalPages={totalPages} />
</div>
</div>
</>
);
}
2 changes: 2 additions & 0 deletions aidbox-forms-smart-launch-2/src/components/forms-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export function FormsRenderer({
onChange,
}: {
questionnaire: Questionnaire;
questionnaireResponse?: QuestionnaireResponse;
onChange?: (questionnaireResponse: QuestionnaireResponse) => void;
}) {
const ref = useRef<HTMLIFrameElement>(null);
Expand All @@ -33,6 +34,7 @@ export function FormsRenderer({
<aidbox-form-renderer
ref={ref}
questionnaire={JSON.stringify(questionnaire)}
questionnaire-response={JSON.stringify(questionnaire)}
style={{
width: "100%",
height: "100%",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"use client";

import { Questionnaire, QuestionnaireResponse } from "fhir/r4";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { Copy, Edit, Eye, MoreHorizontal, Trash2 } from "lucide-react";
import Link from "next/link";
import { Suspense, useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { FormsRenderer } from "@/components/forms-renderer";
import { useToast } from "@/hooks/use-toast";
import { Spinner } from "@/components/spinner";

export function QuestionnaireResponsesActions({
questionnaireResponse,
questionnaire,
onDeleteAction,
}: {
questionnaireResponse: QuestionnaireResponse;
questionnaire?: Questionnaire;
onDeleteAction?: (
questionnaireResponse: QuestionnaireResponse,
) => Promise<void>;
}) {
const [viewing, setViewing] = useState(false);
const { toast } = useToast();

return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
onClick={() =>
navigator &&
navigator.clipboard.writeText(questionnaireResponse.id as string)
}
>
<Copy />
Copy ID
</DropdownMenuItem>
<DropdownMenuSeparator />

{questionnaire && (
<DropdownMenuItem onClick={() => setViewing(true)}>
<Eye />
Preview
</DropdownMenuItem>
)}

<DropdownMenuItem asChild>
<Link href={`/questionnaire-responses/${questionnaireResponse.id}`}>
<Edit />
Edit
</Link>
</DropdownMenuItem>

<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={async () => {
if (onDeleteAction) {
await onDeleteAction(questionnaireResponse);

toast({
title: "Questionnaire deleted",
description: `Questionnaire deleted successfully`,
});
}
}}
>
<Trash2 />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

<Dialog onOpenChange={setViewing} open={viewing}>
<DialogContent className="flex flex-col max-w-[calc(100vw_-_4rem)] h-[calc(100vh_-_4rem)]">
<DialogHeader>
<DialogTitle>Preview</DialogTitle>
</DialogHeader>
{viewing && questionnaire && (
<Suspense fallback={<Spinner expand="true" />}>
<FormsRenderer
questionnaire={questionnaire}
questionnaireResponse={questionnaireResponse}
onChange={() => {
toast({
title: "Not saved",
description: "This is a preview, changes will not be saved",
});
}}
/>
</Suspense>
)}
</DialogContent>
</Dialog>
</>
);
}
6 changes: 1 addition & 5 deletions aidbox-forms-smart-launch-2/src/lib/server/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,7 @@ import {
upsertOrganization,
} from "@/lib/server/aidbox";
import { getCapabilityStatement } from "@/lib/server/smart";
import { readableStreamToObject } from "@/lib/utils";

function isBundle(resource: Resource): resource is Bundle<Resource> {
return "resourceType" in resource && resource.resourceType === "Bundle";
}
import { isBundle, readableStreamToObject } from "@/lib/utils";

async function extractPatient(client: Client) {
const patient = await client.patient.read().catch((e) => {
Expand Down
28 changes: 27 additions & 1 deletion aidbox-forms-smart-launch-2/src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { Address, HumanName } from "fhir/r4";
import { Address, Bundle, HumanName, Resource } from "fhir/r4";
import crypto from "crypto";
import { SMART_LAUNCH_TYPES } from "@/lib/constants";

Expand Down Expand Up @@ -86,3 +86,29 @@ export function createSmartAppLauncherUrl({
export function isDefined<T>(value: T): value is NonNullable<T> {
return value !== undefined && value !== null;
}

export function isBundle<T extends Resource = Resource>(
resource: any,
): resource is Bundle<T> {
return "resourceType" in resource && resource.resourceType === "Bundle";
}

export function getFirst<T extends Resource = Resource>(
resource: T | Bundle<T>,
): T {
if (isBundle<T>(resource)) {
const first = resource.entry?.[0].resource;
if (!first) {
throw new Error("Resource not found");
}
return first;
} else {
return resource;
}
}

export function typeSafeObjectFromEntries<
const T extends ReadonlyArray<readonly [PropertyKey, unknown]>,
>(entries: T): { [K in T[number] as K[0]]: K[1] } {
return Object.fromEntries(entries) as { [K in T[number] as K[0]]: K[1] };
}
Loading

0 comments on commit b1dc6c7

Please sign in to comment.