diff --git a/app/(dashboard)/dashboard/_components/ActiveProtocolSwitch.tsx b/app/(dashboard)/dashboard/_components/ActiveProtocolSwitch.tsx new file mode 100644 index 000000000..238e6edbb --- /dev/null +++ b/app/(dashboard)/dashboard/_components/ActiveProtocolSwitch.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { trpc } from '~/app/_trpc/client'; +import { Switch } from '~/components/ui/switch'; + +const ActiveProtocolSwitch = ({ + initialData, + hash, +}: { + initialData: boolean; + hash: string; +}) => { + const utils = trpc.useContext(); + const router = useRouter(); + + const { data: isActive } = trpc.protocol.getActive.useQuery(hash, { + initialData, + }); + + const { mutateAsync: setActive } = trpc.protocol.setActive.useMutation({ + async onMutate(variables) { + const { input: newState, hash } = variables; + await utils.protocol.getActive.cancel(); + + const previousState = utils.protocol.getActive.getData(); + + if (hash) { + utils.protocol.getActive.setData(hash, newState); + } + + return previousState; + }, + onError: (err, _newState, previousState) => { + utils.protocol.getActive.setData(hash, previousState); + // eslint-disable-next-line no-console + console.error(err); + }, + onSuccess: () => { + router.refresh(); + }, + }); + + const handleCheckedChange = async () => { + await setActive({ input: !isActive, hash }); + }; + + return ( + void handleCheckedChange()} + /> + ); +}; +export default ActiveProtocolSwitch; diff --git a/app/(dashboard)/dashboard/_components/NavigationBar.tsx b/app/(dashboard)/dashboard/_components/NavigationBar.tsx index c10aebfb5..c2387b064 100644 --- a/app/(dashboard)/dashboard/_components/NavigationBar.tsx +++ b/app/(dashboard)/dashboard/_components/NavigationBar.tsx @@ -52,7 +52,7 @@ export function NavigationBar() { Home Protocols diff --git a/app/(dashboard)/dashboard/_components/ProtocolUploader.tsx b/app/(dashboard)/dashboard/_components/ProtocolUploader.tsx index 1aef72520..80c548f34 100644 --- a/app/(dashboard)/dashboard/_components/ProtocolUploader.tsx +++ b/app/(dashboard)/dashboard/_components/ProtocolUploader.tsx @@ -7,19 +7,6 @@ import { useState, useCallback } from 'react'; import { importProtocol } from '../_actions/importProtocol'; import { Button } from '~/components/ui/Button'; -import { Switch } from '~/components/ui/switch'; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, -} from '~/components/ui/form'; - -const ActivateProtocolFormSchema = z.object({ - mark_protocol_active: z.boolean().default(false), -}); const { useUploadThing } = generateReactHelpers(); @@ -31,11 +18,8 @@ import { DialogTitle, } from '~/components/ui/dialog'; import type { UploadFileResponse } from 'uploadthing/client'; -import React from 'react'; import { Collapsible, CollapsibleContent } from '~/components/ui/collapsible'; -import { z } from 'zod'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { useForm } from 'react-hook-form'; +import ActiveProtocolSwitch from '~/app/(dashboard)/dashboard/_components/ActiveProtocolSwitch'; import { trpc } from '~/app/_trpc/client'; export default function ProtocolUploader({ @@ -51,11 +35,7 @@ export default function ProtocolUploader({ progress: true, error: 'dsfsdf', }); - const { mutate: setActive } = trpc.protocol.setActive.useMutation({ - onSuccess: () => { - setOpen(false); - }, - }); + const utils = trpc.useContext(); const handleUploadComplete = async ( res: UploadFileResponse[] | undefined, @@ -74,25 +54,17 @@ export default function ProtocolUploader({ }); const { error, success } = await importProtocol(firstFile); - if (error) { + if (error || !success) { setDialogContent({ title: 'Protocol import', description: 'Error importing protocol', progress: false, - error: error, + error: error ?? 'Unkown error occured', }); return; } - if (!success) { - setDialogContent({ - title: 'Protocol import', - description: 'Error importing protocol', - progress: false, - error: 'Unkown error occured', - }); - return; - } + await utils.protocol.get.lastUploaded.refetch(); setDialogContent({ title: 'Protocol import', @@ -154,17 +126,16 @@ export default function ProtocolUploader({ accept: { 'application/octect-stream': ['.netcanvas'] }, }); - const form = useForm>({ - resolver: zodResolver(ActivateProtocolFormSchema), - }); - - function onSubmit(data: z.infer) { - setActive({ setActive: data.mark_protocol_active }); + function handleFinishImport() { if (typeof onUploaded === 'function') { onUploaded(); } + setOpen(false); } + const { data: lastUploadedProtocol } = + trpc.protocol.get.lastUploaded.useQuery(); + return ( <>
)} - {!dialogContent.progress && !dialogContent.error && ( -
- void form.handleSubmit(onSubmit)} - className="w-full space-y-6" - > + {!dialogContent.progress && + !dialogContent.error && + lastUploadedProtocol && ( +
- ( - -
- - Mark protocol as active? - - - Only one protocol may be active at a time. If you - already have an active protocol, activating this - one will make it inactive. - -
- - - -
- )} - /> +
+
+ +

+ Only one protocol may be active at a time. If you + already have an active protocol, activating this one + will make it inactive. +

+
+
+ +
+
- - - - )} + +
+ )} diff --git a/app/(dashboard)/dashboard/_components/ProtocolsTable/Columns.tsx b/app/(dashboard)/dashboard/_components/ProtocolsTable/Columns.tsx index 11dc09ac5..9e854dcad 100644 --- a/app/(dashboard)/dashboard/_components/ProtocolsTable/Columns.tsx +++ b/app/(dashboard)/dashboard/_components/ProtocolsTable/Columns.tsx @@ -1,7 +1,6 @@ 'use client'; import { type ColumnDef, flexRender } from '@tanstack/react-table'; -import type { Protocol } from '@prisma/client'; import { ActionsDropdown } from '~/components/DataTable/ActionsDropdown'; import { Checkbox } from '~/components/ui/checkbox'; import { Settings } from 'lucide-react'; @@ -13,8 +12,14 @@ import { TooltipProvider, TooltipTrigger, } from '~/components/ui/tooltip'; +import { DropdownMenuItem } from '~/components/ui/dropdown-menu'; +import ActiveProtocolSwitch from '~/app/(dashboard)/dashboard/_components/ActiveProtocolSwitch'; -export const ProtocolColumns: ColumnDef[] = [ +import type { ProtocolWithInterviews } from '~/shared/types'; + +export const ProtocolColumns = ( + handleDelete: (data: ProtocolWithInterviews[]) => void, +): ColumnDef[] => [ { id: 'select', header: ({ table }) => ( @@ -39,13 +44,27 @@ export const ProtocolColumns: ColumnDef[] = [ header: ({ column }) => { return ; }, + cell: ({ row }) => { + return ( +
+ {flexRender(row.original.name, row)} +
+ ); + }, }, { accessorKey: 'description', header: 'Description', cell: ({ row }) => { return ( -
+
{flexRender(row.original.description, row)}
); @@ -56,26 +75,43 @@ export const ProtocolColumns: ColumnDef[] = [ header: ({ column }) => { return ; }, - cell: ({ row }) => { - const date = new Date(row.original.importedAt); - const isoString = date.toISOString().replace('T', ' ').replace('Z', ''); - return isoString + ' UTC'; - }, + cell: ({ row }) => ( +
+ {new Date(row.original.importedAt).toLocaleString()} +
+ ), }, { accessorKey: 'lastModified', header: ({ column }) => { return ; }, - cell: ({ row }) => { - const date = new Date(row.original.lastModified); - const isoString = date.toISOString().replace('T', ' ').replace('Z', ''); - return isoString + ' UTC'; - }, + cell: ({ row }) => ( +
+ {new Date(row.original.lastModified).toLocaleString()} +
+ ), }, { accessorKey: 'schemaVersion', header: 'Schema Version', + cell: ({ row }) => ( +
+ {row.original.schemaVersion} +
+ ), + }, + { + accessorKey: 'active', + header: 'Active', + cell: ({ row }) => { + return ( + + ); + }, }, { id: 'actions', @@ -86,13 +122,29 @@ export const ProtocolColumns: ColumnDef[] = [ -

Edit or delete an individual protocol.

+

Delete an individual protocol.

), - cell: () => { - return ; + cell: ({ row }) => { + return ( + void handleDelete([row.original])} + > + Delete + + ), + }, + ]} + /> + ); }, }, ]; diff --git a/app/(dashboard)/dashboard/_components/ProtocolsTable/DeleteProtocols.tsx b/app/(dashboard)/dashboard/_components/ProtocolsTable/DeleteProtocols.tsx new file mode 100644 index 000000000..6f06a78c5 --- /dev/null +++ b/app/(dashboard)/dashboard/_components/ProtocolsTable/DeleteProtocols.tsx @@ -0,0 +1,104 @@ +import { Loader2, AlertCircle, Trash2 } from 'lucide-react'; +import { Button } from '~/components/ui/Button'; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '~/components/ui/alert-dialog'; +import { Alert, AlertDescription, AlertTitle } from '~/components/ui/Alert'; +import type { ProtocolWithInterviews } from '~/shared/types'; + +interface DeleteProtocolProps { + open: boolean; + onConfirm: () => Promise; + onCancel: () => void; + selectedProtocols: ProtocolWithInterviews[]; + isDeleting: boolean; +} + +export const DeleteProtocol = ({ + open, + onConfirm, + onCancel, + selectedProtocols, + isDeleting, +}: DeleteProtocolProps) => { + const hasInterviews = selectedProtocols.some( + (protocol) => protocol.interviews.length > 0, + ); + + const hasInterviewsNotYetExported = selectedProtocols.some((protocol) => + protocol.interviews.some((interview) => !interview.exportTime), + ); + + return ( + + + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete{' '} + + {selectedProtocols.length}{' '} + {selectedProtocols.length > 1 ? <>protocols. : <>protocol.} + + + {hasInterviews && !hasInterviewsNotYetExported && ( + + + Warning + + {selectedProtocols.length > 1 ? ( + <> + One or more of the selected protocols have interview data + that will also be deleted. + + ) : ( + <> + The selected protocol has interview data that will also be + deleted. + + )} + + + )} + {hasInterviewsNotYetExported && ( + + + Warning + + {selectedProtocols.length > 1 ? ( + <> + One or more of the selected protocols have interview data + that has not yet been exported. Deleting + these protocols will also delete its interview data. + + ) : ( + <> + The selected protocol has interview data that + has not yet been exported. Deleting this + protocol will also delete its interview data. + + )} + + + )} + + + + Cancel + + + + + + ); +}; diff --git a/app/(dashboard)/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx b/app/(dashboard)/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx index 0a436ba67..313b73ecd 100644 --- a/app/(dashboard)/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx +++ b/app/(dashboard)/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx @@ -1,19 +1,71 @@ +'use client'; + import { DataTable } from '~/components/DataTable/DataTable'; import { ProtocolColumns } from './Columns'; -import { trpc } from '~/app/_trpc/server'; - -export const ProtocolsTable = async () => { - const protocols = await trpc.protocol.get.all.query(undefined, { - context: { - revalidate: 0, +import { trpc } from '~/app/_trpc/client'; +import { DeleteProtocol } from '~/app/(dashboard)/dashboard/_components/ProtocolsTable/DeleteProtocols'; +import { useState } from 'react'; +import type { ProtocolWithInterviews } from '~/shared/types'; +import ImportProtocolModal from '~/app/(dashboard)/dashboard/protocols/_components/ImportProtocolModal'; +export const ProtocolsTable = ({ + initialData, +}: { + initialData: ProtocolWithInterviews[]; +}) => { + const { mutateAsync: deleteProtocols, isLoading: isDeleting } = + trpc.protocol.delete.byHash.useMutation(); + const { + isLoading, + refetch, + data: protocols, + } = trpc.protocol.get.all.useQuery(undefined, { + initialData, + refetchOnMount: false, + onError(error) { + // eslint-disable-next-line no-console + console.error(error); }, }); + const [showAlertDialog, setShowAlertDialog] = useState(false); + const [protocolsToDelete, setProtocolsToDelete] = useState< + ProtocolWithInterviews[] + >([]); + + const utils = trpc.useContext(); + + const handleDelete = (data: ProtocolWithInterviews[]) => { + setProtocolsToDelete(data); + setShowAlertDialog(true); + }; + + const handleConfirm = async () => { + await deleteProtocols(protocolsToDelete.map((d) => d.hash)); + await refetch(); + setShowAlertDialog(false); + }; + + const handleUploaded = () => { + void utils.protocol.get.all.refetch(); + }; + return ( - + <> + {isLoading &&
Loading...
} + + + setShowAlertDialog(false)} + onConfirm={handleConfirm} + selectedProtocols={protocolsToDelete} + isDeleting={isDeleting} + /> + ); }; diff --git a/app/(dashboard)/dashboard/interviews/page.tsx b/app/(dashboard)/dashboard/interviews/page.tsx index ce917564a..69f7fcb7e 100644 --- a/app/(dashboard)/dashboard/interviews/page.tsx +++ b/app/(dashboard)/dashboard/interviews/page.tsx @@ -1,6 +1,6 @@ import { InterviewsTable } from '~/app/(dashboard)/dashboard/_components/InterviewsTable/InterviewsTable'; -const ParticipantPage = () => { +const InterviewPage = () => { return (

Interview management view

@@ -9,4 +9,4 @@ const ParticipantPage = () => { ); }; -export default ParticipantPage; +export default InterviewPage; diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(dashboard)/dashboard/page.tsx index 7aacb3055..26b257a30 100644 --- a/app/(dashboard)/dashboard/page.tsx +++ b/app/(dashboard)/dashboard/page.tsx @@ -1,4 +1,3 @@ -import ProtocolUploader from './_components/ProtocolUploader'; import ResetButton from './_components/ResetButton'; import AnonymousRecruitment from './_components/AnonymousRecruitment'; import Link from 'next/link'; @@ -15,7 +14,6 @@ function Home() { - ); diff --git a/app/(dashboard)/dashboard/protocols/_components/ImportProtocolModal.tsx b/app/(dashboard)/dashboard/protocols/_components/ImportProtocolModal.tsx new file mode 100644 index 000000000..8d822150e --- /dev/null +++ b/app/(dashboard)/dashboard/protocols/_components/ImportProtocolModal.tsx @@ -0,0 +1,41 @@ +import { Button } from '~/components/ui/Button'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '~/components/ui/dialog'; +import ProtocolUploader from '~/app/(dashboard)/dashboard/_components/ProtocolUploader'; +import { useState } from 'react'; + +interface ImportProtocolModalProps { + onProtocolUploaded: () => void; +} + +const ImportProtocolModal = ({ + onProtocolUploaded, +}: ImportProtocolModalProps) => { + const [openModal, setOpenModal] = useState(false); + + const handleUploaded = () => { + onProtocolUploaded(); + setOpenModal(false); + }; + + return ( + + + + + + + Import Protocol + + + + + ); +}; + +export default ImportProtocolModal; diff --git a/app/(dashboard)/dashboard/protocols/page.tsx b/app/(dashboard)/dashboard/protocols/page.tsx new file mode 100644 index 000000000..71c5e0953 --- /dev/null +++ b/app/(dashboard)/dashboard/protocols/page.tsx @@ -0,0 +1,18 @@ +import { ProtocolsTable } from '~/app/(dashboard)/dashboard/_components/ProtocolsTable/ProtocolsTable'; +import { trpc } from '~/app/_trpc/server'; + +const ProtocolsPage = async () => { + const protocols = await trpc.protocol.get.all.query(undefined, { + context: { + revalidate: 0, + }, + }); + return ( +
+

Protocols management view

+ +
+ ); +}; + +export default ProtocolsPage; diff --git a/app/(interview)/interview/new/page.tsx b/app/(interview)/interview/new/page.tsx index 3ccbb3f70..0d28a48aa 100644 --- a/app/(interview)/interview/new/page.tsx +++ b/app/(interview)/interview/new/page.tsx @@ -18,7 +18,7 @@ export default async function Page({ }; }) { // check if active protocol exists - const activeProtocol = await trpc.protocol.getActive.query(); + const activeProtocol = await trpc.protocol.getCurrentlyActive.query(); if (!activeProtocol) { return ( { - const protocols = await prisma.protocol.findMany(); + const protocols = await prisma.protocol.findMany({ + include: { interviews: true }, + }); return protocols; }), + byHash: protectedProcedure + .input(z.string()) + .query(async ({ input: hash }) => { + const protocol = await prisma.protocol.findFirst({ + where: { + hash, + }, + }); + + return protocol; + }), + lastUploaded: protectedProcedure.query(async () => { + const protocol = await prisma.protocol.findFirst({ + orderBy: { + importedAt: 'desc', + }, + }); + + return protocol; + }), }), - getActive: publicProcedure.query(async () => { - const activeProtocol = await prisma.protocol.findFirst({ + getActive: protectedProcedure + .input(z.string()) + .query(async ({ input: hash }) => { + const protocol = await prisma.protocol.findFirst({ + where: { + hash, + }, + select: { + active: true, + }, + }); + + return protocol?.active || false; + }), + getCurrentlyActive: protectedProcedure.query(async () => { + const protocol = await prisma.protocol.findFirst({ where: { active: true, }, }); - return activeProtocol; + return protocol; }), setActive: protectedProcedure .input(updateActiveProtocolSchema) - .mutation(async ({ input: { setActive, hash } }) => { - if (!setActive) { - return; - } - + .mutation(async ({ input: { input, hash } }) => { const currentActive = await prisma.protocol.findFirst({ where: { active: true, }, }); - // deactivate the current active protocol - if (currentActive) { + // if input is false, deactivate the active protocol + if (!input) { await prisma.protocol.update({ where: { - id: currentActive?.id, + hash: hash, + active: true, }, data: { active: false, }, }); + return; } - // find the most recently imported protocol - if (!hash) { - const recentlyUploaded = await prisma.protocol.findFirst({ - orderBy: { - importedAt: 'desc', - }, - }); - - // make the most recent protocol active + // deactivate the current active protocol, if it exists + if (currentActive) { await prisma.protocol.update({ where: { - hash: recentlyUploaded?.hash, + id: currentActive?.id, }, data: { - active: true, + active: false, }, }); - return; } - // make the protocol with the given hash active await prisma.protocol.update({ where: { @@ -80,4 +105,32 @@ export const protocolRouter = router({ }, }); }), + delete: router({ + all: protectedProcedure.mutation(async () => { + try { + const deletedProtocols = await prisma.protocol.deleteMany(); + return { error: null, deletedProtocols }; + } catch (error) { + return { + error: 'Failed to delete protocols', + deletedProtocols: null, + }; + } + }), + byHash: protectedProcedure + .input(z.array(z.string())) + .mutation(async ({ input: hashes }) => { + try { + const deletedProtocols = await prisma.protocol.deleteMany({ + where: { hash: { in: hashes } }, + }); + return { error: null, deletedProtocols: deletedProtocols }; + } catch (error) { + return { + error: 'Failed to delete protocols', + deletedProtocols: null, + }; + } + }), + }), }); diff --git a/shared/types.ts b/shared/types.ts index d6ad17dc2..95bcfdf97 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -1,5 +1,12 @@ import { Prisma } from '@prisma/client'; +const ProtocolWithInterviews = Prisma.validator()({ + include: { interviews: true }, +}); + +export type ProtocolWithInterviews = Prisma.ProtocolGetPayload< + typeof ProtocolWithInterviews + const participantWithInterviews = Prisma.validator()({ include: { interviews: true }, @@ -7,4 +14,5 @@ const participantWithInterviews = export type ParticipantWithInterviews = Prisma.ParticipantGetPayload< typeof participantWithInterviews + >;