feat: update document table layout (#371)

* feat: update document table layout

- Removed dashboard page
- Removed redundant ID column
- Moved date to first column
- Added estimated locales for SSR dates
This commit is contained in:
David Nguyen
2023-09-12 14:29:27 +10:00
committed by Mythie
parent a849c6431f
commit 41d46c82d1
10 changed files with 125 additions and 188 deletions

View File

@ -1,124 +0,0 @@
import Link from 'next/link';
import { Clock, File, FileCheck } from 'lucide-react';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
import { getStats } from '@documenso/lib/server-only/document/get-stats';
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@documenso/ui/primitives/table';
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
import { CardMetric } from '~/components/(dashboard)/metric-card/metric-card';
import { DocumentStatus } from '~/components/formatter/document-status';
import { LocaleDate } from '~/components/formatter/locale-date';
import { UploadDocument } from './upload-document';
const CARD_DATA = [
{
icon: FileCheck,
title: 'Completed',
status: InternalDocumentStatus.COMPLETED,
},
{
icon: File,
title: 'Drafts',
status: InternalDocumentStatus.DRAFT,
},
{
icon: Clock,
title: 'Pending',
status: InternalDocumentStatus.PENDING,
},
];
export default async function DashboardPage() {
const user = await getRequiredServerComponentSession();
const [stats, results] = await Promise.all([
getStats({
user,
}),
findDocuments({
userId: user.id,
perPage: 10,
}),
]);
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<h1 className="text-4xl font-semibold">Dashboard</h1>
<div className="mt-8 grid grid-cols-1 gap-4 md:grid-cols-3">
{CARD_DATA.map((card) => (
<Link key={card.status} href={`/documents?status=${card.status}`}>
<CardMetric icon={card.icon} title={card.title} value={stats[card.status]} />
</Link>
))}
</div>
<div className="mt-12">
<UploadDocument />
<h2 className="mt-8 text-2xl font-semibold">Recent Documents</h2>
<div className="border-border mt-8 overflow-x-auto rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">ID</TableHead>
<TableHead>Title</TableHead>
<TableHead>Reciepient</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{results.data.map((document) => {
return (
<TableRow key={document.id}>
<TableCell className="font-medium">{document.id}</TableCell>
<TableCell>
<Link
href={`/documents/${document.id}`}
className="focus-visible:ring-ring ring-offset-background rounded-md font-medium hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
>
{document.title}
</Link>
</TableCell>
<TableCell>
<StackAvatarsWithTooltip recipients={document.Recipient} />
</TableCell>
<TableCell>
<DocumentStatus status={document.status} />
</TableCell>
<TableCell className="text-right">
<LocaleDate date={document.created} />
</TableCell>
</TableRow>
);
})}
{results.data.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
</div>
);
}

View File

@ -136,7 +136,7 @@ export const EditDocumentForm = ({
duration: 5000, duration: 5000,
}); });
router.push('/dashboard'); router.push('/documents');
} catch (err) { } catch (err) {
console.error(err); console.error(err);

View File

@ -52,8 +52,9 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
<DataTable <DataTable
columns={[ columns={[
{ {
header: 'ID', header: 'Created',
accessorKey: 'id', accessorKey: 'created',
cell: ({ row }) => <LocaleDate date={row.getValue('created')} />,
}, },
{ {
header: 'Title', header: 'Title',
@ -71,11 +72,6 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
accessorKey: 'status', accessorKey: 'status',
cell: ({ row }) => <DocumentStatus status={row.getValue('status')} />, cell: ({ row }) => <DocumentStatus status={row.getValue('status')} />,
}, },
{
header: 'Created',
accessorKey: 'created',
cell: ({ row }) => <LocaleDate date={row.getValue('created')} />,
},
{ {
header: 'Actions', header: 'Actions',
cell: ({ row }) => ( cell: ({ row }) => (
@ -92,7 +88,7 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
totalPages={results.totalPages} totalPages={results.totalPages}
onPaginationChange={onPaginationChange} onPaginationChange={onPaginationChange}
> >
{(table) => <DataTablePagination table={table} />} {(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable> </DataTable>
{isPending && ( {isPending && (

View File

@ -11,8 +11,8 @@ import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-
import { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/types'; import { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
import { DocumentStatus } from '~/components/formatter/document-status'; import { DocumentStatus } from '~/components/formatter/document-status';
import { UploadDocument } from '../dashboard/upload-document';
import { DocumentsDataTable } from './data-table'; import { DocumentsDataTable } from './data-table';
import { UploadDocument } from './upload-document';
export type DocumentsPageProps = { export type DocumentsPageProps = {
searchParams?: { searchParams?: {
@ -71,35 +71,15 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
<Link href={getTabHref(InternalDocumentStatus.PENDING)}> <Link href={getTabHref(InternalDocumentStatus.PENDING)}>
<DocumentStatus status={InternalDocumentStatus.PENDING} /> <DocumentStatus status={InternalDocumentStatus.PENDING} />
{value !== ExtendedDocumentStatus.ALL && (
<span className="ml-1 hidden opacity-50 md:inline-block"> <span className="ml-1 hidden opacity-50 md:inline-block">
{Math.min(stats.PENDING, 99)} {Math.min(stats[value], 99)}
{stats[value] > 99 && '+'}
</span> </span>
)}
</Link> </Link>
</TabsTrigger> </TabsTrigger>
))}
<TabsTrigger className="min-w-[60px]" value={InternalDocumentStatus.COMPLETED} asChild>
<Link href={getTabHref(InternalDocumentStatus.COMPLETED)}>
<DocumentStatus status={InternalDocumentStatus.COMPLETED} />
<span className="ml-1 hidden opacity-50 md:inline-block">
{Math.min(stats.COMPLETED, 99)}
</span>
</Link>
</TabsTrigger>
<TabsTrigger className="min-w-[60px]" value={InternalDocumentStatus.DRAFT} asChild>
<Link href={getTabHref(InternalDocumentStatus.DRAFT)}>
<DocumentStatus status={InternalDocumentStatus.DRAFT} />
<span className="ml-1 hidden opacity-50 md:inline-block">
{Math.min(stats.DRAFT, 99)}
</span>
</Link>
</TabsTrigger>
<TabsTrigger className="min-w-[60px]" value="ALL" asChild>
<Link href={getTabHref('ALL')}>All</Link>
</TabsTrigger>
</TabsList> </TabsList>
</Tabs> </Tabs>

View File

@ -2,6 +2,8 @@ import { Suspense } from 'react';
import { Caveat, Inter } from 'next/font/google'; import { Caveat, Inter } from 'next/font/google';
import { LocaleProvider } from '@documenso/lib/client-only/providers/locale';
import { getLocale } from '@documenso/lib/server-only/headers/get-locale';
import { TrpcProvider } from '@documenso/trpc/react'; import { TrpcProvider } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Toaster } from '@documenso/ui/primitives/toaster'; import { Toaster } from '@documenso/ui/primitives/toaster';
@ -45,6 +47,8 @@ export const metadata = {
export default async function RootLayout({ children }: { children: React.ReactNode }) { export default async function RootLayout({ children }: { children: React.ReactNode }) {
const flags = await getServerComponentAllFlags(); const flags = await getServerComponentAllFlags();
const locale = getLocale();
return ( return (
<html <html
lang="en" lang="en"
@ -63,6 +67,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
</Suspense> </Suspense>
<body> <body>
<LocaleProvider locale={locale}>
<FeatureFlagProvider initialFlags={flags}> <FeatureFlagProvider initialFlags={flags}>
<PlausibleProvider> <PlausibleProvider>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem> <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
@ -73,6 +78,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
</PlausibleProvider> </PlausibleProvider>
<Toaster /> <Toaster />
</FeatureFlagProvider> </FeatureFlagProvider>
</LocaleProvider>
</body> </body>
</html> </html>
); );

View File

@ -2,16 +2,31 @@
import { HTMLAttributes, useEffect, useState } from 'react'; import { HTMLAttributes, useEffect, useState } from 'react';
import { DateTime, DateTimeFormatOptions } from 'luxon';
import { useLocale } from '@documenso/lib/client-only/providers/locale';
export type LocaleDateProps = HTMLAttributes<HTMLSpanElement> & { export type LocaleDateProps = HTMLAttributes<HTMLSpanElement> & {
date: string | number | Date; date: string | number | Date;
format?: DateTimeFormatOptions;
}; };
export const LocaleDate = ({ className, date, ...props }: LocaleDateProps) => { /**
const [localeDate, setLocaleDate] = useState(() => new Date(date).toISOString()); * Formats the date based on the user locale.
*
* Will use the estimated locale from the user headers on SSR, then will use
* the client browser locale once mounted.
*/
export const LocaleDate = ({ className, date, format, ...props }: LocaleDateProps) => {
const { locale } = useLocale();
const [localeDate, setLocaleDate] = useState(() =>
DateTime.fromJSDate(new Date(date)).setLocale(locale).toLocaleString(format),
);
useEffect(() => { useEffect(() => {
setLocaleDate(new Date(date).toLocaleString()); setLocaleDate(DateTime.fromJSDate(new Date(date)).toLocaleString(format));
}, [date]); }, [date, format]);
return ( return (
<span className={className} {...props}> <span className={className} {...props}>

View File

@ -18,13 +18,15 @@ import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label'; import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
const ErrorMessages = { const ERROR_MESSAGES = {
[ErrorCode.CREDENTIALS_NOT_FOUND]: 'The email or password provided is incorrect', [ErrorCode.CREDENTIALS_NOT_FOUND]: 'The email or password provided is incorrect',
[ErrorCode.INCORRECT_EMAIL_PASSWORD]: 'The email or password provided is incorrect', [ErrorCode.INCORRECT_EMAIL_PASSWORD]: 'The email or password provided is incorrect',
[ErrorCode.USER_MISSING_PASSWORD]: [ErrorCode.USER_MISSING_PASSWORD]:
'This account appears to be using a social login method, please sign in using that method', 'This account appears to be using a social login method, please sign in using that method',
}; };
const LOGIN_REDIRECT_PATH = '/documents';
export const ZSignInFormSchema = z.object({ export const ZSignInFormSchema = z.object({
email: z.string().email().min(1), email: z.string().email().min(1),
password: z.string().min(6).max(72), password: z.string().min(6).max(72),
@ -37,9 +39,10 @@ export type SignInFormProps = {
}; };
export const SignInForm = ({ className }: SignInFormProps) => { export const SignInForm = ({ className }: SignInFormProps) => {
const { toast } = useToast();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { toast } = useToast();
const { const {
register, register,
handleSubmit, handleSubmit,
@ -61,7 +64,7 @@ export const SignInForm = ({ className }: SignInFormProps) => {
timeout = setTimeout(() => { timeout = setTimeout(() => {
toast({ toast({
variant: 'destructive', variant: 'destructive',
description: ErrorMessages[errorCode] ?? 'An unknown error occurred', description: ERROR_MESSAGES[errorCode] ?? 'An unknown error occurred',
}); });
}, 0); }, 0);
} }
@ -78,12 +81,10 @@ export const SignInForm = ({ className }: SignInFormProps) => {
await signIn('credentials', { await signIn('credentials', {
email, email,
password, password,
callbackUrl: '/documents', callbackUrl: LOGIN_REDIRECT_PATH,
}).catch((err) => { }).catch((err) => {
console.error(err); console.error(err);
}); });
// throw new Error('Not implemented');
} catch (err) { } catch (err) {
toast({ toast({
title: 'An unknown error occurred', title: 'An unknown error occurred',
@ -95,8 +96,7 @@ export const SignInForm = ({ className }: SignInFormProps) => {
const onSignInWithGoogleClick = async () => { const onSignInWithGoogleClick = async () => {
try { try {
await signIn('google', { callbackUrl: '/dashboard' }); await signIn('google', { callbackUrl: LOGIN_REDIRECT_PATH });
// throw new Error('Not implemented');
} catch (err) { } catch (err) {
toast({ toast({
title: 'An unknown error occurred', title: 'An unknown error occurred',

View File

@ -0,0 +1,37 @@
'use client';
import { createContext, useContext } from 'react';
export type LocaleContextValue = {
locale: string;
};
export const LocaleContext = createContext<LocaleContextValue | null>(null);
export const useLocale = () => {
const context = useContext(LocaleContext);
if (!context) {
throw new Error('useLocale must be used within a LocaleProvider');
}
return context;
};
export function LocaleProvider({
children,
locale,
}: {
children: React.ReactNode;
locale: string;
}) {
return (
<LocaleContext.Provider
value={{
locale: locale,
}}
>
{children}
</LocaleContext.Provider>
);
}

View File

@ -1,19 +1,46 @@
import { Table } from '@tanstack/react-table'; import { Table } from '@tanstack/react-table';
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'; import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
import { match } from 'ts-pattern';
import { Button } from './button'; import { Button } from './button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select';
interface DataTablePaginationProps<TData> { interface DataTablePaginationProps<TData> {
table: Table<TData>; table: Table<TData>;
/**
* The type of information to show on the left hand side of the pagination.
*
* Defaults to 'VisibleCount'.
*/
additionalInformation?: 'SelectedCount' | 'VisibleCount' | 'None';
} }
export function DataTablePagination<TData>({ table }: DataTablePaginationProps<TData>) { export function DataTablePagination<TData>({
table,
additionalInformation = 'VisibleCount',
}: DataTablePaginationProps<TData>) {
return ( return (
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-4 px-2"> <div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-4 px-2">
<div className="text-muted-foreground flex-1 text-sm"> <div className="text-muted-foreground flex-1 text-sm">
{match(additionalInformation)
.with('SelectedCount', () => (
<span>
{table.getFilteredSelectedRowModel().rows.length} of{' '} {table.getFilteredSelectedRowModel().rows.length} of{' '}
{table.getFilteredRowModel().rows.length} row(s) selected. {table.getFilteredRowModel().rows.length} row(s) selected.
</span>
))
.with('VisibleCount', () => {
const visibleRows = table.getFilteredRowModel().rows.length;
return (
<span>
Showing {visibleRows} result{visibleRows > 1 && 's'}.
</span>
);
})
.with('None', () => null)
.exhaustive()}
</div> </div>
<div className="flex items-center gap-x-2"> <div className="flex items-center gap-x-2">