feat: add faceted table toolbars and multi-value filters

Consolidating document and template filtering into shared faceted
toolbars makes filtering easier to discover and use.
Supporting comma-separated query params enables multi-select filters
across UI, server queries, and E2E coverage.
This commit is contained in:
ephraimduncan
2026-02-16 18:40:23 +00:00
parent ff9e6acb7a
commit 335fee09a9
23 changed files with 992 additions and 420 deletions
@@ -1,48 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { useSearchParams } from 'react-router';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { Input } from '@documenso/ui/primitives/input';
export const DocumentSearch = ({ initialValue = '' }: { initialValue?: string }) => {
const { _ } = useLingui();
const [searchParams, setSearchParams] = useSearchParams();
const [searchTerm, setSearchTerm] = useState(initialValue);
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
const handleSearch = useCallback(
(term: string) => {
const params = new URLSearchParams(searchParams?.toString() ?? '');
if (term) {
params.set('query', term);
} else {
params.delete('query');
}
setSearchParams(params);
},
[searchParams],
);
useEffect(() => {
const currentQueryParam = searchParams.get('query') || '';
if (debouncedSearchTerm !== currentQueryParam) {
handleSearch(debouncedSearchTerm);
}
}, [debouncedSearchTerm, searchParams]);
return (
<Input
type="search"
placeholder={_(msg`Search documents...`)}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
);
};
@@ -1,70 +0,0 @@
import { useMemo } from 'react';
import { Trans } from '@lingui/react/macro';
import { useLocation, useNavigate, useSearchParams } from 'react-router';
import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
const isPeriodSelectorValue = (value: unknown): value is PeriodSelectorValue => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return ['', '7d', '14d', '30d'].includes(value as string);
};
export const PeriodSelector = () => {
const { pathname } = useLocation();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const period = useMemo(() => {
const p = searchParams?.get('period') ?? 'all';
return isPeriodSelectorValue(p) ? p : 'all';
}, [searchParams]);
const onPeriodChange = (newPeriod: string) => {
if (!pathname) {
return;
}
const params = new URLSearchParams(searchParams?.toString());
params.set('period', newPeriod);
if (newPeriod === '' || newPeriod === 'all') {
params.delete('period');
}
void navigate(`${pathname}?${params.toString()}`, { preventScrollReset: true });
};
return (
<Select defaultValue={period} onValueChange={onPeriodChange}>
<SelectTrigger className="text-muted-foreground max-w-[200px]">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="all">
<Trans>All Time</Trans>
</SelectItem>
<SelectItem value="7d">
<Trans>Last 7 days</Trans>
</SelectItem>
<SelectItem value="14d">
<Trans>Last 14 days</Trans>
</SelectItem>
<SelectItem value="30d">
<Trans>Last 30 days</Trans>
</SelectItem>
</SelectContent>
</Select>
);
};
@@ -4,49 +4,42 @@ import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentSource, DocumentStatus as DocumentStatusEnum } from '@prisma/client';
import type { DocumentSource } from '@prisma/client';
import { InfoIcon } from 'lucide-react';
import { DateTime } from 'luxon';
import { useSearchParams } from 'react-router';
import { z } from 'zod';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
import { trpc } from '@documenso/trpc/react';
import { ZFindDocumentsInternalRequestSchema } from '@documenso/trpc/server/document-router/find-documents-internal.types';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { SelectItem } from '@documenso/ui/primitives/select';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { SearchParamSelector } from '~/components/forms/search-param-selector';
import { DocumentSearch } from '~/components/general/document/document-search';
import { DocumentStatus } from '~/components/general/document/document-status';
import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip';
import { DocumentsTableActionButton } from '~/components/tables/documents-table-action-button';
import { DocumentsTableActionDropdown } from '~/components/tables/documents-table-action-dropdown';
import { DataTableTitle } from '~/components/tables/documents-table-title';
import { TemplateDocumentsTableToolbar } from '~/components/tables/template-documents-table-toolbar';
import { useCurrentTeam } from '~/providers/team';
import { PeriodSelector } from '../period-selector';
const DOCUMENT_SOURCE_LABELS: { [key in DocumentSource]: MessageDescriptor } = {
DOCUMENT: msg`Document`,
TEMPLATE: msg`Template`,
TEMPLATE_DIRECT_LINK: msg`Direct link`,
};
const ZDocumentSearchParamsSchema = ZUrlSearchParamsSchema.extend({
source: z
.nativeEnum(DocumentSource)
.optional()
.catch(() => undefined),
status: z
.nativeEnum(DocumentStatusEnum)
.optional()
.catch(() => undefined),
const ZDocumentSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({
page: true,
perPage: true,
query: true,
period: true,
status: true,
source: true,
});
type TemplatePageViewDocumentsTableProps = {
@@ -67,7 +60,7 @@ export const TemplatePageViewDocumentsTable = ({
Object.fromEntries(searchParams ?? []),
);
const { data, isLoading, isLoadingError } = trpc.document.find.useQuery(
const { data, isLoading, isLoadingError } = trpc.document.findDocumentsInternal.useQuery(
{
templateId,
page: parsedSearchParams.page,
@@ -75,6 +68,7 @@ export const TemplatePageViewDocumentsTable = ({
query: parsedSearchParams.query,
source: parsedSearchParams.source,
status: parsedSearchParams.status,
period: parsedSearchParams.period,
},
{
placeholderData: (previousData) => previousData,
@@ -133,8 +127,8 @@ export const TemplatePageViewDocumentsTable = ({
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 !p-0">
<ul className="text-muted-foreground space-y-0.5 divide-y [&>li]:p-4">
<TooltipContent className="max-w-md space-y-2 !p-0 text-foreground">
<ul className="space-y-0.5 divide-y text-muted-foreground [&>li]:p-4">
<li>
<h2 className="mb-2 flex flex-row items-center font-semibold">
<Trans>Template</Trans>
@@ -183,49 +177,8 @@ export const TemplatePageViewDocumentsTable = ({
}, []);
return (
<div>
<div className="mb-4 flex flex-row space-x-4">
<DocumentSearch />
<SearchParamSelector
paramKey="status"
isValueValid={(value) =>
[...DocumentStatusEnum.COMPLETED].includes(value as unknown as string)
}
>
<SelectItem value="all">
<Trans>Any Status</Trans>
</SelectItem>
<SelectItem value={DocumentStatusEnum.COMPLETED}>
<Trans>Completed</Trans>
</SelectItem>
<SelectItem value={DocumentStatusEnum.PENDING}>
<Trans>Pending</Trans>
</SelectItem>
<SelectItem value={DocumentStatusEnum.DRAFT}>
<Trans>Draft</Trans>
</SelectItem>
</SearchParamSelector>
<SearchParamSelector
paramKey="source"
isValueValid={(value) =>
[...DocumentSource.TEMPLATE].includes(value as unknown as string)
}
>
<SelectItem value="all">
<Trans>Any Source</Trans>
</SelectItem>
<SelectItem value={DocumentSource.TEMPLATE}>
<Trans>Template</Trans>
</SelectItem>
<SelectItem value={DocumentSource.TEMPLATE_DIRECT_LINK}>
<Trans>Direct Link</Trans>
</SelectItem>
</SearchParamSelector>
<PeriodSelector />
</div>
<div className="space-y-4">
<TemplateDocumentsTableToolbar />
<DataTable
columns={columns}