Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/participant urls #61

Merged
merged 20 commits into from
Jan 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading