mirror of
https://github.com/documenso/documenso.git
synced 2026-07-14 06:47:10 +10:00
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:
@@ -1,51 +0,0 @@
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { useLocation, useNavigate } from 'react-router';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { Select, SelectContent, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
||||
|
||||
export type SearchParamSelector = {
|
||||
paramKey: string;
|
||||
isValueValid: (value: unknown) => boolean;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const SearchParamSelector = ({ children, paramKey, isValueValid }: SearchParamSelector) => {
|
||||
const { pathname } = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const value = useMemo(() => {
|
||||
const p = searchParams?.get(paramKey) ?? 'all';
|
||||
|
||||
return isValueValid(p) ? p : 'all';
|
||||
}, [searchParams]);
|
||||
|
||||
const onValueChange = (newValue: string) => {
|
||||
if (!pathname) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
|
||||
params.set(paramKey, newValue);
|
||||
|
||||
if (newValue === '' || newValue === 'all') {
|
||||
params.delete(paramKey);
|
||||
}
|
||||
|
||||
void navigate(`${pathname}?${params.toString()}`, { preventScrollReset: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<Select defaultValue={value} onValueChange={onValueChange}>
|
||||
<SelectTrigger className="text-muted-foreground max-w-[200px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent position="popper">{children}</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router';
|
||||
|
||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
|
||||
|
||||
type DocumentsTableSenderFilterProps = {
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const DocumentsTableSenderFilter = ({ teamId }: DocumentsTableSenderFilterProps) => {
|
||||
const { pathname } = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
const senderIds = (searchParams?.get('senderIds') ?? '')
|
||||
.split(',')
|
||||
.filter((value) => value !== '');
|
||||
|
||||
const { data, isLoading } = trpc.team.member.getMany.useQuery({
|
||||
teamId,
|
||||
});
|
||||
|
||||
const comboBoxOptions = (data ?? []).map((member) => ({
|
||||
label: member.name ?? member.email,
|
||||
value: member.userId.toString(),
|
||||
}));
|
||||
|
||||
const onChange = (newSenderIds: string[]) => {
|
||||
if (!pathname) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
|
||||
params.set('senderIds', newSenderIds.join(','));
|
||||
|
||||
if (newSenderIds.length === 0) {
|
||||
params.delete('senderIds');
|
||||
}
|
||||
|
||||
void navigate(`${pathname}?${params.toString()}`, { preventScrollReset: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<MultiSelectCombobox
|
||||
emptySelectionPlaceholder={
|
||||
<p className="text-muted-foreground font-normal">
|
||||
<Trans>
|
||||
<span className="text-muted-foreground/70">Sender:</span> All
|
||||
</Trans>
|
||||
</p>
|
||||
}
|
||||
enableClearAllButton={true}
|
||||
inputPlaceholder={msg`Search`}
|
||||
loading={!isMounted || isLoading}
|
||||
options={comboBoxOptions}
|
||||
selectedValues={senderIds}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { XIcon } from 'lucide-react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { parseToStringArray, toCommaSeparatedSearchParam } from '@documenso/lib/utils/params';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/find-documents-internal.types';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import type { DataTableFacetedFilterOption } from '@documenso/ui/primitives/data-table-faceted-filter';
|
||||
import { DataTableFacetedFilter } from '@documenso/ui/primitives/data-table-faceted-filter';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
|
||||
import { PERIOD_OPTIONS } from './table-toolbar.constants';
|
||||
|
||||
type DocumentsTableToolbarProps = {
|
||||
teamId?: number;
|
||||
statusOptions: DataTableFacetedFilterOption[];
|
||||
statusCounts: TFindDocumentsInternalResponse['stats'];
|
||||
};
|
||||
|
||||
export const DocumentsTableToolbar = ({
|
||||
teamId,
|
||||
statusOptions,
|
||||
statusCounts,
|
||||
}: DocumentsTableToolbarProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
|
||||
const query = searchParams.get('query') ?? '';
|
||||
const period = searchParams.get('period') ?? '';
|
||||
|
||||
const selectedStatusValues = parseToStringArray(searchParams.get('status'));
|
||||
const selectedSenderValues = parseToStringArray(searchParams.get('senderIds'));
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState(query);
|
||||
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchTerm(query);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedSearchTerm !== searchTerm) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (debouncedSearchTerm === query) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateSearchParams({ query: debouncedSearchTerm || undefined, page: undefined });
|
||||
}, [debouncedSearchTerm, query, searchTerm]);
|
||||
|
||||
const { data: members } = trpc.team.member.getMany.useQuery(
|
||||
{
|
||||
teamId: teamId ?? 0,
|
||||
},
|
||||
{
|
||||
enabled: teamId !== undefined,
|
||||
},
|
||||
);
|
||||
|
||||
const senderOptions = useMemo(() => {
|
||||
return (members ?? []).map((member) => ({
|
||||
label: member.name ?? member.email,
|
||||
value: member.userId.toString(),
|
||||
}));
|
||||
}, [members]);
|
||||
|
||||
const periodOptions = useMemo<DataTableFacetedFilterOption[]>(() => {
|
||||
return PERIOD_OPTIONS.map((option) => ({
|
||||
label: _(option.label),
|
||||
value: option.value,
|
||||
}));
|
||||
}, [_]);
|
||||
|
||||
const hasActiveFilters =
|
||||
query.length > 0 ||
|
||||
selectedStatusValues.length > 0 ||
|
||||
selectedSenderValues.length > 0 ||
|
||||
(period.length > 0 && period !== 'all');
|
||||
|
||||
const onResetFilters = () => {
|
||||
setSearchTerm('');
|
||||
|
||||
updateSearchParams({
|
||||
query: undefined,
|
||||
status: undefined,
|
||||
senderIds: undefined,
|
||||
period: undefined,
|
||||
page: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative min-w-[286px] max-w-[494px]">
|
||||
<Input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
placeholder={_(msg`Search documents...`)}
|
||||
className="h-9 w-full pe-9"
|
||||
/>
|
||||
|
||||
{searchTerm.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={_(msg`Clear search`)}
|
||||
className="absolute inset-y-0 end-0 flex w-9 items-center justify-center text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
setSearchTerm('');
|
||||
updateSearchParams({ query: undefined, page: undefined });
|
||||
}}
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DataTableFacetedFilter
|
||||
title={_(msg`Status`)}
|
||||
options={statusOptions}
|
||||
selectedValues={selectedStatusValues}
|
||||
counts={statusCounts}
|
||||
showSearch={false}
|
||||
onSelectedValuesChange={(values) => {
|
||||
updateSearchParams({
|
||||
status: toCommaSeparatedSearchParam(values),
|
||||
page: undefined,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
{teamId !== undefined && (
|
||||
<DataTableFacetedFilter
|
||||
title={_(msg`Sender`)}
|
||||
options={senderOptions}
|
||||
selectedValues={selectedSenderValues}
|
||||
showSearch
|
||||
onSelectedValuesChange={(values) => {
|
||||
updateSearchParams({
|
||||
senderIds: toCommaSeparatedSearchParam(values),
|
||||
page: undefined,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DataTableFacetedFilter
|
||||
title={_(msg`Time`)}
|
||||
options={periodOptions}
|
||||
selectedValues={period ? [period] : []}
|
||||
singleSelect
|
||||
showSearch={false}
|
||||
onSelectedValuesChange={(values) => {
|
||||
const nextPeriod = values[0];
|
||||
|
||||
updateSearchParams({
|
||||
period: nextPeriod ?? undefined,
|
||||
page: undefined,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<Button variant="ghost" size="sm" onClick={onResetFilters}>
|
||||
<Trans>Reset</Trans>
|
||||
<XIcon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
||||
export const PERIOD_OPTIONS: Array<{ label: MessageDescriptor; value: string }> = [
|
||||
{
|
||||
label: msg`All Time`,
|
||||
value: 'all',
|
||||
},
|
||||
{
|
||||
label: msg`Last 7 days`,
|
||||
value: '7d',
|
||||
},
|
||||
{
|
||||
label: msg`Last 14 days`,
|
||||
value: '14d',
|
||||
},
|
||||
{
|
||||
label: msg`Last 30 days`,
|
||||
value: '30d',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
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 { CheckCircle2, Clock, File, FileText, LinkIcon, XIcon } from 'lucide-react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { parseToStringArray, toCommaSeparatedSearchParam } from '@documenso/lib/utils/params';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import type { DataTableFacetedFilterOption } from '@documenso/ui/primitives/data-table-faceted-filter';
|
||||
import { DataTableFacetedFilter } from '@documenso/ui/primitives/data-table-faceted-filter';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
|
||||
import { PERIOD_OPTIONS } from './table-toolbar.constants';
|
||||
|
||||
export const TemplateDocumentsTableToolbar = () => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const updateSearchParams = useUpdateSearchParams({ preventScrollReset: true });
|
||||
|
||||
const query = searchParams.get('query') ?? '';
|
||||
const period = searchParams.get('period') ?? '';
|
||||
|
||||
const selectedStatusValues = parseToStringArray(searchParams.get('status'));
|
||||
const selectedSourceValues = parseToStringArray(searchParams.get('source'));
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState(query);
|
||||
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchTerm(query);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedSearchTerm !== searchTerm) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (debouncedSearchTerm === query) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateSearchParams({ query: debouncedSearchTerm || undefined, page: undefined });
|
||||
}, [debouncedSearchTerm, query, searchTerm]);
|
||||
|
||||
const statusOptions = useMemo<DataTableFacetedFilterOption[]>(
|
||||
() => [
|
||||
{
|
||||
label: _(msg`Completed`),
|
||||
value: DocumentStatusEnum.COMPLETED,
|
||||
icon: CheckCircle2,
|
||||
iconClassName: 'text-green-500 dark:text-green-300',
|
||||
},
|
||||
{
|
||||
label: _(msg`Pending`),
|
||||
value: DocumentStatusEnum.PENDING,
|
||||
icon: Clock,
|
||||
iconClassName: 'text-blue-600 dark:text-blue-300',
|
||||
},
|
||||
{
|
||||
label: _(msg`Draft`),
|
||||
value: DocumentStatusEnum.DRAFT,
|
||||
icon: File,
|
||||
iconClassName: 'text-yellow-500 dark:text-yellow-200',
|
||||
},
|
||||
],
|
||||
[_],
|
||||
);
|
||||
|
||||
const sourceOptions = useMemo<DataTableFacetedFilterOption[]>(
|
||||
() => [
|
||||
{
|
||||
label: _(msg`Template`),
|
||||
value: DocumentSource.TEMPLATE,
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
label: _(msg`Direct Link`),
|
||||
value: DocumentSource.TEMPLATE_DIRECT_LINK,
|
||||
icon: LinkIcon,
|
||||
},
|
||||
],
|
||||
[_],
|
||||
);
|
||||
|
||||
const periodOptions = useMemo<DataTableFacetedFilterOption[]>(() => {
|
||||
return PERIOD_OPTIONS.map((option) => ({
|
||||
label: _(option.label),
|
||||
value: option.value,
|
||||
}));
|
||||
}, [_]);
|
||||
|
||||
const hasActiveFilters =
|
||||
query.length > 0 ||
|
||||
selectedStatusValues.length > 0 ||
|
||||
selectedSourceValues.length > 0 ||
|
||||
(period.length > 0 && period !== 'all');
|
||||
|
||||
const onResetFilters = () => {
|
||||
setSearchTerm('');
|
||||
|
||||
updateSearchParams({
|
||||
query: undefined,
|
||||
status: undefined,
|
||||
source: undefined,
|
||||
period: undefined,
|
||||
page: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative min-w-[286px] max-w-[494px]">
|
||||
<Input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
placeholder={_(msg`Search documents...`)}
|
||||
className="h-9 w-full pe-9"
|
||||
/>
|
||||
|
||||
{searchTerm.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={_(msg`Clear search`)}
|
||||
className="absolute inset-y-0 end-0 flex w-9 items-center justify-center text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
setSearchTerm('');
|
||||
updateSearchParams({ query: undefined, page: undefined });
|
||||
}}
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DataTableFacetedFilter
|
||||
title={_(msg`Status`)}
|
||||
options={statusOptions}
|
||||
selectedValues={selectedStatusValues}
|
||||
showSearch={false}
|
||||
onSelectedValuesChange={(values) => {
|
||||
updateSearchParams({
|
||||
status: toCommaSeparatedSearchParam(values),
|
||||
page: undefined,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<DataTableFacetedFilter
|
||||
title={_(msg`Source`)}
|
||||
options={sourceOptions}
|
||||
selectedValues={selectedSourceValues}
|
||||
showSearch={false}
|
||||
onSelectedValuesChange={(values) => {
|
||||
updateSearchParams({
|
||||
source: toCommaSeparatedSearchParam(values),
|
||||
page: undefined,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<DataTableFacetedFilter
|
||||
title={_(msg`Time`)}
|
||||
options={periodOptions}
|
||||
selectedValues={period ? [period] : []}
|
||||
singleSelect
|
||||
showSearch={false}
|
||||
onSelectedValuesChange={(values) => {
|
||||
const nextPeriod = values[0];
|
||||
|
||||
updateSearchParams({
|
||||
period: nextPeriod ?? undefined,
|
||||
page: undefined,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<Button variant="ghost" size="sm" onClick={onResetFilters}>
|
||||
<Trans>Reset</Trans>
|
||||
<XIcon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { TemplateType } from '@prisma/client';
|
||||
import { Globe2Icon, LockIcon, XIcon } from 'lucide-react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { parseToStringArray, toCommaSeparatedSearchParam } from '@documenso/lib/utils/params';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import type { DataTableFacetedFilterOption } from '@documenso/ui/primitives/data-table-faceted-filter';
|
||||
import { DataTableFacetedFilter } from '@documenso/ui/primitives/data-table-faceted-filter';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
|
||||
export const TemplatesTableToolbar = () => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
|
||||
const query = searchParams.get('query') ?? '';
|
||||
const selectedTypeValues = parseToStringArray(searchParams.get('type'));
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState(query);
|
||||
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchTerm(query);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedSearchTerm !== searchTerm) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (debouncedSearchTerm === query) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateSearchParams({ query: debouncedSearchTerm || undefined, page: undefined });
|
||||
}, [debouncedSearchTerm, query, searchTerm]);
|
||||
|
||||
const typeOptions = useMemo<DataTableFacetedFilterOption[]>(
|
||||
() => [
|
||||
{
|
||||
label: _(msg`Public`),
|
||||
value: TemplateType.PUBLIC,
|
||||
icon: Globe2Icon,
|
||||
iconClassName: 'text-green-500 dark:text-green-300',
|
||||
},
|
||||
{
|
||||
label: _(msg`Private`),
|
||||
value: TemplateType.PRIVATE,
|
||||
icon: LockIcon,
|
||||
iconClassName: 'text-blue-600 dark:text-blue-300',
|
||||
},
|
||||
],
|
||||
[_],
|
||||
);
|
||||
|
||||
const hasActiveFilters = query.length > 0 || selectedTypeValues.length > 0;
|
||||
|
||||
const onResetFilters = () => {
|
||||
setSearchTerm('');
|
||||
|
||||
updateSearchParams({
|
||||
query: undefined,
|
||||
type: undefined,
|
||||
page: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative min-w-[286px] max-w-[494px]">
|
||||
<Input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
placeholder={_(msg`Search templates...`)}
|
||||
className="h-9 w-full pe-9"
|
||||
/>
|
||||
|
||||
{searchTerm.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={_(msg`Clear search`)}
|
||||
className="absolute inset-y-0 end-0 flex w-9 items-center justify-center text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
setSearchTerm('');
|
||||
updateSearchParams({ query: undefined, page: undefined });
|
||||
}}
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DataTableFacetedFilter
|
||||
title={_(msg`Type`)}
|
||||
options={typeOptions}
|
||||
selectedValues={selectedTypeValues}
|
||||
showSearch={false}
|
||||
onSelectedValuesChange={(values) => {
|
||||
updateSearchParams({
|
||||
type: toCommaSeparatedSearchParam(values),
|
||||
page: undefined,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<Button variant="ghost" size="sm" onClick={onResetFilters}>
|
||||
<Trans>Reset</Trans>
|
||||
<XIcon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,36 +1,33 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import { FolderType, OrganisationType } from '@prisma/client';
|
||||
import { useParams, useSearchParams } from 'react-router';
|
||||
import { Link } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useSessionStorage } from '@documenso/lib/client-only/hooks/use-session-storage';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { parseToIntegerArray } from '@documenso/lib/utils/params';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/find-documents-internal.types';
|
||||
import { ZFindDocumentsInternalRequestSchema } from '@documenso/trpc/server/document-router/find-documents-internal.types';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
import type { RowSelectionState } from '@documenso/ui/primitives/data-table';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
import type { DataTableFacetedFilterOption } from '@documenso/ui/primitives/data-table-faceted-filter';
|
||||
|
||||
import { DocumentMoveToFolderDialog } from '~/components/dialogs/document-move-to-folder-dialog';
|
||||
import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog';
|
||||
import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog';
|
||||
import { DocumentSearch } from '~/components/general/document/document-search';
|
||||
import { DocumentStatus } from '~/components/general/document/document-status';
|
||||
import { FRIENDLY_STATUS_MAP } from '~/components/general/document/document-status';
|
||||
import { EnvelopeDropZoneWrapper } from '~/components/general/envelope/envelope-drop-zone-wrapper';
|
||||
import { FolderGrid } from '~/components/general/folder/folder-grid';
|
||||
import { PeriodSelector } from '~/components/general/period-selector';
|
||||
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 { DocumentsTableToolbar } from '~/components/tables/documents-table-toolbar';
|
||||
import { EnvelopesTableBulkActionBar } from '~/components/tables/envelopes-table-bulk-action-bar';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
@@ -50,6 +47,8 @@ const ZSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({
|
||||
});
|
||||
|
||||
export default function DocumentsPage() {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
const team = useCurrentTeam();
|
||||
|
||||
@@ -89,35 +88,37 @@ export default function DocumentsPage() {
|
||||
folderId,
|
||||
});
|
||||
|
||||
const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
const statusOptions = useMemo<DataTableFacetedFilterOption[]>(() => {
|
||||
return [
|
||||
ExtendedDocumentStatus.INBOX,
|
||||
ExtendedDocumentStatus.PENDING,
|
||||
ExtendedDocumentStatus.COMPLETED,
|
||||
ExtendedDocumentStatus.DRAFT,
|
||||
ExtendedDocumentStatus.REJECTED,
|
||||
]
|
||||
.filter((status) => {
|
||||
if (organisation.type === OrganisationType.PERSONAL) {
|
||||
return status !== ExtendedDocumentStatus.INBOX;
|
||||
}
|
||||
|
||||
params.set('status', value);
|
||||
return true;
|
||||
})
|
||||
.map((status) => {
|
||||
const { label, icon, color } = FRIENDLY_STATUS_MAP[status];
|
||||
|
||||
if (value === ExtendedDocumentStatus.ALL) {
|
||||
params.delete('status');
|
||||
}
|
||||
return {
|
||||
label: _(label),
|
||||
value: status,
|
||||
icon,
|
||||
iconClassName: color,
|
||||
};
|
||||
});
|
||||
}, [organisation.type, _]);
|
||||
|
||||
if (value === ExtendedDocumentStatus.INBOX && organisation.type === OrganisationType.PERSONAL) {
|
||||
params.delete('status');
|
||||
}
|
||||
const selectedStatuses = findDocumentSearchParams.status ?? [];
|
||||
|
||||
if (params.has('page')) {
|
||||
params.delete('page');
|
||||
}
|
||||
|
||||
let path = formatDocumentsPath(team.url);
|
||||
|
||||
if (folderId) {
|
||||
path += `/f/${folderId}`;
|
||||
}
|
||||
|
||||
if (params.toString()) {
|
||||
path += `?${params.toString()}`;
|
||||
}
|
||||
|
||||
return path;
|
||||
};
|
||||
const selectedStatus =
|
||||
selectedStatuses.length === 1 ? selectedStatuses[0] : ExtendedDocumentStatus.ALL;
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.stats) {
|
||||
@@ -143,60 +144,20 @@ export default function DocumentsPage() {
|
||||
<Trans>Documents</Trans>
|
||||
</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,
|
||||
]
|
||||
.filter((value) => {
|
||||
if (organisation.type === OrganisationType.PERSONAL) {
|
||||
return value !== ExtendedDocumentStatus.INBOX;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.map((value) => (
|
||||
<TabsTrigger
|
||||
key={value}
|
||||
className="min-w-[60px] hover:text-foreground"
|
||||
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 className="mt-8">
|
||||
<DocumentsTableToolbar
|
||||
teamId={team?.id}
|
||||
statusOptions={statusOptions}
|
||||
statusCounts={stats}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<div>
|
||||
{data && data.count === 0 ? (
|
||||
<DocumentsTableEmptyState
|
||||
status={findDocumentSearchParams.status || ExtendedDocumentStatus.ALL}
|
||||
/>
|
||||
<DocumentsTableEmptyState status={selectedStatus} />
|
||||
) : (
|
||||
<DocumentsTable
|
||||
data={data}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { FolderType } from '@documenso/lib/types/folder-type';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZFindTemplatesRequestSchema } from '@documenso/trpc/server/template-router/schema';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
import type { RowSelectionState } from '@documenso/ui/primitives/data-table';
|
||||
|
||||
@@ -19,6 +20,7 @@ import { EnvelopeDropZoneWrapper } from '~/components/general/envelope/envelope-
|
||||
import { FolderGrid } from '~/components/general/folder/folder-grid';
|
||||
import { EnvelopesTableBulkActionBar } from '~/components/tables/envelopes-table-bulk-action-bar';
|
||||
import { TemplatesTable } from '~/components/tables/templates-table';
|
||||
import { TemplatesTableToolbar } from '~/components/tables/templates-table-toolbar';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
@@ -26,14 +28,24 @@ export function meta() {
|
||||
return appMetaTags('Templates');
|
||||
}
|
||||
|
||||
const ZTemplatesSearchParamsSchema = ZFindTemplatesRequestSchema.pick({
|
||||
query: true,
|
||||
type: true,
|
||||
page: true,
|
||||
perPage: true,
|
||||
});
|
||||
|
||||
export default function TemplatesPage() {
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { folderId } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const page = Number(searchParams.get('page')) || 1;
|
||||
const perPage = Number(searchParams.get('perPage')) || 10;
|
||||
const findTemplatesSearchParams = useMemo(
|
||||
() =>
|
||||
ZTemplatesSearchParamsSchema.safeParse(Object.fromEntries(searchParams.entries())).data || {},
|
||||
[searchParams],
|
||||
);
|
||||
|
||||
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>(
|
||||
'templates-bulk-selection',
|
||||
@@ -50,8 +62,7 @@ export default function TemplatesPage() {
|
||||
const templateRootPath = formatTemplatesPath(team.url);
|
||||
|
||||
const { data, isLoading, isLoadingError } = trpc.template.findTemplates.useQuery({
|
||||
page: page,
|
||||
perPage: perPage,
|
||||
...findTemplatesSearchParams,
|
||||
folderId,
|
||||
});
|
||||
|
||||
@@ -74,6 +85,10 @@ export default function TemplatesPage() {
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<TemplatesTableToolbar />
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
{data && data.count === 0 ? (
|
||||
<div className="flex h-96 flex-col items-center justify-center gap-y-4 text-muted-foreground/60">
|
||||
|
||||
Reference in New Issue
Block a user