Skip to content

Commit

Permalink
- Added @tanstack/react-table dependency
Browse files Browse the repository at this point in the history
- Added `BookmarksTable` component
  • Loading branch information
michaelschwobe committed Oct 23, 2023
1 parent 91b27ab commit 8682ac3
Show file tree
Hide file tree
Showing 5 changed files with 372 additions and 61 deletions.
281 changes: 281 additions & 0 deletions app/components/bookmarks-table.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
import type { ColumnDef } from "@tanstack/react-table";
import {
createColumnHelper,
flexRender,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import { Favorite } from "~/components/favorite";
import { Button } from "~/components/ui/button";
import { Checkbox } from "~/components/ui/checkbox";
import { Favicon } from "~/components/ui/favicon";
import { Icon } from "~/components/ui/icon";
import { LinkButton } from "~/components/ui/link-button";
import {
Table,
TableWrapper,
Tbody,
Td,
Tfoot,
Th,
Thead,
Tr,
} from "~/components/ui/table";
import { cn } from "~/utils/misc";

export interface BookmarksWithFavicon {
id: string;
title: string | null;
url: string;
favorite: boolean | null;
favicon: null;
}

const columnHelper = createColumnHelper<BookmarksWithFavicon>();

export const bookmarksTableColumns = [
columnHelper.display({
id: "select",
header: ({ table }) => (
<Checkbox
aria-label="Select all rows"
aria-controls={table
.getSelectedRowModel()
.rows.map((row) => row.original.id)
.join(" ")}
checked={table.getIsAllRowsSelected()}
indeterminate={table.getIsSomeRowsSelected()}
onCheckedChange={(checked) =>
table.getToggleAllRowsSelectedHandler()({ target: { checked } })
}
/>
),
footer: ({ column }) => column.id,
cell: ({ row }) => (
<Checkbox
aria-label="Select row"
id={row.original.id}
name="selectedIds"
value={row.original.id}
checked={row.getIsSelected()}
indeterminate={row.getIsSomeSelected()}
disabled={!row.getCanSelect()}
onCheckedChange={(checked) =>
row.getToggleSelectedHandler()({ target: { checked } })
}
/>
),
}),

columnHelper.accessor("title", {
header: () => "Title",
footer: ({ column }) => column.id,
cell: ({ row, getValue }) => (
<ButtonTitle
bookmarkId={row.original.id}
faviconSrc={row.original.favicon}
title={getValue()}
/>
),
}),

columnHelper.accessor("url", {
header: () => "URL",
footer: ({ column }) => column.id,
cell: ({ getValue }) => <ButtonUrl url={getValue()} />,
}),

columnHelper.accessor("favorite", {
header: () => <span className="sr-only">Favorite</span>,
footer: ({ column }) => column.id,
cell: ({ row, getValue }) => (
<ButtonFavorite bookmarkId={row.original.id} defaultValue={getValue()} />
),
}),
];

export interface BookmarksTableProps<TData, TValue>
extends Omit<React.ComponentPropsWithoutRef<"table">, "children"> {
/** Sets the `class` attribute. */
className?: string | undefined;
/** Sets table column definitions, display templates, etc. **Required** */
columns: ColumnDef<TData, TValue>[];
/** Sets table data. **Required** */
data: TData[];
}

export function BookmarksTable<TData, TValue>({
className,
columns,
data,
...props
}: BookmarksTableProps<TData, TValue>) {
const table = useReactTable({
columns,
data,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
enableRowSelection: true,
});

return (
<TableWrapper className="border">
<Table {...props} className={cn(className)}>
<Thead>
{table.getHeaderGroups().map((headerGroup) => (
<Tr key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<Th
key={header.id}
className={cn(
"py-1.5",
header.column.id === "url" ? "w-full" : undefined,
)}
>
{header.isPlaceholder ? null : header.column.getCanSort() ? (
<Button
type="button"
variant="ghost"
className={cn(
"cursor-pointer select-none",
header.column.id !== "favorite"
? "w-full justify-start px-4"
: undefined,
)}
size="sm"
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
<Icon
type={
header.column.getIsSorted() === "asc"
? "chevron-up"
: header.column.getIsSorted() === "desc"
? "chevron-down"
: "chevrons-up-down"
}
/>
</Button>
) : (
flexRender(
header.column.columnDef.header,
header.getContext(),
)
)}
</Th>
);
})}
</Tr>
))}
</Thead>
<Tbody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<Tr
key={row.id}
data-state={row.getIsSelected() ? "selected" : undefined}
>
{row.getVisibleCells().map((cell) => (
<Td key={cell.id} className="py-1">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</Td>
))}
</Tr>
))
) : (
<Tr>
<Td colSpan={columns.length} className="px-4 text-center">
No results.
</Td>
</Tr>
)}
</Tbody>
<Tfoot>
<Tr>
<Td>
<Checkbox
aria-label="Select all rows"
aria-controls={table
.getSelectedRowModel()
// TODO: remove ts-expect-error once this is fixed
// @ts-expect-error - 🤷‍♂️ 'id' does exist
.rows.map((row) => row.original.id)
.join(" ")}
checked={table.getIsAllRowsSelected()}
indeterminate={table.getIsSomeRowsSelected()}
onCheckedChange={(checked) =>
table.getToggleAllRowsSelectedHandler()({
target: { checked },
})
}
/>
</Td>
<Td className="pl-6 pr-4 text-sm" colSpan={columns.length - 1}>
{table.getSelectedRowModel().rows.length} of{" "}
{table.getRowModel().rows.length} Rows selected
</Td>
</Tr>
</Tfoot>
</Table>
</TableWrapper>
);
}

function ButtonTitle({
bookmarkId,
faviconSrc,
title,
}: {
bookmarkId: string;
faviconSrc: string | null | undefined;
title: string | null | undefined;
}) {
return (
<LinkButton
to={`/bookmarks/${bookmarkId}`}
variant="ghost"
className="w-full max-w-full justify-start overflow-hidden"
>
<Favicon src={faviconSrc} />{" "}
<span className="truncate text-sm">
{title ? <span>{title}</span> : <span aria-label="Untitled">--</span>}
</span>
</LinkButton>
);
}

function ButtonUrl({ url }: { url: string }) {
return (
<LinkButton
to={url}
target="_blank"
rel="noopener noreferrer"
variant="ghost"
className="w-full max-w-[65vw] justify-start overflow-hidden font-normal"
>
<Icon type="external-link" />
<span className="truncate text-xs font-normal">{url}</span>
</LinkButton>
);
}

function ButtonFavorite({
bookmarkId,
defaultValue,
}: {
bookmarkId: string;
defaultValue: boolean | null | undefined;
}) {
return (
<Favorite
formAction={`/bookmarks/${bookmarkId}`}
defaultValue={defaultValue}
variant="ghost"
/>
);
}
57 changes: 14 additions & 43 deletions app/routes/bookmarks._index.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import {
BookmarksTable,
bookmarksTableColumns,
} from "~/components/bookmarks-table";
import { GeneralErrorBoundary } from "~/components/error-boundary";
import { Favorite } from "~/components/favorite";
import { Main } from "~/components/main";
import { SearchForm } from "~/components/search-form";
import { SearchHelp } from "~/components/search-help";
import { Badge } from "~/components/ui/badge";
import { Favicon } from "~/components/ui/favicon";
import { H1 } from "~/components/ui/h1";
import { Icon } from "~/components/ui/icon";
import { LinkButton } from "~/components/ui/link-button";
Expand Down Expand Up @@ -57,14 +59,14 @@ export const meta: MetaFunction<typeof loader> = ({ data }) => {
export default function BookmarksIndexPage() {
const loaderData = useLoaderData<typeof loader>();

const hasBookmarks = loaderData.bookmarks.length > 0;
const bookmarksCount = loaderData.bookmarks.length;

return (
<Main>
<div className="mb-4 flex items-center gap-2">
<H1>
<Icon type="bookmarks" />
Bookmarks <Badge aria-hidden>{loaderData.bookmarks.length}</Badge>
Bookmarks <Badge aria-hidden>{bookmarksCount}</Badge>
</H1>
<LinkButton to={`${USER_LOGIN_ROUTE}?redirectTo=/bookmarks/import`}>
<Icon type="upload" />
Expand All @@ -90,7 +92,7 @@ export default function BookmarksIndexPage() {

<SearchHelp
className="mb-4"
count={loaderData.bookmarks.length}
count={bookmarksCount}
singular="bookmark"
plural="bookmarks"
>
Expand All @@ -115,44 +117,13 @@ export default function BookmarksIndexPage() {
</LinkButton>
</SearchHelp>

{hasBookmarks ? (
<ul className="divide-y divide-slate-300 rounded-md border border-slate-300 bg-white dark:divide-slate-600 dark:border-slate-600 dark:bg-slate-800">
{loaderData.bookmarks.map((bookmark) => (
<li key={bookmark.id} className="flex gap-1 p-1">
<LinkButton
to={`/bookmarks/${bookmark.id}`}
className="max-w-[18rem] basis-1/3 justify-start overflow-hidden"
variant="ghost"
>
<Favicon src={bookmark.favicon} />{" "}
<span className="truncate text-sm">
{bookmark.title ? (
<span>{bookmark.title}</span>
) : (
<span aria-label="Untitled">--</span>
)}
</span>
</LinkButton>{" "}
<LinkButton
to={bookmark.url}
target="_blank"
rel="noopener noreferrer"
className="grow justify-between overflow-hidden font-normal"
variant="ghost"
>
<span className="truncate text-xs font-normal">
{bookmark.url}
</span>
<Icon type="external-link" />
</LinkButton>{" "}
<Favorite
formAction={`/bookmarks/${bookmark.id}`}
defaultValue={bookmark.favorite}
variant="ghost"
/>
</li>
))}
</ul>
{bookmarksCount > 0 ? (
<BookmarksTable
// TODO: remove ts-expect-error once this is fixed
// @ts-expect-error - node module bug https://github.com/TanStack/table/issues/5135
columns={bookmarksTableColumns}
data={loaderData.bookmarks}
/>
) : null}
</Main>
);
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"@remix-run/node": "^2.1.0",
"@remix-run/react": "^2.1.0",
"@remix-run/serve": "^2.1.0",
"@tanstack/react-table": "^8.10.7",
"bcryptjs": "^2.4.3",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
Expand Down
Loading

0 comments on commit 8682ac3

Please sign in to comment.