fix: table empty state and use the table somewhere else

This commit is contained in:
Ephraim Atta-Duncan
2025-06-05 07:42:39 +00:00
parent f5365554ab
commit 9ccd8e0397
7 changed files with 131 additions and 238 deletions

View File

@ -25,6 +25,21 @@ export const DocumentsTableEmptyState = ({ status }: DocumentsTableEmptyStatePro
message: msg`There are no active drafts at the current moment. You can upload a document to start drafting.`, message: msg`There are no active drafts at the current moment. You can upload a document to start drafting.`,
icon: CheckCircle2, icon: CheckCircle2,
})) }))
.with(ExtendedDocumentStatus.PENDING, () => ({
title: msg`No pending documents`,
message: msg`There are no pending documents at the moment. Documents awaiting signatures will appear here.`,
icon: CheckCircle2,
}))
.with(ExtendedDocumentStatus.REJECTED, () => ({
title: msg`No rejected documents`,
message: msg`There are no rejected documents. Documents that have been declined will appear here.`,
icon: CheckCircle2,
}))
.with(ExtendedDocumentStatus.INBOX, () => ({
title: msg`Your inbox is empty`,
message: msg`There are no documents waiting for your action. Documents requiring your signature will appear here.`,
icon: CheckCircle2,
}))
.with(ExtendedDocumentStatus.ALL, () => ({ .with(ExtendedDocumentStatus.ALL, () => ({
title: msg`We're all empty`, title: msg`We're all empty`,
message: msg`You have not yet created or received any documents. To create a document please upload one.`, message: msg`You have not yet created or received any documents. To create a document please upload one.`,
@ -38,7 +53,7 @@ export const DocumentsTableEmptyState = ({ status }: DocumentsTableEmptyStatePro
return ( return (
<div <div
className="text-muted-foreground/60 flex h-60 flex-col items-center justify-center gap-y-4" className="text-muted-foreground/60 mt-12 flex h-60 flex-col items-center justify-center gap-y-4"
data-testid="empty-document-state" data-testid="empty-document-state"
> >
<Icon className="h-12 w-12" strokeWidth={1.5} /> <Icon className="h-12 w-12" strokeWidth={1.5} />

View File

@ -12,6 +12,7 @@ import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-upda
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import { isDocumentCompleted } from '@documenso/lib/utils/document'; import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/schema'; import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/schema';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table'; import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
@ -29,6 +30,7 @@ import { useOptionalCurrentTeam } from '~/providers/team';
import { DocumentsTableActionButton } from '../documents-table-action-button'; import { DocumentsTableActionButton } from '../documents-table-action-button';
import { DocumentsTableActionDropdown } from '../documents-table-action-dropdown'; import { DocumentsTableActionDropdown } from '../documents-table-action-dropdown';
import { DocumentsTableEmptyState } from '../documents-table-empty-state';
export type DataTableProps = { export type DataTableProps = {
data?: TFindDocumentsInternalResponse; data?: TFindDocumentsInternalResponse;
@ -164,6 +166,13 @@ export function DocumentsDataTable({
totalPages: 1, totalPages: 1,
}; };
const getEmptyStateStatus = (): ExtendedDocumentStatus => {
if (selectedStatusValues.length > 0) {
return selectedStatusValues[0] as ExtendedDocumentStatus;
}
return ExtendedDocumentStatus.ALL;
};
return ( return (
<div className="relative"> <div className="relative">
<DataTable <DataTable
@ -212,6 +221,10 @@ export function DocumentsDataTable({
</> </>
), ),
}} }}
emptyState={{
enable: !isLoading && !isLoadingError,
component: <DocumentsTableEmptyState status={getEmptyStateStatus()} />,
}}
> >
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />} {(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable> </DataTable>

View File

@ -10,7 +10,7 @@ import { FolderType } from '@documenso/lib/types/folder-type';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { parseToIntegerArray } from '@documenso/lib/utils/params'; import { parseToIntegerArray } from '@documenso/lib/utils/params';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { ZFindDocumentsInternalRequestSchema } from '@documenso/trpc/server/document-router/schema'; import { ZFindDocumentsInternalRequestSchema } from '@documenso/trpc/server/document-router/schema';
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema'; import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
@ -26,7 +26,6 @@ import { FolderSettingsDialog } from '~/components/dialogs/folder-settings-dialo
import { DocumentDropZoneWrapper } from '~/components/general/document/document-drop-zone-wrapper'; import { DocumentDropZoneWrapper } from '~/components/general/document/document-drop-zone-wrapper';
import { DocumentUploadDropzone } from '~/components/general/document/document-upload'; import { DocumentUploadDropzone } from '~/components/general/document/document-upload';
import { FolderCard } from '~/components/general/folder/folder-card'; import { FolderCard } from '~/components/general/folder/folder-card';
import { DocumentsTableEmptyState } from '~/components/tables/documents-table-empty-state';
import { DocumentsDataTable } from '~/components/tables/documents-table/data-table'; import { DocumentsDataTable } from '~/components/tables/documents-table/data-table';
import { useOptionalCurrentTeam } from '~/providers/team'; import { useOptionalCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta'; import { appMetaTags } from '~/utils/meta';
@ -248,17 +247,6 @@ export default function DocumentsPage() {
<div className="mt-8"> <div className="mt-8">
<div> <div>
{data &&
data.count === 0 &&
(!foldersData?.folders.length || foldersData.folders.length === 0) ? (
<DocumentsTableEmptyState
status={
Array.isArray(findDocumentSearchParams.status)
? findDocumentSearchParams.status[0] || ExtendedDocumentStatus.ALL
: findDocumentSearchParams.status || ExtendedDocumentStatus.ALL
}
/>
) : (
<DocumentsDataTable <DocumentsDataTable
data={data} data={data}
isLoading={isLoading} isLoading={isLoading}
@ -268,7 +256,6 @@ export default function DocumentsPage() {
setIsMovingDocument(true); setIsMovingDocument(true);
}} }}
/> />
)}
</div> </div>
</div> </div>

View File

@ -3,22 +3,18 @@ import { useEffect, useMemo, useState } from 'react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { FolderIcon, HomeIcon, Loader2 } from 'lucide-react'; import { FolderIcon, HomeIcon, Loader2 } from 'lucide-react';
import { useNavigate, useParams, useSearchParams } from 'react-router'; import { useNavigate, useParams, useSearchParams } from 'react-router';
import { Link } from 'react-router';
import { z } from 'zod'; import { z } from 'zod';
import { FolderType } from '@documenso/lib/types/folder-type';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { parseToIntegerArray } from '@documenso/lib/utils/params'; import { parseToIntegerArray } from '@documenso/lib/utils/params';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { import { ZFindDocumentsInternalRequestSchema } from '@documenso/trpc/server/document-router/schema';
type TFindDocumentsInternalResponse,
ZFindDocumentsInternalRequestSchema,
} from '@documenso/trpc/server/document-router/schema';
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema'; import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { DocumentMoveToFolderDialog } from '~/components/dialogs/document-move-to-folder-dialog'; import { DocumentMoveToFolderDialog } from '~/components/dialogs/document-move-to-folder-dialog';
import { CreateFolderDialog } from '~/components/dialogs/folder-create-dialog'; import { CreateFolderDialog } from '~/components/dialogs/folder-create-dialog';
@ -26,14 +22,9 @@ import { FolderDeleteDialog } from '~/components/dialogs/folder-delete-dialog';
import { FolderMoveDialog } from '~/components/dialogs/folder-move-dialog'; import { FolderMoveDialog } from '~/components/dialogs/folder-move-dialog';
import { FolderSettingsDialog } from '~/components/dialogs/folder-settings-dialog'; import { FolderSettingsDialog } from '~/components/dialogs/folder-settings-dialog';
import { DocumentDropZoneWrapper } from '~/components/general/document/document-drop-zone-wrapper'; import { DocumentDropZoneWrapper } from '~/components/general/document/document-drop-zone-wrapper';
import { DocumentSearch } from '~/components/general/document/document-search';
import { DocumentStatus } from '~/components/general/document/document-status';
import { DocumentUploadDropzone } from '~/components/general/document/document-upload'; import { DocumentUploadDropzone } from '~/components/general/document/document-upload';
import { FolderCard } from '~/components/general/folder/folder-card'; import { FolderCard } from '~/components/general/folder/folder-card';
import { PeriodSelector } from '~/components/general/period-selector'; import { DocumentsDataTable } from '~/components/tables/documents-table/data-table';
import { DocumentsTable } from '~/components/tables/documents-table';
import { DocumentsTableEmptyState } from '~/components/tables/documents-table-empty-state';
import { DocumentsTableSenderFilter } from '~/components/tables/documents-table-sender-filter';
import { useOptionalCurrentTeam } from '~/providers/team'; import { useOptionalCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta'; import { appMetaTags } from '~/utils/meta';
@ -42,13 +33,23 @@ export function meta() {
} }
const ZSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({ const ZSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({
status: true,
period: true, period: true,
page: true, page: true,
perPage: true, perPage: true,
query: true, query: true,
}).extend({ }).extend({
senderIds: z.string().transform(parseToIntegerArray).optional().catch([]), senderIds: z.string().transform(parseToIntegerArray).optional().catch([]),
status: z
.string()
.transform(
(val) =>
val
.split(',')
.map((s) => s.trim())
.filter(Boolean) as ExtendedDocumentStatus[],
)
.optional()
.catch(undefined),
}); });
export default function DocumentsPage() { export default function DocumentsPage() {
@ -71,15 +72,6 @@ export default function DocumentsPage() {
const { mutateAsync: pinFolder } = trpc.folder.pinFolder.useMutation(); const { mutateAsync: pinFolder } = trpc.folder.pinFolder.useMutation();
const { mutateAsync: unpinFolder } = trpc.folder.unpinFolder.useMutation(); const { mutateAsync: unpinFolder } = trpc.folder.unpinFolder.useMutation();
const [stats, setStats] = useState<TFindDocumentsInternalResponse['stats']>({
[ExtendedDocumentStatus.DRAFT]: 0,
[ExtendedDocumentStatus.PENDING]: 0,
[ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.REJECTED]: 0,
[ExtendedDocumentStatus.INBOX]: 0,
[ExtendedDocumentStatus.ALL]: 0,
});
const findDocumentSearchParams = useMemo( const findDocumentSearchParams = useMemo(
() => ZSearchParamsSchema.safeParse(Object.fromEntries(searchParams.entries())).data || {}, () => ZSearchParamsSchema.safeParse(Object.fromEntries(searchParams.entries())).data || {},
[searchParams], [searchParams],
@ -97,6 +89,7 @@ export default function DocumentsPage() {
isLoading: isFoldersLoading, isLoading: isFoldersLoading,
refetch: refetchFolders, refetch: refetchFolders,
} = trpc.folder.getFolders.useQuery({ } = trpc.folder.getFolders.useQuery({
type: FolderType.DOCUMENT,
parentId: folderId, parentId: folderId,
}); });
@ -105,28 +98,6 @@ export default function DocumentsPage() {
void refetchFolders(); void refetchFolders();
}, [team?.url]); }, [team?.url]);
const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => {
const params = new URLSearchParams(searchParams);
params.set('status', value);
if (value === ExtendedDocumentStatus.ALL) {
params.delete('status');
}
if (params.has('page')) {
params.delete('page');
}
return `${formatDocumentsPath(team?.url)}/f/${folderId}?${params.toString()}`;
};
useEffect(() => {
if (data?.stats) {
setStats(data.stats);
}
}, [data?.stats]);
const navigateToFolder = (folderId?: string | null) => { const navigateToFolder = (folderId?: string | null) => {
const documentsPath = formatDocumentsPath(team?.url); const documentsPath = formatDocumentsPath(team?.url);
@ -255,56 +226,11 @@ export default function DocumentsPage() {
<Trans>Documents</Trans> <Trans>Documents</Trans>
</h2> </h2>
</div> </div>
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
<Tabs value={findDocumentSearchParams.status || 'ALL'} className="overflow-x-auto">
<TabsList>
{[
ExtendedDocumentStatus.INBOX,
ExtendedDocumentStatus.PENDING,
ExtendedDocumentStatus.COMPLETED,
ExtendedDocumentStatus.DRAFT,
ExtendedDocumentStatus.ALL,
].map((value) => (
<TabsTrigger
key={value}
className="hover:text-foreground min-w-[60px]"
value={value}
asChild
>
<Link to={getTabHref(value)} preventScrollReset>
<DocumentStatus status={value} />
{value !== ExtendedDocumentStatus.ALL && (
<span className="ml-1 inline-block opacity-50">{stats[value]}</span>
)}
</Link>
</TabsTrigger>
))}
</TabsList>
</Tabs>
{team && <DocumentsTableSenderFilter teamId={team.id} />}
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
<PeriodSelector />
</div>
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
<DocumentSearch initialValue={findDocumentSearchParams.query} />
</div>
</div>
</div> </div>
<div className="mt-8"> <div className="mt-8">
<div> <div>
{data && <DocumentsDataTable
data.count === 0 &&
(!foldersData?.folders.length || foldersData.folders.length === 0) ? (
<DocumentsTableEmptyState
status={findDocumentSearchParams.status || ExtendedDocumentStatus.ALL}
/>
) : (
<DocumentsTable
data={data} data={data}
isLoading={isLoading} isLoading={isLoading}
isLoadingError={isLoadingError} isLoadingError={isLoadingError}
@ -313,7 +239,6 @@ export default function DocumentsPage() {
setIsMovingDocument(true); setIsMovingDocument(true);
}} }}
/> />
)}
</div> </div>
</div> </div>

View File

@ -52,6 +52,10 @@ interface DataTableProps<TData, TValue> {
enable: boolean; enable: boolean;
component?: React.ReactNode; component?: React.ReactNode;
}; };
emptyState?: {
enable: boolean;
component?: React.ReactNode;
};
} }
export function DataTable<TData, TValue>({ export function DataTable<TData, TValue>({
@ -72,6 +76,7 @@ export function DataTable<TData, TValue>({
onResetFilters, onResetFilters,
isStatusFiltered, isStatusFiltered,
isTimePeriodFiltered, isTimePeriodFiltered,
emptyState,
}: DataTableProps<TData, TValue>) { }: DataTableProps<TData, TValue>) {
const [rowSelection, setRowSelection] = React.useState({}); const [rowSelection, setRowSelection] = React.useState({});
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({}); const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({});
@ -149,6 +154,7 @@ export function DataTable<TData, TValue>({
isStatusFiltered={isStatusFiltered} isStatusFiltered={isStatusFiltered}
isTimePeriodFiltered={isTimePeriodFiltered} isTimePeriodFiltered={isTimePeriodFiltered}
/> />
{table.getRowModel().rows?.length || error?.enable || skeleton?.enable ? (
<div className="rounded-md border"> <div className="rounded-md border">
<Table> <Table>
<TableHeader> <TableHeader>
@ -191,19 +197,22 @@ export function DataTable<TData, TValue>({
{skeleton.component ?? <Skeleton />} {skeleton.component ?? <Skeleton />}
</TableRow> </TableRow>
)) ))
) : ( ) : null}
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
) : emptyState?.enable ? (
(emptyState.component ?? (
<div className="flex h-24 items-center justify-center text-center">No results.</div>
))
) : (
<div className="flex h-24 items-center justify-center text-center">No results.</div>
)}
</div> </div>
{children && <div className="mt-8 w-full">{children(table)}</div>} {children && (table.getRowModel().rows?.length || error?.enable || skeleton?.enable) && (
<div className="mt-8 w-full">{children(table)}</div>
)}
</> </>
); );
} }

View File

@ -1,6 +1,13 @@
import { CheckCircle2, Clock, File, Inbox, XCircle } from 'lucide-react'; import { CheckCircle2, Clock, File, Inbox, XCircle } from 'lucide-react';
export const statuses = [ export const statuses = [
{
value: 'INBOX',
label: 'Inbox',
icon: Inbox,
color: 'text-blue-700 dark:text-blue-300',
bgColor: 'bg-blue-100 dark:bg-blue-100 text-blue-700 dark:text-blue-700',
},
{ {
value: 'DRAFT', value: 'DRAFT',
label: 'Draft', label: 'Draft',
@ -29,13 +36,6 @@ export const statuses = [
color: 'text-red-700 dark:text-red-300', color: 'text-red-700 dark:text-red-300',
bgColor: 'bg-red-100 dark:bg-red-100 text-red-500 dark:text-red-700', bgColor: 'bg-red-100 dark:bg-red-100 text-red-500 dark:text-red-700',
}, },
{
value: 'INBOX',
label: 'Inbox',
icon: Inbox,
color: 'text-blue-700 dark:text-blue-300',
bgColor: 'bg-blue-100 dark:bg-blue-100 text-blue-700 dark:text-blue-700',
},
]; ];
export const timePeriods = [ export const timePeriods = [

View File

@ -1,56 +0,0 @@
import { Avatar, AvatarFallback, AvatarImage } from '../avatar';
import { Button } from '../button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '../dropdown-menu';
export function UserNav() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
<Avatar className="h-9 w-9">
<AvatarImage src="/avatars/03.png" alt="@shadcn" />
<AvatarFallback>SC</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end" forceMount>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">shadcn</p>
<p className="text-muted-foreground text-xs leading-none">m@example.com</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
Profile
<DropdownMenuShortcut>P</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
Billing
<DropdownMenuShortcut>B</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
Settings
<DropdownMenuShortcut>S</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>New Team</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem>
Log out
<DropdownMenuShortcut>Q</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}