Files
zeekayandHanzo Dev 03f38fbef0 fix(ui): build the data table the way react-table 9 builds one
react-table went from 8 to 9 with nothing migrated. 9 has no useReactTable
and no getCoreRowModel, so the one component every list in the app renders
through could not be imported, and ColumnDef takes the table's features as
its first type argument, so all 42 column lists stopped typing their rows:
217 of the 498 errors in the app's typecheck.

DataTable now names the four features it uses — paging, row selection,
column visibility, column sizing — and calls useTable. DataTableColumnDef is
ColumnDef over those features, so a column list still says only what a row
is. The second type parameter goes: 9's table takes columns of unknown
value, and no caller ever passed one.

Two things the types turned up. `enableSorting: false` sat on two select
columns of a table that has never sorted; without the sorting feature the
option does not exist, and neither does the sorting. The organisation
insights table erased its three row types through `as unknown as` to feed
one DataTable; it renders one per view, typed, and the casts are gone. The
server rejects any other view before the component renders.

data-table.test.tsx renders the table and drives the instance: headers and
cells, a hidden column, a sized one, one-based paging read from the props
and reported back, selection by the caller's row id. Against the old file
it fails 7 of 7 at the import.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-18 18:36:33 -07:00

258 lines
8.0 KiB
TypeScript

import { useMemo, useTransition } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Loader } from 'lucide-react';
import { DateTime } from 'luxon';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
import { useUpdateSearchParams } from '@hanzo/esign-lib/client-only/hooks/use-update-search-params';
import { useSession } from '@hanzo/esign-lib/client-only/providers/session';
import { isDocumentCompleted } from '@hanzo/esign-lib/utils/document';
import { formatDocumentsPath } from '@hanzo/esign-lib/utils/teams';
import type { TFindDocumentsResponse } from '@hanzo/esign-trpc/server/document-router/find-documents.types';
import { Checkbox } from '@hanzo/esign-ui/primitives/checkbox';
import type { DataTableColumnDef, RowSelectionState } from '@hanzo/esign-ui/primitives/data-table';
import { DataTable } from '@hanzo/esign-ui/primitives/data-table';
import { DataTablePagination } from '@hanzo/esign-ui/primitives/data-table-pagination';
import { Skeleton } from '@hanzo/esign-ui/primitives/skeleton';
import { TableCell } from '@hanzo/esign-ui/primitives/table';
import { DocumentStatus } from '~/components/general/document/document-status';
import { useCurrentTeam } from '~/providers/team';
import { StackAvatarsWithTooltip } from '../general/stack-avatars-with-tooltip';
import { DocumentsTableActionButton } from './documents-table-action-button';
import { DocumentsTableActionDropdown } from './documents-table-action-dropdown';
export type DocumentsTableProps = {
data?: TFindDocumentsResponse;
isLoading?: boolean;
isLoadingError?: boolean;
onMoveDocument?: (documentId: number) => void;
enableSelection?: boolean;
rowSelection?: RowSelectionState;
onRowSelectionChange?: (selection: RowSelectionState) => void;
};
type DocumentsTableRow = TFindDocumentsResponse['data'][number];
export const DocumentsTable = ({
data,
isLoading,
isLoadingError,
onMoveDocument,
enableSelection,
rowSelection,
onRowSelectionChange,
}: DocumentsTableProps) => {
const { _, i18n } = useLingui();
const team = useCurrentTeam();
const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams();
const columns = useMemo(() => {
const cols: DataTableColumnDef<DocumentsTableRow>[] = [];
if (enableSelection) {
cols.push({
id: 'select',
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label={_(msg`Select all`)}
onClick={(e) => e.stopPropagation()}
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label={_(msg`Select row`)}
onClick={(e) => e.stopPropagation()}
/>
),
enableHiding: false,
size: 40,
});
}
cols.push(
{
header: _(msg`Created`),
accessorKey: 'createdAt',
cell: ({ row }) =>
i18n.date(row.original.createdAt, { ...DateTime.DATETIME_SHORT, hourCycle: 'h12' }),
},
{
header: _(msg`Title`),
cell: ({ row }) => <DataTableTitle row={row.original} teamUrl={team?.url} />,
},
{
id: 'sender',
header: _(msg`Sender`),
cell: ({ row }) => row.original.user.name ?? row.original.user.email,
},
{
header: _(msg`Recipient`),
accessorKey: 'recipient',
cell: ({ row }) => (
<StackAvatarsWithTooltip
recipients={row.original.recipients}
documentStatus={row.original.status}
/>
),
},
{
header: _(msg`Status`),
accessorKey: 'status',
cell: ({ row }) => <DocumentStatus status={row.original.status} />,
size: 140,
},
{
header: _(msg`Actions`),
cell: ({ row }) =>
(!row.original.deletedAt || isDocumentCompleted(row.original.status)) && (
<div className="flex items-center gap-x-4">
<DocumentsTableActionButton row={row.original} />
<DocumentsTableActionDropdown
row={row.original}
onMoveDocument={onMoveDocument ? () => onMoveDocument(row.original.id) : undefined}
/>
</div>
),
},
);
return cols;
}, [team, onMoveDocument, enableSelection]);
const onPaginationChange = (page: number, perPage: number) => {
startTransition(() => {
updateSearchParams({
page,
perPage,
});
});
};
const results = data ?? {
data: [],
perPage: 10,
currentPage: 1,
totalPages: 1,
};
return (
<div className="relative">
<DataTable
columns={columns}
data={results.data}
perPage={results.perPage}
currentPage={results.currentPage}
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
columnVisibility={{
sender: team !== undefined,
}}
error={{
enable: isLoadingError || false,
}}
skeleton={{
enable: isLoading || false,
rows: 5,
component: (
<>
{enableSelection && (
<TableCell>
<Skeleton className="h-4 w-4 rounded" />
</TableCell>
)}
<TableCell>
<Skeleton className="h-4 w-40 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-20 rounded-full" />
</TableCell>
<TableCell className="py-4">
<div className="flex w-full flex-row items-center">
<Skeleton className="h-10 w-10 flex-shrink-0 rounded-full" />
</div>
</TableCell>
<TableCell>
<Skeleton className="h-4 w-20 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-10 w-24 rounded" />
</TableCell>
</>
),
}}
enableRowSelection={enableSelection}
rowSelection={rowSelection}
onRowSelectionChange={onRowSelectionChange}
getRowId={(row) => row.envelopeId}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>
{isPending && (
<div className="bg-background/50 absolute inset-0 flex items-center justify-center">
<Loader className="text-muted-foreground h-8 w-8 animate-spin" />
</div>
)}
</div>
);
};
type DataTableTitleProps = {
row: DocumentsTableRow;
teamUrl: string;
};
const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => {
const { user } = useSession();
const recipient = row.recipients.find((recipient) => recipient.email === user.email);
const isOwner = row.user.id === user.id;
const isRecipient = !!recipient;
const isCurrentTeamDocument = teamUrl && row.team?.url === teamUrl;
const documentsPath = formatDocumentsPath(teamUrl);
const formatPath = `${documentsPath}/${row.envelopeId}`;
return match({
isOwner,
isRecipient,
isCurrentTeamDocument,
})
.with({ isOwner: true }, { isCurrentTeamDocument: true }, () => (
<Link
to={formatPath}
title={row.title}
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
>
{row.title}
</Link>
))
.with({ isRecipient: true }, () => (
<Link
to={`/sign/${recipient?.token}`}
title={row.title}
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
>
{row.title}
</Link>
))
.otherwise(() => (
<span className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]">
{row.title}
</span>
));
};