Skip to content

Commit 8e3b1ba

Browse files
committed
example of a better pattern for setting the active protocol
1 parent ea4174f commit 8e3b1ba

File tree

10 files changed

+207
-111
lines changed

10 files changed

+207
-111
lines changed
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
'use client';
2+
import { api } from '~/trpc/client';
3+
import { BadgeCheck } from 'lucide-react';
4+
5+
const ActiveButton = ({
6+
active,
7+
protocolId,
8+
}: {
9+
active: boolean;
10+
protocolId: string;
11+
}) => {
12+
const utils = api.useUtils();
13+
14+
const { mutateAsync: setActiveProtocol } =
15+
api.protocol.active.set.useMutation({
16+
// Optimistic update
17+
onMutate: async (newActiveProtocolId: string) => {
18+
await utils.protocol.get.all.cancel();
19+
await utils.protocol.active.get.cancel();
20+
21+
const protocolGetAll = utils.protocol.get.all.getData();
22+
const protocolActiveGet = utils.protocol.active.get.getData();
23+
24+
utils.protocol.active.get.setData(undefined, protocolId);
25+
utils.protocol.get.all.setData(
26+
undefined,
27+
(protocolGetAll) =>
28+
protocolGetAll?.map((protocol) => {
29+
if (protocol.id === newActiveProtocolId) {
30+
return {
31+
...protocol,
32+
active: true,
33+
};
34+
}
35+
36+
return {
37+
...protocol,
38+
active: false,
39+
};
40+
}),
41+
);
42+
43+
return { protocolGetAll, protocolActiveGet };
44+
},
45+
onSettled: () => {
46+
void utils.protocol.get.all.invalidate();
47+
void utils.protocol.active.get.invalidate();
48+
},
49+
onError: (_error, _newActiveProtocolId, context) => {
50+
utils.protocol.get.all.setData(undefined, context?.protocolGetAll);
51+
utils.protocol.active.get.setData(
52+
undefined,
53+
context?.protocolActiveGet,
54+
);
55+
},
56+
});
57+
58+
if (active) {
59+
return <BadgeCheck className="fill-white text-purple-500" />;
60+
}
61+
62+
return (
63+
<button
64+
title="Make active..."
65+
onClick={() => setActiveProtocol(protocolId)}
66+
>
67+
<BadgeCheck className="cursor-pointer fill-white text-primary/20 hover:scale-150 hover:fill-purple-500 hover:text-white" />
68+
</button>
69+
);
70+
};
71+
72+
export default ActiveButton;
Lines changed: 37 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
1+
/* eslint-disable @typescript-eslint/no-unsafe-argument */
12
'use client';
23

34
import { type ColumnDef, flexRender } from '@tanstack/react-table';
4-
55
import { Checkbox } from '~/components/ui/checkbox';
6-
6+
import ActiveButton from './ActiveButton';
77
import { DataTableColumnHeader } from '~/components/DataTable/ColumnHeader';
8-
9-
import ActiveProtocolSwitch from '~/app/(dashboard)/dashboard/_components/ActiveProtocolSwitch';
10-
118
import type { ProtocolWithInterviews } from '~/shared/types';
9+
import { dateOptions } from '~/components/DataTable/helpers';
1210

13-
export const ProtocolColumns = (): ColumnDef<ProtocolWithInterviews>[] => [
11+
export const ProtocolColumns: ColumnDef<ProtocolWithInterviews>[] = [
1412
{
1513
id: 'select',
1614
header: ({ table }) => (
@@ -30,45 +28,48 @@ export const ProtocolColumns = (): ColumnDef<ProtocolWithInterviews>[] => [
3028
enableSorting: false,
3129
enableHiding: false,
3230
},
31+
{
32+
id: 'active',
33+
enableSorting: true,
34+
accessorFn: (row) => row.active,
35+
header: ({ column }) => (
36+
<DataTableColumnHeader column={column} title="Active" />
37+
),
38+
cell: ({ row }) => (
39+
<ActiveButton active={row.original.active} protocolId={row.original.id} />
40+
),
41+
},
3342
{
3443
accessorKey: 'name',
3544
header: ({ column }) => {
3645
return <DataTableColumnHeader column={column} title="Name" />;
3746
},
3847
cell: ({ row }) => {
39-
return (
40-
<div className={row.original.active ? '' : 'text-muted-foreground'}>
41-
{flexRender(row.original.name, row)}
42-
</div>
43-
);
48+
return flexRender(row.original.name, row);
4449
},
4550
},
4651
{
4752
accessorKey: 'description',
4853
header: 'Description',
4954
cell: ({ row }) => {
50-
return (
51-
<div
52-
className={
53-
row.original.active
54-
? 'min-w-[200px]'
55-
: 'min-w-[200px] text-muted-foreground'
56-
}
57-
key={row.original.description}
58-
>
59-
{flexRender(row.original.description, row)}
60-
</div>
61-
);
55+
return flexRender(row.original.description, row);
6256
},
6357
},
6458
{
6559
accessorKey: 'importedAt',
6660
header: ({ column }) => {
6761
return <DataTableColumnHeader column={column} title="Imported" />;
6862
},
69-
cell: ({ row }) => (
70-
<div className={row.original.active ? '' : 'text-muted-foreground'}>
71-
{new Date(row.original.importedAt).toLocaleString()}
63+
cell: ({
64+
row,
65+
table: {
66+
options: { meta },
67+
},
68+
}) => (
69+
<div className="text-xs">
70+
{new Intl.DateTimeFormat(meta.navigatorLanguages, dateOptions).format(
71+
new Date(row.original.importedAt),
72+
)}
7273
</div>
7374
),
7475
},
@@ -77,31 +78,17 @@ export const ProtocolColumns = (): ColumnDef<ProtocolWithInterviews>[] => [
7778
header: ({ column }) => {
7879
return <DataTableColumnHeader column={column} title="Modified" />;
7980
},
80-
cell: ({ row }) => (
81-
<div className={row.original.active ? '' : 'text-muted-foreground'}>
82-
{new Date(row.original.lastModified).toLocaleString()}
83-
</div>
84-
),
85-
},
86-
{
87-
accessorKey: 'schemaVersion',
88-
header: 'Schema Version',
89-
cell: ({ row }) => (
90-
<div className={row.original.active ? '' : 'text-muted-foreground'}>
91-
{row.original.schemaVersion}
81+
cell: ({
82+
row,
83+
table: {
84+
options: { meta },
85+
},
86+
}) => (
87+
<div className="text-xs">
88+
{new Intl.DateTimeFormat(meta.navigatorLanguages, dateOptions).format(
89+
new Date(row.original.lastModified),
90+
)}
9291
</div>
9392
),
9493
},
95-
{
96-
accessorKey: 'active',
97-
header: 'Active',
98-
cell: ({ row }) => {
99-
return (
100-
<ActiveProtocolSwitch
101-
initialData={row.original.active}
102-
hash={row.original.hash}
103-
/>
104-
);
105-
},
106-
},
10794
];

app/(dashboard)/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export const ProtocolsTable = ({
3737
<>
3838
{isLoading && <div>Loading...</div>}
3939
<DataTable
40-
columns={ProtocolColumns()}
40+
columns={ProtocolColumns}
4141
data={protocols}
4242
filterColumnAccessorKey="name"
4343
handleDeleteSelected={handleDelete}

components/DataTable/DataTable.tsx

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable @typescript-eslint/no-unsafe-return */
12
import {
23
flexRender,
34
getCoreRowModel,
@@ -9,8 +10,9 @@ import {
910
type ColumnFiltersState,
1011
type SortingState,
1112
type Row,
13+
type Table as TTable,
1214
} from '@tanstack/react-table';
13-
import { useState } from 'react';
15+
import { useEffect, useState } from 'react';
1416
import { Button } from '~/components/ui/Button';
1517
import { Input } from '~/components/ui/Input';
1618
import {
@@ -24,6 +26,15 @@ import {
2426
import { makeDefaultColumns } from '~/components/DataTable/DefaultColumns';
2527
import { Loader } from 'lucide-react';
2628

29+
type CustomTable<TData> = TTable<TData> & {
30+
options?: {
31+
meta?: {
32+
getRowClasses?: (row: Row<TData>) => string | undefined;
33+
navigatorLanguages?: string[];
34+
};
35+
};
36+
};
37+
2738
interface DataTableProps<TData, TValue> {
2839
columns?: ColumnDef<TData, TValue>[];
2940
data: TData[];
@@ -44,6 +55,15 @@ export function DataTable<TData, TValue>({
4455
const [sorting, setSorting] = useState<SortingState>([]);
4556
const [isDeleting, setIsDeleting] = useState(false);
4657
const [rowSelection, setRowSelection] = useState({});
58+
const [navigatorLanguages, setNavigatorLanguages] = useState<
59+
string[] | undefined
60+
>();
61+
62+
useEffect(() => {
63+
if (window.navigator.languages) {
64+
setNavigatorLanguages(window.navigator.languages as string[]);
65+
}
66+
}, []);
4767

4868
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
4969

@@ -92,12 +112,17 @@ export function DataTable<TData, TValue>({
92112
onRowSelectionChange: setRowSelection,
93113
onColumnFiltersChange: setColumnFilters,
94114
getFilteredRowModel: getFilteredRowModel(),
115+
meta: {
116+
getRowClasses: (row) =>
117+
row.original.active && 'bg-purple-500/30 hover:bg-purple-500/40',
118+
navigatorLanguages,
119+
},
95120
state: {
96121
sorting,
97122
rowSelection,
98123
columnFilters,
99124
},
100-
});
125+
}) as CustomTable<TData>;
101126

102127
const hasSelectedRows = table.getSelectedRowModel().rows.length > 0;
103128

@@ -147,6 +172,7 @@ export function DataTable<TData, TValue>({
147172
<TableRow
148173
key={row.id}
149174
data-state={row.getIsSelected() && 'selected'}
175+
className={table.options.meta?.getRowClasses?.(row)}
150176
>
151177
{row.getVisibleCells().map((cell) => (
152178
<TableCell key={cell.id}>

components/DataTable/DefaultColumns.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,4 @@
1-
import { type ColumnDef } from '@tanstack/react-table';
2-
3-
export const makeDefaultColumns = <TData, TValue>(
4-
data: TData[],
5-
): ColumnDef<TData, TValue>[] => {
1+
export const makeDefaultColumns = <TData,>(data: TData[]) => {
62
const firstRow = data[0];
73

84
if (!firstRow || typeof firstRow !== 'object') {
@@ -11,7 +7,7 @@ export const makeDefaultColumns = <TData, TValue>(
117

128
const columnKeys = Object.keys(firstRow);
139

14-
const columns: ColumnDef<TData, TValue>[] = columnKeys.map((key) => {
10+
const columns = columnKeys.map((key) => {
1511
return {
1612
accessorKey: key,
1713
header: key,

components/DataTable/helpers.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// Display options for dates: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat#using_options
2+
export const dateOptions: Intl.DateTimeFormatOptions = {
3+
year: 'numeric',
4+
month: 'numeric',
5+
day: 'numeric',
6+
hour: 'numeric',
7+
minute: 'numeric',
8+
};

components/ui/badge.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import * as React from "react"
2+
import { cva, type VariantProps } from "class-variance-authority"
3+
4+
import { cn } from "~/utils/shadcn"
5+
6+
const badgeVariants = cva(
7+
"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",
8+
{
9+
variants: {
10+
variant: {
11+
default:
12+
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
13+
secondary:
14+
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
15+
destructive:
16+
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
17+
outline: "text-foreground",
18+
},
19+
},
20+
defaultVariants: {
21+
variant: "default",
22+
},
23+
}
24+
)
25+
26+
export interface BadgeProps
27+
extends React.HTMLAttributes<HTMLDivElement>,
28+
VariantProps<typeof badgeVariants> {}
29+
30+
function Badge({ className, variant, ...props }: BadgeProps) {
31+
return (
32+
<div className={cn(badgeVariants({ variant }), className)} {...props} />
33+
)
34+
}
35+
36+
export { Badge, badgeVariants }

declarations.d.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +0,0 @@
1-

prisma/schema.prisma

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ datasource db {
1414

1515
model Protocol {
1616
id String @id @default(cuid())
17+
active Boolean @default(false)
1718
hash String @unique
1819
name String
1920
schemaVersion Int
@@ -24,7 +25,6 @@ model Protocol {
2425
codebook Json
2526
assets Asset[]
2627
interviews Interview[]
27-
active Boolean @default(false)
2828
}
2929

3030
model Asset {

0 commit comments

Comments
 (0)