mirror of
https://github.com/documenso/documenso.git
synced 2026-07-27 10:25:00 +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 { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
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 { InfoIcon } from 'lucide-react';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { useSearchParams } from 'react-router';
|
import { useSearchParams } from 'react-router';
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
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 { 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 type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
import { SelectItem } from '@documenso/ui/primitives/select';
|
|
||||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||||
import { TableCell } from '@documenso/ui/primitives/table';
|
import { TableCell } from '@documenso/ui/primitives/table';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
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 { DocumentStatus } from '~/components/general/document/document-status';
|
||||||
import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip';
|
import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip';
|
||||||
import { DocumentsTableActionButton } from '~/components/tables/documents-table-action-button';
|
import { DocumentsTableActionButton } from '~/components/tables/documents-table-action-button';
|
||||||
import { DocumentsTableActionDropdown } from '~/components/tables/documents-table-action-dropdown';
|
import { DocumentsTableActionDropdown } from '~/components/tables/documents-table-action-dropdown';
|
||||||
import { DataTableTitle } from '~/components/tables/documents-table-title';
|
import { DataTableTitle } from '~/components/tables/documents-table-title';
|
||||||
|
import { TemplateDocumentsTableToolbar } from '~/components/tables/template-documents-table-toolbar';
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
import { PeriodSelector } from '../period-selector';
|
|
||||||
|
|
||||||
const DOCUMENT_SOURCE_LABELS: { [key in DocumentSource]: MessageDescriptor } = {
|
const DOCUMENT_SOURCE_LABELS: { [key in DocumentSource]: MessageDescriptor } = {
|
||||||
DOCUMENT: msg`Document`,
|
DOCUMENT: msg`Document`,
|
||||||
TEMPLATE: msg`Template`,
|
TEMPLATE: msg`Template`,
|
||||||
TEMPLATE_DIRECT_LINK: msg`Direct link`,
|
TEMPLATE_DIRECT_LINK: msg`Direct link`,
|
||||||
};
|
};
|
||||||
|
|
||||||
const ZDocumentSearchParamsSchema = ZUrlSearchParamsSchema.extend({
|
const ZDocumentSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({
|
||||||
source: z
|
page: true,
|
||||||
.nativeEnum(DocumentSource)
|
perPage: true,
|
||||||
.optional()
|
query: true,
|
||||||
.catch(() => undefined),
|
period: true,
|
||||||
status: z
|
status: true,
|
||||||
.nativeEnum(DocumentStatusEnum)
|
source: true,
|
||||||
.optional()
|
|
||||||
.catch(() => undefined),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type TemplatePageViewDocumentsTableProps = {
|
type TemplatePageViewDocumentsTableProps = {
|
||||||
@@ -67,7 +60,7 @@ export const TemplatePageViewDocumentsTable = ({
|
|||||||
Object.fromEntries(searchParams ?? []),
|
Object.fromEntries(searchParams ?? []),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, isLoading, isLoadingError } = trpc.document.find.useQuery(
|
const { data, isLoading, isLoadingError } = trpc.document.findDocumentsInternal.useQuery(
|
||||||
{
|
{
|
||||||
templateId,
|
templateId,
|
||||||
page: parsedSearchParams.page,
|
page: parsedSearchParams.page,
|
||||||
@@ -75,6 +68,7 @@ export const TemplatePageViewDocumentsTable = ({
|
|||||||
query: parsedSearchParams.query,
|
query: parsedSearchParams.query,
|
||||||
source: parsedSearchParams.source,
|
source: parsedSearchParams.source,
|
||||||
status: parsedSearchParams.status,
|
status: parsedSearchParams.status,
|
||||||
|
period: parsedSearchParams.period,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
placeholderData: (previousData) => previousData,
|
placeholderData: (previousData) => previousData,
|
||||||
@@ -133,8 +127,8 @@ export const TemplatePageViewDocumentsTable = ({
|
|||||||
<InfoIcon className="mx-2 h-4 w-4" />
|
<InfoIcon className="mx-2 h-4 w-4" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|
||||||
<TooltipContent className="text-foreground max-w-md space-y-2 !p-0">
|
<TooltipContent className="max-w-md space-y-2 !p-0 text-foreground">
|
||||||
<ul className="text-muted-foreground space-y-0.5 divide-y [&>li]:p-4">
|
<ul className="space-y-0.5 divide-y text-muted-foreground [&>li]:p-4">
|
||||||
<li>
|
<li>
|
||||||
<h2 className="mb-2 flex flex-row items-center font-semibold">
|
<h2 className="mb-2 flex flex-row items-center font-semibold">
|
||||||
<Trans>Template</Trans>
|
<Trans>Template</Trans>
|
||||||
@@ -183,49 +177,8 @@ export const TemplatePageViewDocumentsTable = ({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="space-y-4">
|
||||||
<div className="mb-4 flex flex-row space-x-4">
|
<TemplateDocumentsTableToolbar />
|
||||||
<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>
|
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
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 { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { EnvelopeType } from '@prisma/client';
|
import { EnvelopeType } from '@prisma/client';
|
||||||
import { FolderType, OrganisationType } from '@prisma/client';
|
import { FolderType, OrganisationType } from '@prisma/client';
|
||||||
import { useParams, useSearchParams } from 'react-router';
|
import { useParams, useSearchParams } from 'react-router';
|
||||||
import { Link } from 'react-router';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { useSessionStorage } from '@documenso/lib/client-only/hooks/use-session-storage';
|
import { useSessionStorage } from '@documenso/lib/client-only/hooks/use-session-storage';
|
||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
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 { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/find-documents-internal.types';
|
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 { ZFindDocumentsInternalRequestSchema } from '@documenso/trpc/server/document-router/find-documents-internal.types';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||||
import type { RowSelectionState } from '@documenso/ui/primitives/data-table';
|
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 { DocumentMoveToFolderDialog } from '~/components/dialogs/document-move-to-folder-dialog';
|
||||||
import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog';
|
import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog';
|
||||||
import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog';
|
import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog';
|
||||||
import { DocumentSearch } from '~/components/general/document/document-search';
|
import { FRIENDLY_STATUS_MAP } from '~/components/general/document/document-status';
|
||||||
import { DocumentStatus } from '~/components/general/document/document-status';
|
|
||||||
import { EnvelopeDropZoneWrapper } from '~/components/general/envelope/envelope-drop-zone-wrapper';
|
import { EnvelopeDropZoneWrapper } from '~/components/general/envelope/envelope-drop-zone-wrapper';
|
||||||
import { FolderGrid } from '~/components/general/folder/folder-grid';
|
import { FolderGrid } from '~/components/general/folder/folder-grid';
|
||||||
import { PeriodSelector } from '~/components/general/period-selector';
|
|
||||||
import { DocumentsTable } from '~/components/tables/documents-table';
|
import { DocumentsTable } from '~/components/tables/documents-table';
|
||||||
import { DocumentsTableEmptyState } from '~/components/tables/documents-table-empty-state';
|
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 { EnvelopesTableBulkActionBar } from '~/components/tables/envelopes-table-bulk-action-bar';
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
import { appMetaTags } from '~/utils/meta';
|
import { appMetaTags } from '~/utils/meta';
|
||||||
@@ -50,6 +47,8 @@ const ZSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export default function DocumentsPage() {
|
export default function DocumentsPage() {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const organisation = useCurrentOrganisation();
|
const organisation = useCurrentOrganisation();
|
||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
@@ -89,35 +88,37 @@ export default function DocumentsPage() {
|
|||||||
folderId,
|
folderId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => {
|
const statusOptions = useMemo<DataTableFacetedFilterOption[]>(() => {
|
||||||
const params = new URLSearchParams(searchParams);
|
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) {
|
return {
|
||||||
params.delete('status');
|
label: _(label),
|
||||||
}
|
value: status,
|
||||||
|
icon,
|
||||||
|
iconClassName: color,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [organisation.type, _]);
|
||||||
|
|
||||||
if (value === ExtendedDocumentStatus.INBOX && organisation.type === OrganisationType.PERSONAL) {
|
const selectedStatuses = findDocumentSearchParams.status ?? [];
|
||||||
params.delete('status');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.has('page')) {
|
const selectedStatus =
|
||||||
params.delete('page');
|
selectedStatuses.length === 1 ? selectedStatuses[0] : ExtendedDocumentStatus.ALL;
|
||||||
}
|
|
||||||
|
|
||||||
let path = formatDocumentsPath(team.url);
|
|
||||||
|
|
||||||
if (folderId) {
|
|
||||||
path += `/f/${folderId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.toString()) {
|
|
||||||
path += `?${params.toString()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return path;
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data?.stats) {
|
if (data?.stats) {
|
||||||
@@ -143,60 +144,20 @@ export default function DocumentsPage() {
|
|||||||
<Trans>Documents</Trans>
|
<Trans>Documents</Trans>
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
|
<div className="mt-8">
|
||||||
<Tabs value={findDocumentSearchParams.status || 'ALL'} className="overflow-x-auto">
|
<DocumentsTableToolbar
|
||||||
<TabsList>
|
teamId={team?.id}
|
||||||
{[
|
statusOptions={statusOptions}
|
||||||
ExtendedDocumentStatus.INBOX,
|
statusCounts={stats}
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<div>
|
<div>
|
||||||
{data && data.count === 0 ? (
|
{data && data.count === 0 ? (
|
||||||
<DocumentsTableEmptyState
|
<DocumentsTableEmptyState status={selectedStatus} />
|
||||||
status={findDocumentSearchParams.status || ExtendedDocumentStatus.ALL}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<DocumentsTable
|
<DocumentsTable
|
||||||
data={data}
|
data={data}
|
||||||
|
|||||||
@@ -10,6 +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 { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
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 { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||||
import type { RowSelectionState } from '@documenso/ui/primitives/data-table';
|
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 { FolderGrid } from '~/components/general/folder/folder-grid';
|
||||||
import { EnvelopesTableBulkActionBar } from '~/components/tables/envelopes-table-bulk-action-bar';
|
import { EnvelopesTableBulkActionBar } from '~/components/tables/envelopes-table-bulk-action-bar';
|
||||||
import { TemplatesTable } from '~/components/tables/templates-table';
|
import { TemplatesTable } from '~/components/tables/templates-table';
|
||||||
|
import { TemplatesTableToolbar } from '~/components/tables/templates-table-toolbar';
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
import { appMetaTags } from '~/utils/meta';
|
import { appMetaTags } from '~/utils/meta';
|
||||||
|
|
||||||
@@ -26,14 +28,24 @@ export function meta() {
|
|||||||
return appMetaTags('Templates');
|
return appMetaTags('Templates');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ZTemplatesSearchParamsSchema = ZFindTemplatesRequestSchema.pick({
|
||||||
|
query: true,
|
||||||
|
type: true,
|
||||||
|
page: true,
|
||||||
|
perPage: true,
|
||||||
|
});
|
||||||
|
|
||||||
export default function TemplatesPage() {
|
export default function TemplatesPage() {
|
||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
const { folderId } = useParams();
|
const { folderId } = useParams();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
const page = Number(searchParams.get('page')) || 1;
|
const findTemplatesSearchParams = useMemo(
|
||||||
const perPage = Number(searchParams.get('perPage')) || 10;
|
() =>
|
||||||
|
ZTemplatesSearchParamsSchema.safeParse(Object.fromEntries(searchParams.entries())).data || {},
|
||||||
|
[searchParams],
|
||||||
|
);
|
||||||
|
|
||||||
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>(
|
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>(
|
||||||
'templates-bulk-selection',
|
'templates-bulk-selection',
|
||||||
@@ -50,8 +62,7 @@ export default function TemplatesPage() {
|
|||||||
const templateRootPath = formatTemplatesPath(team.url);
|
const templateRootPath = formatTemplatesPath(team.url);
|
||||||
|
|
||||||
const { data, isLoading, isLoadingError } = trpc.template.findTemplates.useQuery({
|
const { data, isLoading, isLoadingError } = trpc.template.findTemplates.useQuery({
|
||||||
page: page,
|
...findTemplatesSearchParams,
|
||||||
perPage: perPage,
|
|
||||||
folderId,
|
folderId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -74,6 +85,10 @@ export default function TemplatesPage() {
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<TemplatesTableToolbar />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
{data && data.count === 0 ? (
|
{data && data.count === 0 ? (
|
||||||
<div className="flex h-96 flex-col items-center justify-center gap-y-4 text-muted-foreground/60">
|
<div className="flex h-96 flex-col items-center justify-center gap-y-4 text-muted-foreground/60">
|
||||||
|
|||||||
@@ -2,12 +2,27 @@ import type { Page } from '@playwright/test';
|
|||||||
import { expect } from '@playwright/test';
|
import { expect } from '@playwright/test';
|
||||||
|
|
||||||
export const checkDocumentTabCount = async (page: Page, tabName: string, count: number) => {
|
export const checkDocumentTabCount = async (page: Page, tabName: string, count: number) => {
|
||||||
await page.getByRole('tab', { name: tabName }).click();
|
const statusMap: Record<string, string | undefined> = {
|
||||||
|
Inbox: 'INBOX',
|
||||||
|
Pending: 'PENDING',
|
||||||
|
Completed: 'COMPLETED',
|
||||||
|
Draft: 'DRAFT',
|
||||||
|
All: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
if (tabName !== 'All') {
|
const currentUrl = new URL(page.url());
|
||||||
await expect(page.getByRole('tab', { name: tabName })).toContainText(count.toString());
|
const status = statusMap[tabName];
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
currentUrl.searchParams.set('status', status);
|
||||||
|
} else {
|
||||||
|
currentUrl.searchParams.delete('status');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentUrl.searchParams.delete('page');
|
||||||
|
|
||||||
|
await page.goto(currentUrl.toString());
|
||||||
|
|
||||||
if (count === 0) {
|
if (count === 0) {
|
||||||
await expect(page.getByTestId('empty-document-state')).toBeVisible();
|
await expect(page.getByTestId('empty-document-state')).toBeVisible();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ test('[TEAMS]: check team documents count', async ({ page }) => {
|
|||||||
await checkDocumentTabCount(page, 'All', 5);
|
await checkDocumentTabCount(page, 'All', 5);
|
||||||
|
|
||||||
// Apply filter.
|
// Apply filter.
|
||||||
await page.locator('button').filter({ hasText: 'Sender: All' }).click();
|
await page.getByRole('button', { name: /Sender/ }).click();
|
||||||
await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
|
await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
|
||||||
await page.waitForURL(/senderIds/);
|
await page.waitForURL(/senderIds/);
|
||||||
|
|
||||||
@@ -51,6 +51,21 @@ test('[TEAMS]: check team documents count', async ({ page }) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('[TEAMS]: supports filtering documents by multiple statuses', async ({ page }) => {
|
||||||
|
const { team, teamOwner } = await seedTeamDocuments();
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: teamOwner.email,
|
||||||
|
redirectPath: `/t/${team.url}/documents?status=PENDING,DRAFT`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/status=PENDING,DRAFT/);
|
||||||
|
await expect(page.getByTestId('data-table-count')).toContainText('Showing 4');
|
||||||
|
|
||||||
|
await apiSignout({ page });
|
||||||
|
});
|
||||||
|
|
||||||
test('[TEAMS]: check team documents count with internal team email', async ({ page }) => {
|
test('[TEAMS]: check team documents count with internal team email', async ({ page }) => {
|
||||||
const { team, teamOwner, teamMember2, teamMember4 } = await seedTeamDocuments();
|
const { team, teamOwner, teamMember2, teamMember4 } = await seedTeamDocuments();
|
||||||
const {
|
const {
|
||||||
@@ -135,7 +150,7 @@ test('[TEAMS]: check team documents count with internal team email', async ({ pa
|
|||||||
await checkDocumentTabCount(page, 'All', 11);
|
await checkDocumentTabCount(page, 'All', 11);
|
||||||
|
|
||||||
// Apply filter.
|
// Apply filter.
|
||||||
await page.locator('button').filter({ hasText: 'Sender: All' }).click();
|
await page.getByRole('button', { name: /Sender/ }).click();
|
||||||
await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
|
await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
|
||||||
await page.waitForURL(/senderIds/);
|
await page.waitForURL(/senderIds/);
|
||||||
|
|
||||||
@@ -222,7 +237,7 @@ test('[TEAMS]: check team documents count with external team email', async ({ pa
|
|||||||
await checkDocumentTabCount(page, 'All', 9);
|
await checkDocumentTabCount(page, 'All', 9);
|
||||||
|
|
||||||
// Apply filter.
|
// Apply filter.
|
||||||
await page.locator('button').filter({ hasText: 'Sender: All' }).click();
|
await page.getByRole('button', { name: /Sender/ }).click();
|
||||||
await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
|
await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
|
||||||
await page.waitForURL(/senderIds/);
|
await page.waitForURL(/senderIds/);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
import { TeamMemberRole } from '@prisma/client';
|
import { TeamMemberRole, TemplateType } from '@prisma/client';
|
||||||
|
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||||
import { seedTemplate } from '@documenso/prisma/seed/templates';
|
import { seedTemplate } from '@documenso/prisma/seed/templates';
|
||||||
|
|
||||||
@@ -41,6 +42,56 @@ test('[TEMPLATES]: view templates', async ({ page }) => {
|
|||||||
await expect(page.getByTestId('data-table-count')).toContainText('Showing 2 results');
|
await expect(page.getByTestId('data-table-count')).toContainText('Showing 2 results');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('[TEMPLATES]: supports search and multi-type filtering', async ({ page }) => {
|
||||||
|
const { team, owner } = await seedTeam({
|
||||||
|
createTeamMembers: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const publicTemplate = await seedTemplate({
|
||||||
|
title: 'Public Team Template',
|
||||||
|
userId: owner.id,
|
||||||
|
teamId: team.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const privateTemplate = await seedTemplate({
|
||||||
|
title: 'Private Team Template',
|
||||||
|
userId: owner.id,
|
||||||
|
teamId: team.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.envelope.update({
|
||||||
|
where: {
|
||||||
|
id: publicTemplate.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
templateType: TemplateType.PUBLIC,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.envelope.update({
|
||||||
|
where: {
|
||||||
|
id: privateTemplate.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
templateType: TemplateType.PRIVATE,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: owner.email,
|
||||||
|
redirectPath: `/t/${team.url}/templates?query=Public&type=PUBLIC`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(page.getByRole('link', { name: 'Public Team Template' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: 'Private Team Template' })).not.toBeVisible();
|
||||||
|
|
||||||
|
await page.goto(`/t/${team.url}/templates?type=PUBLIC,PRIVATE`);
|
||||||
|
|
||||||
|
await expect(page.getByRole('link', { name: 'Public Team Template' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: 'Private Team Template' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test('[TEMPLATES]: delete template', async ({ page }) => {
|
test('[TEMPLATES]: delete template', async ({ page }) => {
|
||||||
const { team, owner, organisation } = await seedTeam({
|
const { team, owner, organisation } = await seedTeam({
|
||||||
createTeamMembers: 1,
|
createTeamMembers: 1,
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
import type { NavigateOptions } from 'react-router';
|
||||||
import { useSearchParams } from 'react-router';
|
import { useSearchParams } from 'react-router';
|
||||||
|
|
||||||
export const useUpdateSearchParams = () => {
|
type SearchParamValues = Record<string, string | number | boolean | null | undefined>;
|
||||||
|
type UpdateSearchParamsOptions = Pick<NavigateOptions, 'preventScrollReset' | 'replace' | 'state'>;
|
||||||
|
|
||||||
|
export const useUpdateSearchParams = (defaultOptions: UpdateSearchParamsOptions = {}) => {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
return (params: Record<string, string | number | boolean | null | undefined>) => {
|
return (params: SearchParamValues, options?: UpdateSearchParamsOptions) => {
|
||||||
const nextSearchParams = new URLSearchParams(searchParams?.toString() ?? '');
|
const nextSearchParams = new URLSearchParams(searchParams?.toString() ?? '');
|
||||||
|
|
||||||
Object.entries(params).forEach(([key, value]) => {
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
@@ -14,6 +18,9 @@ export const useUpdateSearchParams = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
setSearchParams(nextSearchParams);
|
setSearchParams(nextSearchParams, {
|
||||||
|
...defaultOptions,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,14 +11,14 @@ import { type FindResultResponse } from '../../types/search-params';
|
|||||||
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
|
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
|
||||||
import { getTeamById } from '../team/get-team';
|
import { getTeamById } from '../team/get-team';
|
||||||
|
|
||||||
export type PeriodSelectorValue = '' | '7d' | '14d' | '30d';
|
export type PeriodSelectorValue = '' | 'all' | '7d' | '14d' | '30d';
|
||||||
|
|
||||||
export type FindDocumentsOptions = {
|
export type FindDocumentsOptions = {
|
||||||
userId: number;
|
userId: number;
|
||||||
teamId?: number;
|
teamId?: number;
|
||||||
templateId?: number;
|
templateId?: number;
|
||||||
source?: DocumentSource;
|
source?: DocumentSource | DocumentSource[];
|
||||||
status?: ExtendedDocumentStatus;
|
status?: ExtendedDocumentStatus | ExtendedDocumentStatus[];
|
||||||
page?: number;
|
page?: number;
|
||||||
perPage?: number;
|
perPage?: number;
|
||||||
orderBy?: {
|
orderBy?: {
|
||||||
@@ -36,7 +36,7 @@ export const findDocuments = async ({
|
|||||||
teamId,
|
teamId,
|
||||||
templateId,
|
templateId,
|
||||||
source,
|
source,
|
||||||
status = ExtendedDocumentStatus.ALL,
|
status,
|
||||||
page = 1,
|
page = 1,
|
||||||
perPage = 10,
|
perPage = 10,
|
||||||
orderBy,
|
orderBy,
|
||||||
@@ -69,6 +69,8 @@ export const findDocuments = async ({
|
|||||||
const orderByDirection = orderBy?.direction ?? 'desc';
|
const orderByDirection = orderBy?.direction ?? 'desc';
|
||||||
const teamMemberRole = team?.currentTeamRole ?? null;
|
const teamMemberRole = team?.currentTeamRole ?? null;
|
||||||
|
|
||||||
|
const normalizedStatuses = normalizeStatuses(status);
|
||||||
|
|
||||||
const searchFilter: Prisma.EnvelopeWhereInput = {
|
const searchFilter: Prisma.EnvelopeWhereInput = {
|
||||||
OR: [
|
OR: [
|
||||||
{ title: { contains: query, mode: 'insensitive' } },
|
{ title: { contains: query, mode: 'insensitive' } },
|
||||||
@@ -111,10 +113,16 @@ export const findDocuments = async ({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
let filters: Prisma.EnvelopeWhereInput | null = findDocumentsFilter(status, user, folderId);
|
let filters: Prisma.EnvelopeWhereInput | null = mergeStatusFilters(
|
||||||
|
normalizedStatuses.map((currentStatus) => findDocumentsFilter(currentStatus, user, folderId)),
|
||||||
|
);
|
||||||
|
|
||||||
if (team) {
|
if (team) {
|
||||||
filters = findTeamDocumentsFilter(status, team, visibilityFilters, folderId);
|
filters = mergeStatusFilters(
|
||||||
|
normalizedStatuses.map((currentStatus) =>
|
||||||
|
findTeamDocumentsFilter(currentStatus, team, visibilityFilters, folderId),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters === null) {
|
if (filters === null) {
|
||||||
@@ -193,8 +201,12 @@ export const findDocuments = async ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (source) {
|
if (source) {
|
||||||
|
const sources = Array.isArray(source) ? source : [source];
|
||||||
|
|
||||||
whereAndClause.push({
|
whereAndClause.push({
|
||||||
source,
|
source: {
|
||||||
|
in: sources,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,7 +215,7 @@ export const findDocuments = async ({
|
|||||||
AND: whereAndClause,
|
AND: whereAndClause,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (period) {
|
if (period && period !== 'all') {
|
||||||
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
|
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
|
||||||
|
|
||||||
const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day');
|
const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day');
|
||||||
@@ -219,11 +231,7 @@ export const findDocuments = async ({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (folderId !== undefined) {
|
whereClause.folderId = folderId ?? null;
|
||||||
whereClause.folderId = folderId;
|
|
||||||
} else {
|
|
||||||
whereClause.folderId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [data, count] = await Promise.all([
|
const [data, count] = await Promise.all([
|
||||||
prisma.envelope.findMany({
|
prisma.envelope.findMany({
|
||||||
@@ -279,6 +287,39 @@ export const findDocuments = async ({
|
|||||||
} satisfies FindResultResponse<typeof data>;
|
} satisfies FindResultResponse<typeof data>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeStatuses = (status: FindDocumentsOptions['status']) => {
|
||||||
|
if (!status) {
|
||||||
|
return [ExtendedDocumentStatus.ALL];
|
||||||
|
}
|
||||||
|
|
||||||
|
const statuses = Array.isArray(status) ? status : [status];
|
||||||
|
const dedupedStatuses = Array.from(new Set(statuses));
|
||||||
|
|
||||||
|
if (dedupedStatuses.includes(ExtendedDocumentStatus.ALL)) {
|
||||||
|
return [ExtendedDocumentStatus.ALL];
|
||||||
|
}
|
||||||
|
|
||||||
|
return dedupedStatuses;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergeStatusFilters = (filters: Array<Prisma.EnvelopeWhereInput | null>) => {
|
||||||
|
const validFilters = filters.filter(
|
||||||
|
(filter): filter is Prisma.EnvelopeWhereInput => filter !== null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (validFilters.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validFilters.length === 1) {
|
||||||
|
return validFilters[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
OR: validFilters,
|
||||||
|
} satisfies Prisma.EnvelopeWhereInput;
|
||||||
|
};
|
||||||
|
|
||||||
const findDocumentsFilter = (
|
const findDocumentsFilter = (
|
||||||
status: ExtendedDocumentStatus,
|
status: ExtendedDocumentStatus,
|
||||||
user: Pick<User, 'id' | 'email' | 'name'>,
|
user: Pick<User, 'id' | 'email' | 'name'>,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const getStats = async ({
|
|||||||
}: GetStatsInput) => {
|
}: GetStatsInput) => {
|
||||||
let createdAt: Prisma.EnvelopeWhereInput['createdAt'];
|
let createdAt: Prisma.EnvelopeWhereInput['createdAt'];
|
||||||
|
|
||||||
if (period) {
|
if (period && period !== 'all') {
|
||||||
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
|
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
|
||||||
|
|
||||||
const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day');
|
const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day');
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import { getMemberRoles } from '../team/get-member-roles';
|
|||||||
export type FindTemplatesOptions = {
|
export type FindTemplatesOptions = {
|
||||||
userId: number;
|
userId: number;
|
||||||
teamId: number;
|
teamId: number;
|
||||||
type?: TemplateType;
|
type?: TemplateType | TemplateType[];
|
||||||
|
query?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
perPage?: number;
|
perPage?: number;
|
||||||
folderId?: string;
|
folderId?: string;
|
||||||
@@ -20,12 +21,15 @@ export const findTemplates = async ({
|
|||||||
userId,
|
userId,
|
||||||
teamId,
|
teamId,
|
||||||
type,
|
type,
|
||||||
|
query = '',
|
||||||
page = 1,
|
page = 1,
|
||||||
perPage = 10,
|
perPage = 10,
|
||||||
folderId,
|
folderId,
|
||||||
}: FindTemplatesOptions) => {
|
}: FindTemplatesOptions) => {
|
||||||
const whereFilter: Prisma.EnvelopeWhereInput[] = [];
|
const whereFilter: Prisma.EnvelopeWhereInput[] = [];
|
||||||
|
|
||||||
|
const templateTypeFilter = type ? { in: Array.isArray(type) ? type : [type] } : undefined;
|
||||||
|
|
||||||
const { teamRole } = await getMemberRoles({
|
const { teamRole } = await getMemberRoles({
|
||||||
teamId,
|
teamId,
|
||||||
reference: {
|
reference: {
|
||||||
@@ -54,11 +58,30 @@ export const findTemplates = async ({
|
|||||||
whereFilter.push({ folderId: null });
|
whereFilter.push({ folderId: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
whereFilter.push({
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
title: {
|
||||||
|
contains: query,
|
||||||
|
mode: 'insensitive',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
externalId: {
|
||||||
|
contains: query,
|
||||||
|
mode: 'insensitive',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const [data, count] = await Promise.all([
|
const [data, count] = await Promise.all([
|
||||||
prisma.envelope.findMany({
|
prisma.envelope.findMany({
|
||||||
where: {
|
where: {
|
||||||
type: EnvelopeType.TEMPLATE,
|
type: EnvelopeType.TEMPLATE,
|
||||||
templateType: type,
|
templateType: templateTypeFilter,
|
||||||
AND: whereFilter,
|
AND: whereFilter,
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
@@ -87,7 +110,7 @@ export const findTemplates = async ({
|
|||||||
prisma.envelope.count({
|
prisma.envelope.count({
|
||||||
where: {
|
where: {
|
||||||
type: EnvelopeType.TEMPLATE,
|
type: EnvelopeType.TEMPLATE,
|
||||||
templateType: type,
|
templateType: templateTypeFilter,
|
||||||
AND: whereFilter,
|
AND: whereFilter,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
/**
|
|
||||||
* From an unknown string, parse it into an integer array.
|
|
||||||
*
|
|
||||||
* Filter out unknown values.
|
|
||||||
*/
|
|
||||||
export const parseToIntegerArray = (value: unknown): number[] => {
|
export const parseToIntegerArray = (value: unknown): number[] => {
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
return [];
|
return [];
|
||||||
@@ -14,6 +9,30 @@ export const parseToIntegerArray = (value: unknown): number[] => {
|
|||||||
.filter((value) => !isNaN(value));
|
.filter((value) => !isNaN(value));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const parseToStringArray = (value: unknown): string[] => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.filter((item): item is string => typeof item === 'string');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
.split(',')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseCommaSeparatedValues = (value: unknown): string[] | undefined => {
|
||||||
|
const parsed = parseToStringArray(value);
|
||||||
|
return parsed.length > 0 ? parsed : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toCommaSeparatedSearchParam = (values: string[]): string | undefined => {
|
||||||
|
return values.length > 0 ? values.join(',') : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
type GetRootHrefOptions = {
|
type GetRootHrefOptions = {
|
||||||
returnEmptyRootString?: boolean;
|
returnEmptyRootString?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
|
import { DocumentSource } from '@prisma/client';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { ZDocumentManySchema } from '@documenso/lib/types/document';
|
import { ZDocumentManySchema } from '@documenso/lib/types/document';
|
||||||
import { ZFindResultResponse } from '@documenso/lib/types/search-params';
|
import { ZFindResultResponse } from '@documenso/lib/types/search-params';
|
||||||
|
import { parseCommaSeparatedValues } from '@documenso/lib/utils/params';
|
||||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||||
|
|
||||||
import { ZFindDocumentsRequestSchema } from './find-documents.types';
|
import { ZFindDocumentsRequestSchema } from './find-documents.types';
|
||||||
|
|
||||||
export const ZFindDocumentsInternalRequestSchema = ZFindDocumentsRequestSchema.extend({
|
export const ZFindDocumentsInternalRequestSchema = ZFindDocumentsRequestSchema.extend({
|
||||||
period: z.enum(['7d', '14d', '30d']).optional(),
|
period: z.enum(['all', '7d', '14d', '30d']).optional(),
|
||||||
senderIds: z.array(z.number()).optional(),
|
senderIds: z.array(z.number()).optional(),
|
||||||
status: z.nativeEnum(ExtendedDocumentStatus).optional(),
|
source: z.preprocess(parseCommaSeparatedValues, z.array(z.nativeEnum(DocumentSource)).optional()),
|
||||||
|
status: z.preprocess(
|
||||||
|
parseCommaSeparatedValues,
|
||||||
|
z.array(z.nativeEnum(ExtendedDocumentStatus)).optional(),
|
||||||
|
),
|
||||||
folderId: z.string().optional(),
|
folderId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
ZTemplateManySchema,
|
ZTemplateManySchema,
|
||||||
ZTemplateSchema,
|
ZTemplateSchema,
|
||||||
} from '@documenso/lib/types/template';
|
} from '@documenso/lib/types/template';
|
||||||
|
import { parseCommaSeparatedValues } from '@documenso/lib/utils/params';
|
||||||
import { LegacyTemplateDirectLinkSchema } from '@documenso/prisma/types/template-legacy-schema';
|
import { LegacyTemplateDirectLinkSchema } from '@documenso/prisma/types/template-legacy-schema';
|
||||||
import { ZDocumentExternalIdSchema } from '@documenso/trpc/server/document-router/schema';
|
import { ZDocumentExternalIdSchema } from '@documenso/trpc/server/document-router/schema';
|
||||||
|
|
||||||
@@ -289,7 +290,9 @@ export const ZUpdateTemplateRequestSchema = z.object({
|
|||||||
export const ZUpdateTemplateResponseSchema = ZTemplateLiteSchema;
|
export const ZUpdateTemplateResponseSchema = ZTemplateLiteSchema;
|
||||||
|
|
||||||
export const ZFindTemplatesRequestSchema = ZFindSearchParamsSchema.extend({
|
export const ZFindTemplatesRequestSchema = ZFindSearchParamsSchema.extend({
|
||||||
type: z.nativeEnum(TemplateType).describe('Filter templates by type.').optional(),
|
type: z
|
||||||
|
.preprocess(parseCommaSeparatedValues, z.array(z.nativeEnum(TemplateType)).optional())
|
||||||
|
.describe('Filter templates by type.'),
|
||||||
folderId: z.string().describe('The ID of the folder to filter templates by.').optional(),
|
folderId: z.string().describe('The ID of the folder to filter templates by.').optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { Check, PlusCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '../lib/utils';
|
||||||
|
import { Badge } from './badge';
|
||||||
|
import { Button } from './button';
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
CommandSeparator,
|
||||||
|
} from './command';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from './popover';
|
||||||
|
import { Separator } from './separator';
|
||||||
|
|
||||||
|
export type DataTableFacetedFilterOption = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
icon?: React.ComponentType<{ className?: string }>;
|
||||||
|
iconClassName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DataTableFacetedFilterProps = {
|
||||||
|
title: string;
|
||||||
|
options: DataTableFacetedFilterOption[];
|
||||||
|
selectedValues: string[];
|
||||||
|
onSelectedValuesChange: (values: string[]) => void;
|
||||||
|
singleSelect?: boolean;
|
||||||
|
counts?: Record<string, number>;
|
||||||
|
showSearch?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DataTableFacetedFilter = ({
|
||||||
|
title,
|
||||||
|
options,
|
||||||
|
selectedValues,
|
||||||
|
onSelectedValuesChange,
|
||||||
|
singleSelect,
|
||||||
|
counts,
|
||||||
|
showSearch = true,
|
||||||
|
}: DataTableFacetedFilterProps) => {
|
||||||
|
const selectedValuesSet = new Set(selectedValues);
|
||||||
|
|
||||||
|
const selectedOptions = options.filter((option) => selectedValuesSet.has(option.value));
|
||||||
|
|
||||||
|
const onSelect = (value: string) => {
|
||||||
|
if (singleSelect) {
|
||||||
|
const nextValue = selectedValuesSet.has(value) ? [] : [value];
|
||||||
|
|
||||||
|
onSelectedValuesChange(nextValue);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextValues = new Set(selectedValuesSet);
|
||||||
|
|
||||||
|
if (nextValues.has(value)) {
|
||||||
|
nextValues.delete(value);
|
||||||
|
} else {
|
||||||
|
nextValues.add(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelectedValuesChange(Array.from(nextValues));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" className="h-9 border-dashed">
|
||||||
|
<PlusCircle className="mr-2 h-4 w-4" />
|
||||||
|
{title}
|
||||||
|
|
||||||
|
{selectedValues.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||||
|
|
||||||
|
<Badge
|
||||||
|
variant="neutral"
|
||||||
|
size="small"
|
||||||
|
className="rounded-sm px-1 font-normal lg:hidden"
|
||||||
|
>
|
||||||
|
{selectedValues.length}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
<div className="hidden gap-1 lg:flex">
|
||||||
|
{selectedValues.length > 2 ? (
|
||||||
|
<Badge variant="neutral" size="small" className="rounded-sm px-1 font-normal">
|
||||||
|
{selectedValues.length} <Trans>selected</Trans>
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
selectedOptions.map((option) => (
|
||||||
|
<Badge
|
||||||
|
key={option.value}
|
||||||
|
variant="neutral"
|
||||||
|
size="small"
|
||||||
|
className="rounded-sm px-1 font-normal"
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
|
||||||
|
<PopoverContent className="w-[220px] p-0" align="start">
|
||||||
|
<Command shouldFilter={showSearch}>
|
||||||
|
{showSearch && <CommandInput placeholder={title} />}
|
||||||
|
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>
|
||||||
|
<Trans>No results found.</Trans>
|
||||||
|
</CommandEmpty>
|
||||||
|
|
||||||
|
<CommandGroup>
|
||||||
|
{options.map((option) => {
|
||||||
|
const isSelected = selectedValuesSet.has(option.value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandItem key={option.value} onSelect={() => onSelect(option.value)}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
|
||||||
|
isSelected
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'opacity-50 [&_svg]:invisible',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{option.icon && (
|
||||||
|
<option.icon
|
||||||
|
className={cn(
|
||||||
|
'mr-2 h-4 w-4',
|
||||||
|
option.iconClassName ?? 'text-muted-foreground',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span>{option.label}</span>
|
||||||
|
|
||||||
|
{counts && counts[option.value] !== undefined && (
|
||||||
|
<span className="ml-auto flex h-4 w-4 items-center justify-center font-mono text-xs text-muted-foreground">
|
||||||
|
{counts[option.value]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
|
||||||
|
{selectedValues.length > 0 && (
|
||||||
|
<>
|
||||||
|
<CommandSeparator />
|
||||||
|
|
||||||
|
<CommandGroup>
|
||||||
|
<CommandItem
|
||||||
|
onSelect={() => onSelectedValuesChange([])}
|
||||||
|
className="justify-center text-center"
|
||||||
|
>
|
||||||
|
<Trans>Clear filters</Trans>
|
||||||
|
</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -18,7 +18,7 @@ export type DataTableChildren<TData> = (_table: TTable<TData>) => React.ReactNod
|
|||||||
|
|
||||||
export type { ColumnDef as DataTableColumnDef, RowSelectionState } from '@tanstack/react-table';
|
export type { ColumnDef as DataTableColumnDef, RowSelectionState } from '@tanstack/react-table';
|
||||||
|
|
||||||
export interface DataTableProps<TData, TValue> {
|
export type DataTableProps<TData, TValue> = {
|
||||||
columns: ColumnDef<TData, TValue>[];
|
columns: ColumnDef<TData, TValue>[];
|
||||||
columnVisibility?: VisibilityState;
|
columnVisibility?: VisibilityState;
|
||||||
data: TData[];
|
data: TData[];
|
||||||
@@ -45,7 +45,7 @@ export interface DataTableProps<TData, TValue> {
|
|||||||
rowSelection?: RowSelectionState;
|
rowSelection?: RowSelectionState;
|
||||||
onRowSelectionChange?: (selection: RowSelectionState) => void;
|
onRowSelectionChange?: (selection: RowSelectionState) => void;
|
||||||
getRowId?: (row: TData) => string;
|
getRowId?: (row: TData) => string;
|
||||||
}
|
};
|
||||||
|
|
||||||
export function DataTable<TData, TValue>({
|
export function DataTable<TData, TValue>({
|
||||||
columns,
|
columns,
|
||||||
@@ -167,7 +167,7 @@ export function DataTable<TData, TValue>({
|
|||||||
)}
|
)}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : skeleton?.enable ? (
|
) : skeleton?.enable ? (
|
||||||
Array.from({ length: skeleton.rows }).map((_, i) => (
|
Array.from({ length: skeleton.rows }, (_, i) => (
|
||||||
<TableRow key={`skeleton-row-${i}`}>{skeleton.component ?? <Skeleton />}</TableRow>
|
<TableRow key={`skeleton-row-${i}`}>{skeleton.component ?? <Skeleton />}</TableRow>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
@@ -181,6 +181,7 @@ export function DataTable<TData, TValue>({
|
|||||||
|
|
||||||
{hasFilters && onClearFilters !== undefined && (
|
{hasFilters && onClearFilters !== undefined && (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => onClearFilters()}
|
onClick={() => onClearFilters()}
|
||||||
className="mt-1 text-sm text-foreground"
|
className="mt-1 text-sm text-foreground"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user