diff --git a/app/(dashboard)/dashboard/_components/ProtocolsTable/ActiveButton.tsx b/app/(dashboard)/dashboard/_components/ProtocolsTable/ActiveButton.tsx new file mode 100644 index 000000000..8bacf247e --- /dev/null +++ b/app/(dashboard)/dashboard/_components/ProtocolsTable/ActiveButton.tsx @@ -0,0 +1,72 @@ +'use client'; +import { api } from '~/trpc/client'; +import { BadgeCheck } from 'lucide-react'; + +const ActiveButton = ({ + active, + protocolId, +}: { + active: boolean; + protocolId: string; +}) => { + const utils = api.useUtils(); + + const { mutateAsync: setActiveProtocol } = + api.protocol.active.set.useMutation({ + // Optimistic update + onMutate: async (newActiveProtocolId: string) => { + await utils.protocol.get.all.cancel(); + await utils.protocol.active.get.cancel(); + + const protocolGetAll = utils.protocol.get.all.getData(); + const protocolActiveGet = utils.protocol.active.get.getData(); + + utils.protocol.active.get.setData(undefined, protocolId); + utils.protocol.get.all.setData( + undefined, + (protocolGetAll) => + protocolGetAll?.map((protocol) => { + if (protocol.id === newActiveProtocolId) { + return { + ...protocol, + active: true, + }; + } + + return { + ...protocol, + active: false, + }; + }), + ); + + return { protocolGetAll, protocolActiveGet }; + }, + onSettled: () => { + void utils.protocol.get.all.invalidate(); + void utils.protocol.active.get.invalidate(); + }, + onError: (_error, _newActiveProtocolId, context) => { + utils.protocol.get.all.setData(undefined, context?.protocolGetAll); + utils.protocol.active.get.setData( + undefined, + context?.protocolActiveGet, + ); + }, + }); + + if (active) { + return ; + } + + return ( + + ); +}; + +export default ActiveButton; diff --git a/app/(dashboard)/dashboard/_components/ProtocolsTable/Columns.tsx b/app/(dashboard)/dashboard/_components/ProtocolsTable/Columns.tsx index d408b22c1..0c1c6540f 100644 --- a/app/(dashboard)/dashboard/_components/ProtocolsTable/Columns.tsx +++ b/app/(dashboard)/dashboard/_components/ProtocolsTable/Columns.tsx @@ -1,16 +1,14 @@ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ 'use client'; import { type ColumnDef, flexRender } from '@tanstack/react-table'; - import { Checkbox } from '~/components/ui/checkbox'; - +import ActiveButton from './ActiveButton'; import { DataTableColumnHeader } from '~/components/DataTable/ColumnHeader'; - -import ActiveProtocolSwitch from '~/app/(dashboard)/dashboard/_components/ActiveProtocolSwitch'; - import type { ProtocolWithInterviews } from '~/shared/types'; +import { dateOptions } from '~/components/DataTable/helpers'; -export const ProtocolColumns = (): ColumnDef[] => [ +export const ProtocolColumns: ColumnDef[] = [ { id: 'select', header: ({ table }) => ( @@ -30,35 +28,31 @@ export const ProtocolColumns = (): ColumnDef[] => [ enableSorting: false, enableHiding: false, }, + { + id: 'active', + enableSorting: true, + accessorFn: (row) => row.active, + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + ), + }, { accessorKey: 'name', header: ({ column }) => { return ; }, cell: ({ row }) => { - return ( -
- {flexRender(row.original.name, row)} -
- ); + return flexRender(row.original.name, row); }, }, { accessorKey: 'description', header: 'Description', cell: ({ row }) => { - return ( -
- {flexRender(row.original.description, row)} -
- ); + return flexRender(row.original.description, row); }, }, { @@ -66,9 +60,16 @@ export const ProtocolColumns = (): ColumnDef[] => [ header: ({ column }) => { return ; }, - cell: ({ row }) => ( -
- {new Date(row.original.importedAt).toLocaleString()} + cell: ({ + row, + table: { + options: { meta }, + }, + }) => ( +
+ {new Intl.DateTimeFormat(meta.navigatorLanguages, dateOptions).format( + new Date(row.original.importedAt), + )}
), }, @@ -77,31 +78,17 @@ export const ProtocolColumns = (): ColumnDef[] => [ header: ({ column }) => { return ; }, - cell: ({ row }) => ( -
- {new Date(row.original.lastModified).toLocaleString()} -
- ), - }, - { - accessorKey: 'schemaVersion', - header: 'Schema Version', - cell: ({ row }) => ( -
- {row.original.schemaVersion} + cell: ({ + row, + table: { + options: { meta }, + }, + }) => ( +
+ {new Intl.DateTimeFormat(meta.navigatorLanguages, dateOptions).format( + new Date(row.original.lastModified), + )}
), }, - { - accessorKey: 'active', - header: 'Active', - cell: ({ row }) => { - return ( - - ); - }, - }, ]; diff --git a/app/(dashboard)/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx b/app/(dashboard)/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx index 3ddfecc5a..e21b87528 100644 --- a/app/(dashboard)/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx +++ b/app/(dashboard)/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx @@ -37,7 +37,7 @@ export const ProtocolsTable = ({ <> {isLoading &&
Loading...
} = TTable & { + options?: { + meta?: { + getRowClasses?: (row: Row) => string | undefined; + navigatorLanguages?: string[]; + }; + }; +}; + interface DataTableProps { columns?: ColumnDef[]; data: TData[]; @@ -44,6 +55,15 @@ export function DataTable({ const [sorting, setSorting] = useState([]); const [isDeleting, setIsDeleting] = useState(false); const [rowSelection, setRowSelection] = useState({}); + const [navigatorLanguages, setNavigatorLanguages] = useState< + string[] | undefined + >(); + + useEffect(() => { + if (window.navigator.languages) { + setNavigatorLanguages(window.navigator.languages as string[]); + } + }, []); const [columnFilters, setColumnFilters] = useState([]); @@ -92,12 +112,17 @@ export function DataTable({ onRowSelectionChange: setRowSelection, onColumnFiltersChange: setColumnFilters, getFilteredRowModel: getFilteredRowModel(), + meta: { + getRowClasses: (row) => + row.original.active && 'bg-purple-500/30 hover:bg-purple-500/40', + navigatorLanguages, + }, state: { sorting, rowSelection, columnFilters, }, - }); + }) as CustomTable; const hasSelectedRows = table.getSelectedRowModel().rows.length > 0; @@ -147,6 +172,7 @@ export function DataTable({ {row.getVisibleCells().map((cell) => ( diff --git a/components/DataTable/DefaultColumns.tsx b/components/DataTable/DefaultColumns.tsx index 8e71296a9..2d8072768 100644 --- a/components/DataTable/DefaultColumns.tsx +++ b/components/DataTable/DefaultColumns.tsx @@ -1,8 +1,4 @@ -import { type ColumnDef } from '@tanstack/react-table'; - -export const makeDefaultColumns = ( - data: TData[], -): ColumnDef[] => { +export const makeDefaultColumns = (data: TData[]) => { const firstRow = data[0]; if (!firstRow || typeof firstRow !== 'object') { @@ -11,7 +7,7 @@ export const makeDefaultColumns = ( const columnKeys = Object.keys(firstRow); - const columns: ColumnDef[] = columnKeys.map((key) => { + const columns = columnKeys.map((key) => { return { accessorKey: key, header: key, diff --git a/components/DataTable/helpers.ts b/components/DataTable/helpers.ts new file mode 100644 index 000000000..7a65e8cc7 --- /dev/null +++ b/components/DataTable/helpers.ts @@ -0,0 +1,8 @@ +// Display options for dates: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat#using_options +export const dateOptions: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', +}; diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx new file mode 100644 index 000000000..ccc9cd787 --- /dev/null +++ b/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "~/utils/shadcn" + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/declarations.d.ts b/declarations.d.ts index 8b1378917..e69de29bb 100644 --- a/declarations.d.ts +++ b/declarations.d.ts @@ -1 +0,0 @@ - diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ca19a1792..54f3b7af9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -14,6 +14,7 @@ datasource db { model Protocol { id String @id @default(cuid()) + active Boolean @default(false) hash String @unique name String schemaVersion Int @@ -24,7 +25,6 @@ model Protocol { codebook Json assets Asset[] interviews Interview[] - active Boolean @default(false) } model Asset { diff --git a/server/routers/protocol.ts b/server/routers/protocol.ts index f7cba90c4..ecf77c28a 100644 --- a/server/routers/protocol.ts +++ b/server/routers/protocol.ts @@ -7,11 +7,6 @@ import { Prisma } from '@prisma/client'; import { utapi } from '~/app/api/uploadthing/core'; import type { Protocol } from '@codaco/shared-consts'; -const updateActiveProtocolSchema = z.object({ - input: z.boolean(), - hash: z.string(), -}); - export const assetInsertSchema = z.array( z.object({ key: z.string(), @@ -114,64 +109,41 @@ export const protocolRouter = router({ active: true, }, }); - return protocol; - }), - is: protectedProcedure.input(z.string()).query(async ({ input: hash }) => { - const protocol = await prisma.protocol.findFirst({ - where: { - hash, - }, - select: { - active: true, - }, - }); - return protocol?.active || false; + return protocol?.id ?? null; }), + is: protectedProcedure + .input(z.string()) + .mutation(async ({ input: protocolId }) => { + const protocol = await prisma.protocol.findFirst({ + where: { + id: protocolId, + }, + }); + + return protocol?.active ?? false; + }), set: protectedProcedure - .input(updateActiveProtocolSchema) - .mutation(async ({ input: { input, hash } }) => { + .input(z.string()) + .mutation(async ({ input: protocolId }) => { try { - const currentActive = await prisma.protocol.findFirst({ - where: { - active: true, - }, - }); - - // If input is false, deactivate the active protocol - if (!input) { - await prisma.protocol.update({ + await prisma.$transaction([ + prisma.protocol.updateMany({ where: { - hash: hash, active: true, }, data: { active: false, }, - }); - return { error: null, success: true }; - } - - // Deactivate the current active protocol, if it exists - if (currentActive) { - await prisma.protocol.update({ + }), + prisma.protocol.update({ where: { - id: currentActive.id, + id: protocolId, }, data: { - active: false, + active: true, }, - }); - } - - // Make the protocol with the given hash active - await prisma.protocol.update({ - where: { - hash, - }, - data: { - active: true, - }, - }); + }), + ]); return { error: null, success: true }; } catch (error) {