Skip to content

Commit

Permalink
Merge pull request #61 from complexdatacollective/feature/participant…
Browse files Browse the repository at this point in the history
…-urls

Feature/participant urls
  • Loading branch information
jthrilly authored Jan 30, 2024
2 parents 8669a75 + 1b82d82 commit da41553
Show file tree
Hide file tree
Showing 15 changed files with 690 additions and 37 deletions.
101 changes: 101 additions & 0 deletions app/(dashboard)/dashboard/_components/ParticipantSelectionDropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { useEffect, useState } from 'react';

import { Button } from '~/components/ui/Button';
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '~/components/ui/dropdown-menu';
import type { Participant } from '@prisma/client';
import { ScrollArea } from '~/components/ui/scroll-area';

type ParticipantSelectionDropdownProps = {
participants: Participant[];
disabled: boolean;
setParticipantsToExport: (participants: Participant[]) => void;
};

export function ParticipantSelectionDropdown({
participants,
disabled,
setParticipantsToExport,
}: ParticipantSelectionDropdownProps) {
const [selectedParticipants, setSelectedParticipants] = useState<
Participant[]
>([]);

useEffect(() => {
setSelectedParticipants(participants);
}, [participants, setParticipantsToExport]);

useEffect(() => {
setParticipantsToExport(selectedParticipants);
}, [selectedParticipants, setParticipantsToExport]);

return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button disabled={disabled} variant="outline">
{selectedParticipants.length === participants.length
? 'All Participants Selected'
: `${selectedParticipants.length} Participants Selected`}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-auto">
<ScrollArea className="h-72 w-auto">
<DropdownMenuLabel>Participants</DropdownMenuLabel>
<div className="flex flex-row gap-2 p-2">
<div className="text-sm">
{selectedParticipants.length} selected
</div>
<Button
size="xs"
onClick={() => setSelectedParticipants(participants)}
>
Select All
</Button>
<Button
variant="destructive"
size="xs"
onClick={() => setSelectedParticipants([])}
>
Clear
</Button>
</div>

<DropdownMenuSeparator />

{/* loop through all participants and render a dropdown menu checkbox item for them */}
{participants.map((participant) => {
return (
<DropdownMenuCheckboxItem
key={participant.id}
checked={selectedParticipants.includes(participant)}
onCheckedChange={(checked) => {
if (checked) {
setSelectedParticipants([
...selectedParticipants,
participant,
]);
} else {
setSelectedParticipants(
selectedParticipants.filter(
(selectedParticipant) =>
selectedParticipant !== participant,
),
);
}
}}
>
{participant.identifier}
</DropdownMenuCheckboxItem>
);
})}
</ScrollArea>
</DropdownMenuContent>
</DropdownMenu>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
DropdownMenuTrigger,
} from '~/components/ui/dropdown-menu';
import type { Row } from '@tanstack/react-table';
import CopyButton from './CopyButton';
import { useState } from 'react';
import ParticipantModal from '~/app/(dashboard)/dashboard/participants/_components/ParticipantModal';
import type { ParticipantWithInterviews } from '~/shared/types';
Expand Down Expand Up @@ -72,10 +71,6 @@ export const ActionsDropdown = ({
<DropdownMenuItem onClick={() => handleDelete(row.original)}>
Delete
</DropdownMenuItem>

<CopyButton text={`/interview/${row.original.id}`}>
Copy URL
</CopyButton>
</DropdownMenuContent>
</DropdownMenu>
</>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
'use client';

import { type ColumnDef } from '@tanstack/react-table';
import Link from 'next/link';
import { DataTableColumnHeader } from '~/components/DataTable/ColumnHeader';
import { Checkbox } from '~/components/ui/checkbox';
import type { ParticipantWithInterviews } from '~/shared/types';
import { GetParticipantURLButton } from './GetParticipantURLButton';

export const ParticipantColumns =
(): ColumnDef<ParticipantWithInterviews>[] => [
Expand Down Expand Up @@ -45,21 +45,9 @@ export const ParticipantColumns =
},
},
{
accessorKey: 'Unique_interview_URL',
header: ({ column }) => {
return (
<DataTableColumnHeader column={column} title="Unique interview URL" />
);
id: 'participant-url',
cell: ({ row }) => {
return <GetParticipantURLButton participant={row.original} />;
},
cell: ({ row }) => (
<Link
className="text-blue-500 underline hover:text-blue-300"
href={`/interview/new?identifier=${row.original.id}`}
>
Participant link
</Link>
),
enableSorting: false,
enableHiding: false,
},
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
'use client';
import type { Participant, Protocol } from '@prisma/client';
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '~/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '~/components/ui/select';

import { Button } from '~/components/ui/Button';
import { api } from '~/trpc/client';
import { getBaseUrl } from '~/trpc/shared';
import { useToast } from '~/components/ui/use-toast';

export const GetParticipantURLButton = ({
participant,
}: {
participant: Participant;
}) => {
const { data: protocolData, isLoading: isLoadingProtocols } =
api.protocol.get.all.useQuery();
const [protocols, setProtocols] = useState<Protocol[]>([]);
const [selectedProtocol, setSelectedProtocol] = useState<Protocol>();
const [dialogOpen, setDialogOpen] = useState(false);

useEffect(() => {
if (protocolData) {
setProtocols(protocolData);
}
}, [protocolData]);

const { toast } = useToast();

const handleCopy = (url: string) => {
if (url) {
navigator.clipboard
.writeText(url)
.then(() => {
toast({
description: 'Copied to clipboard',
variant: 'success',
duration: 3000,
});
})
.catch(() => {
toast({
title: 'Error',
description: 'Could not copy text',
variant: 'destructive',
});
});
}
setDialogOpen(false);
};

return (
<Dialog open={dialogOpen}>
<DialogTrigger asChild onClick={() => setDialogOpen(true)}>
<Button size="sm">Get Participation URL</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Get Participation URL</DialogTitle>
<DialogDescription>
Generate a URL that can be shared with a participant to allow them
to participate for a selected protocol.
</DialogDescription>
</DialogHeader>
<>
<div>
<Select
onValueChange={(value) => {
const protocol = protocols.find(
(protocol) => protocol.id === value,
);

setSelectedProtocol(protocol);
handleCopy(
`${getBaseUrl()}/onboard/${protocol?.id}/participantId=${
participant.id
}`,
);
}}
value={selectedProtocol?.id}
disabled={isLoadingProtocols}
>
<SelectTrigger>
<SelectValue placeholder="Select a Protocol..." />
</SelectTrigger>
<SelectContent>
{protocols?.map((protocol) => (
<SelectItem key={protocol.id} value={protocol.id}>
{protocol.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
</DialogContent>
</Dialog>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ import { api } from '~/trpc/client';
import { DataTable } from '~/components/DataTable/DataTable';
import { ParticipantColumns } from '~/app/(dashboard)/dashboard/_components/ParticipantsTable/Columns';
import ImportCSVModal from '~/app/(dashboard)/dashboard/participants/_components/ImportCSVModal';
import ExportCSVParticipants from '~/app/(dashboard)/dashboard/participants/_components/ExportCSVParticipants';
import type { ParticipantWithInterviews } from '~/shared/types';
import { ActionsDropdown } from '~/app/(dashboard)/dashboard/_components/ParticipantsTable/ActionsDropdown';
import { DeleteAllParticipantsButton } from '~/app/(dashboard)/dashboard/participants/_components/DeleteAllParticipantsButton';
import AddParticipantButton from '~/app/(dashboard)/dashboard/participants/_components/AddParticipantButton';
import { useState } from 'react';
import { DeleteParticipantsDialog } from '~/app/(dashboard)/dashboard/participants/_components/DeleteParticipantsDialog';

import { ExportParticipantUrlSection } from '~/app/(dashboard)/dashboard/participants/_components/ExportParticipantUrlSection';
import ExportParticipants from '~/app/(dashboard)/dashboard/participants/_components/ExportParticipants';
export const ParticipantsTable = ({
initialData,
}: {
Expand Down Expand Up @@ -42,9 +42,10 @@ export const ParticipantsTable = ({
<div className="flex gap-2">
<AddParticipantButton existingParticipants={participants} />
<ImportCSVModal />
<ExportCSVParticipants participants={participants} />
<ExportParticipants participants={participants} />
<DeleteAllParticipantsButton />
</div>
<ExportParticipantUrlSection />
{isLoading && <div>Loading...</div>}
<DeleteParticipantsDialog
open={showDeleteModal}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
'use client';
import RecruitmentSwitch from '~/components/RecruitmentSwitch';

import { AnonymousRecruitmentModal } from '~/app/(dashboard)/dashboard/protocols/_components/AnonymousRecruitmentModal';
export const AnonymousRecruitmentSection = () => {
return (
<div className="flex w-1/3 flex-col gap-4 rounded-lg border border-solid p-6">
<div>
<h1 className="pb-2 text-xl">Anonymous Recruitment</h1>
<p className="text-sm text-muted-foreground">
If anonymous recruitment is enabled, you may generate an anonymous
participation URL. This URL can be shared with participants to allow
them to self-enroll in your study.
</p>
</div>

<div className="flex justify-between">
<p>Allow anonymous recruitment?</p>
<RecruitmentSwitch />
</div>
<AnonymousRecruitmentModal />
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
'use client';

import { Badge } from '~/components/ui/badge';
import { getBaseUrl } from '~/trpc/shared';
import { useToast } from '~/components/ui/use-toast';
import { Copy } from 'lucide-react';
import { api } from '~/trpc/client';

export const AnonymousRecruitmentURLButton = ({
protocolId,
}: {
protocolId: string;
}) => {
const { data: appSettings } = api.appSettings.get.useQuery();
const allowAnonymousRecruitment = !!appSettings?.allowAnonymousRecruitment;

const { toast } = useToast();
const url = `${getBaseUrl()}/onboard/${protocolId}`;
const handleCopyClick = () => {
navigator.clipboard
.writeText(url)
.then(() => {
toast({
description: 'Copied to clipboard',
variant: 'success',
duration: 3000,
});
})
.catch((error) => {
// eslint-disable-next-line no-console
console.error('Could not copy text: ', error);
toast({
title: 'Error',
description: 'Could not copy text',
variant: 'destructive',
});
});
};

return (
<>
{allowAnonymousRecruitment ? (
<Badge onClick={handleCopyClick} className="cursor-pointer">
<p className="w-36 truncate">{url}</p>
<Copy className="ml-2 h-4 w-4" />
</Badge>
) : (
<Badge variant="destructive">Disabled</Badge>
)}
</>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Checkbox } from '~/components/ui/checkbox';
import { DataTableColumnHeader } from '~/components/DataTable/ColumnHeader';
import type { ProtocolWithInterviews } from '~/shared/types';
import { dateOptions } from '~/components/DataTable/helpers';
import { AnonymousRecruitmentURLButton } from './AnonymousRecruitmentURLButton';

export const ProtocolColumns: ColumnDef<ProtocolWithInterviews>[] = [
{
Expand Down Expand Up @@ -86,4 +87,11 @@ export const ProtocolColumns: ColumnDef<ProtocolWithInterviews>[] = [
</div>
),
},
{
id: 'participant-url',
header: 'Anonymous Participation URL',
cell: ({ row }) => {
return <AnonymousRecruitmentURLButton protocolId={row.original.id} />;
},
},
];
Loading

0 comments on commit da41553

Please sign in to comment.