mirror of
https://github.com/documenso/documenso.git
synced 2026-07-11 05:25:08 +10:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 847244ccda | |||
| 92f3b3ffc4 | |||
| a395c9b57b | |||
| 179902a96f | |||
| 77889270b8 | |||
| ecc99b8e4f | |||
| 89c804a635 |
@@ -0,0 +1,49 @@
|
||||
import { Select, SelectContent, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
||||
import type React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router';
|
||||
|
||||
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="max-w-[200px] text-muted-foreground">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent position="popper">{children}</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
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)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+41
-1
@@ -42,6 +42,7 @@ import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import { Combobox } from '@documenso/ui/primitives/combobox';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -51,7 +52,15 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
||||
@@ -94,6 +103,7 @@ export const ZAddSettingsFormSchema = z.object({
|
||||
timezone: ZDocumentMetaTimezoneSchema.default(DEFAULT_DOCUMENT_TIME_ZONE),
|
||||
dateFormat: ZDocumentMetaDateFormatSchema.default(DEFAULT_DOCUMENT_DATE_FORMAT),
|
||||
distributionMethod: z.nativeEnum(DocumentDistributionMethod).optional().default(DocumentDistributionMethod.EMAIL),
|
||||
includeAuditLog: z.boolean().default(false),
|
||||
redirectUrl: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -194,6 +204,7 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
dateFormat: (envelope.documentMeta.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT) as TDocumentMetaDateFormat,
|
||||
distributionMethod: envelope.documentMeta.distributionMethod || DocumentDistributionMethod.EMAIL,
|
||||
includeAuditLog: envelope.documentMeta.includeAuditLog,
|
||||
redirectUrl: envelope.documentMeta.redirectUrl ?? '',
|
||||
language: envelope.documentMeta.language ?? 'en',
|
||||
emailId: envelope.documentMeta.emailId ?? null,
|
||||
@@ -251,6 +262,7 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
|
||||
emailReplyTo,
|
||||
envelopeExpirationPeriod,
|
||||
reminderSettings,
|
||||
includeAuditLog,
|
||||
} = data.meta;
|
||||
|
||||
const parsedGlobalAccessAuth = z.array(ZDocumentAccessAuthTypesSchema).safeParse(data.globalAccessAuth);
|
||||
@@ -280,6 +292,7 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
|
||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||
envelopeExpirationPeriod,
|
||||
reminderSettings,
|
||||
includeAuditLog,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -924,6 +937,33 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="meta.includeAuditLog"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
id="include-audit-log"
|
||||
checked={field.value}
|
||||
disabled={field.disabled}
|
||||
onCheckedChange={(checked) => field.onChange(Boolean(checked))}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormLabel className="ml-2 text-muted-foreground text-sm" htmlFor="include-audit-log">
|
||||
<Trans>Include audit logs in signed PDF</Trans>
|
||||
</FormLabel>
|
||||
</div>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>Audit logs can still be downloaded separately from the document logs page.</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!isEmbedded && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useMemo } from 'react';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router';
|
||||
|
||||
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="max-w-[200px] text-muted-foreground">
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +1,10 @@
|
||||
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';
|
||||
@@ -11,33 +12,39 @@ import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { DocumentSource } from '@prisma/client';
|
||||
import { DocumentSource, DocumentStatus as DocumentStatusEnum } from '@prisma/client';
|
||||
import { InfoIcon } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
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 = ZFindDocumentsInternalRequestSchema.pick({
|
||||
page: true,
|
||||
perPage: true,
|
||||
query: true,
|
||||
period: true,
|
||||
status: true,
|
||||
source: true,
|
||||
const ZDocumentSearchParamsSchema = ZUrlSearchParamsSchema.extend({
|
||||
source: z
|
||||
.nativeEnum(DocumentSource)
|
||||
.optional()
|
||||
.catch(() => undefined),
|
||||
status: z
|
||||
.nativeEnum(DocumentStatusEnum)
|
||||
.optional()
|
||||
.catch(() => undefined),
|
||||
});
|
||||
|
||||
type TemplatePageViewDocumentsTableProps = {
|
||||
@@ -52,14 +59,9 @@ export const TemplatePageViewDocumentsTable = ({ templateId }: TemplatePageViewD
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const searchParamsString = searchParams.toString();
|
||||
const parsedSearchParams = ZDocumentSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
|
||||
|
||||
const parsedSearchParams = useMemo(
|
||||
() => ZDocumentSearchParamsSchema.parse(Object.fromEntries(searchParams)),
|
||||
[searchParamsString],
|
||||
);
|
||||
|
||||
const { data, isLoading, isLoadingError } = trpc.document.findDocumentsInternal.useQuery(
|
||||
const { data, isLoading, isLoadingError } = trpc.document.find.useQuery(
|
||||
{
|
||||
templateId,
|
||||
page: parsedSearchParams.page,
|
||||
@@ -67,7 +69,6 @@ export const TemplatePageViewDocumentsTable = ({ templateId }: TemplatePageViewD
|
||||
query: parsedSearchParams.query,
|
||||
source: parsedSearchParams.source,
|
||||
status: parsedSearchParams.status,
|
||||
period: parsedSearchParams.period,
|
||||
},
|
||||
{
|
||||
placeholderData: (previousData) => previousData,
|
||||
@@ -165,11 +166,48 @@ export const TemplatePageViewDocumentsTable = ({ templateId }: TemplatePageViewD
|
||||
),
|
||||
},
|
||||
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
||||
}, [_, i18n, team?.url]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<TemplateDocumentsTableToolbar />
|
||||
<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>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
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';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router';
|
||||
|
||||
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="font-normal text-muted-foreground">
|
||||
<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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,196 +0,0 @@
|
||||
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 statusParam = searchParams.get('status');
|
||||
const senderIdsParam = searchParams.get('senderIds');
|
||||
|
||||
const selectedStatusValues = useMemo(() => parseToStringArray(statusParam), [statusParam]);
|
||||
const selectedSenderValues = useMemo(() => parseToStringArray(senderIdsParam), [senderIdsParam]);
|
||||
|
||||
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 },
|
||||
{ replace: true },
|
||||
);
|
||||
}, [debouncedSearchTerm, query, searchTerm, updateSearchParams]);
|
||||
|
||||
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 }, { replace: true });
|
||||
}}
|
||||
>
|
||||
<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,
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
{teamId !== undefined && (
|
||||
<DataTableFacetedFilter
|
||||
title={_(msg`Sender`)}
|
||||
options={senderOptions}
|
||||
selectedValues={selectedSenderValues}
|
||||
showSearch
|
||||
onSelectedValuesChange={(values) => {
|
||||
updateSearchParams(
|
||||
{
|
||||
senderIds: toCommaSeparatedSearchParam(values),
|
||||
page: undefined,
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DataTableFacetedFilter
|
||||
title={_(msg`Time`)}
|
||||
options={periodOptions}
|
||||
selectedValues={period ? [period] : []}
|
||||
singleSelect
|
||||
showSearch={false}
|
||||
onSelectedValuesChange={(values) => {
|
||||
const nextPeriod = values[0];
|
||||
|
||||
updateSearchParams(
|
||||
{
|
||||
period: nextPeriod ?? undefined,
|
||||
page: undefined,
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<Button variant="ghost" size="sm" onClick={onResetFilters}>
|
||||
<Trans>Reset</Trans>
|
||||
<XIcon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
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',
|
||||
},
|
||||
];
|
||||
@@ -1,206 +0,0 @@
|
||||
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 statusParam = searchParams.get('status');
|
||||
const sourceParam = searchParams.get('source');
|
||||
|
||||
const selectedStatusValues = useMemo(() => parseToStringArray(statusParam), [statusParam]);
|
||||
const selectedSourceValues = useMemo(() => parseToStringArray(sourceParam), [sourceParam]);
|
||||
|
||||
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 },
|
||||
{ replace: true },
|
||||
);
|
||||
}, [debouncedSearchTerm, query, searchTerm, updateSearchParams]);
|
||||
|
||||
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 }, { replace: true });
|
||||
}}
|
||||
>
|
||||
<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,
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<DataTableFacetedFilter
|
||||
title={_(msg`Source`)}
|
||||
options={sourceOptions}
|
||||
selectedValues={selectedSourceValues}
|
||||
showSearch={false}
|
||||
onSelectedValuesChange={(values) => {
|
||||
updateSearchParams(
|
||||
{
|
||||
source: toCommaSeparatedSearchParam(values),
|
||||
page: undefined,
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<DataTableFacetedFilter
|
||||
title={_(msg`Time`)}
|
||||
options={periodOptions}
|
||||
selectedValues={period ? [period] : []}
|
||||
singleSelect
|
||||
showSearch={false}
|
||||
onSelectedValuesChange={(values) => {
|
||||
const nextPeriod = values[0];
|
||||
|
||||
updateSearchParams(
|
||||
{
|
||||
period: nextPeriod ?? undefined,
|
||||
page: undefined,
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<Button variant="ghost" size="sm" onClick={onResetFilters}>
|
||||
<Trans>Reset</Trans>
|
||||
<XIcon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,131 +0,0 @@
|
||||
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 typeParam = searchParams.get('type');
|
||||
|
||||
const selectedTypeValues = useMemo(() => parseToStringArray(typeParam), [typeParam]);
|
||||
|
||||
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 },
|
||||
{ replace: true },
|
||||
);
|
||||
}, [debouncedSearchTerm, query, searchTerm, updateSearchParams]);
|
||||
|
||||
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 }, { replace: true });
|
||||
}}
|
||||
>
|
||||
<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,
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<Button variant="ghost" size="sm" onClick={onResetFilters}>
|
||||
<Trans>Reset</Trans>
|
||||
<XIcon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useSessionStorage } from '@documenso/lib/client-only/hooks/use-session-storage';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { STATS_COUNT_CAP } from '@documenso/lib/constants/document';
|
||||
import { SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { parseToIntegerArray } from '@documenso/lib/utils/params';
|
||||
@@ -10,24 +11,25 @@ import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/docu
|
||||
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 type { DataTableFacetedFilterOption } from '@documenso/ui/primitives/data-table-faceted-filter';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType, FolderType, OrganisationType } from '@prisma/client';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router';
|
||||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { EnvelopesBulkCancelDialog } from '~/components/dialogs/envelopes-bulk-cancel-dialog';
|
||||
import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog';
|
||||
import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog';
|
||||
import { FRIENDLY_STATUS_MAP } from '~/components/general/document/document-status';
|
||||
import { DocumentSearch } from '~/components/general/document/document-search';
|
||||
import { DocumentStatus } 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 { DocumentsTableToolbar } from '~/components/tables/documents-table-toolbar';
|
||||
import { DocumentsTableSenderFilter } from '~/components/tables/documents-table-sender-filter';
|
||||
import { EnvelopesTableBulkActionBar } from '~/components/tables/envelopes-table-bulk-action-bar';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
@@ -47,8 +49,6 @@ const ZSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({
|
||||
});
|
||||
|
||||
export default function DocumentsPage() {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
const team = useCurrentTeam();
|
||||
|
||||
@@ -95,37 +95,35 @@ export default function DocumentsPage() {
|
||||
},
|
||||
);
|
||||
|
||||
const statusOptions = useMemo<DataTableFacetedFilterOption[]>(() => {
|
||||
return [
|
||||
ExtendedDocumentStatus.INBOX,
|
||||
ExtendedDocumentStatus.PENDING,
|
||||
ExtendedDocumentStatus.COMPLETED,
|
||||
ExtendedDocumentStatus.CANCELLED,
|
||||
ExtendedDocumentStatus.DRAFT,
|
||||
ExtendedDocumentStatus.REJECTED,
|
||||
]
|
||||
.filter((status) => {
|
||||
if (organisation.type === OrganisationType.PERSONAL) {
|
||||
return status !== ExtendedDocumentStatus.INBOX;
|
||||
}
|
||||
const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
|
||||
return true;
|
||||
})
|
||||
.map((status) => {
|
||||
const { label, icon, color } = FRIENDLY_STATUS_MAP[status];
|
||||
params.set('status', value);
|
||||
|
||||
return {
|
||||
label: _(label),
|
||||
value: status,
|
||||
icon,
|
||||
iconClassName: color,
|
||||
};
|
||||
});
|
||||
}, [organisation.type, _]);
|
||||
if (value === ExtendedDocumentStatus.ALL) {
|
||||
params.delete('status');
|
||||
}
|
||||
|
||||
const selectedStatuses = findDocumentSearchParams.status ?? [];
|
||||
if (value === ExtendedDocumentStatus.INBOX && organisation.type === OrganisationType.PERSONAL) {
|
||||
params.delete('status');
|
||||
}
|
||||
|
||||
const selectedStatus = selectedStatuses.length === 1 ? selectedStatuses[0] : ExtendedDocumentStatus.ALL;
|
||||
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;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.stats) {
|
||||
@@ -149,16 +147,56 @@ export default function DocumentsPage() {
|
||||
<Trans>Documents</Trans>
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<DocumentsTableToolbar teamId={team?.id} statusOptions={statusOptions} statusCounts={stats} />
|
||||
<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.CANCELLED,
|
||||
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] >= STATS_COUNT_CAP ? `${STATS_COUNT_CAP.toLocaleString()}+` : 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 className="mt-8">
|
||||
<div>
|
||||
{data && data.count === 0 ? (
|
||||
<DocumentsTableEmptyState status={selectedStatus} />
|
||||
<DocumentsTableEmptyState status={findDocumentSearchParams.status || ExtendedDocumentStatus.ALL} />
|
||||
) : (
|
||||
<DocumentsTable
|
||||
data={data}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useSessionStorage } from '@documenso/lib/client-only/hooks/use-session-storage';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { FolderType } from '@documenso/lib/types/folder-type';
|
||||
import { ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { parseToStringArray } from '@documenso/lib/utils/params';
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
@@ -11,7 +9,6 @@ import type { RowSelectionState } from '@documenso/ui/primitives/data-table';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { TemplateType } from '@prisma/client';
|
||||
import { EnvelopeType, OrganisationType } from '@prisma/client';
|
||||
import { Bird } from 'lucide-react';
|
||||
import { parseAsStringLiteral, useQueryState } from 'nuqs';
|
||||
@@ -24,7 +21,6 @@ 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';
|
||||
|
||||
@@ -36,12 +32,6 @@ export function meta() {
|
||||
return appMetaTags(msg`Templates`);
|
||||
}
|
||||
|
||||
const ZTemplatesSearchParamsSchema = ZFindSearchParamsSchema.pick({
|
||||
query: true,
|
||||
page: true,
|
||||
perPage: true,
|
||||
});
|
||||
|
||||
export default function TemplatesPage() {
|
||||
const team = useCurrentTeam();
|
||||
const organisation = useCurrentOrganisation();
|
||||
@@ -49,15 +39,8 @@ export default function TemplatesPage() {
|
||||
const { folderId } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const findTemplatesSearchParams = useMemo(
|
||||
() => ZTemplatesSearchParamsSchema.safeParse(Object.fromEntries(searchParams.entries())).data || {},
|
||||
[searchParams],
|
||||
);
|
||||
|
||||
const typeFilter = useMemo(() => {
|
||||
const selected = parseToStringArray(searchParams.get('type'));
|
||||
return selected.length === 1 ? (selected[0] as TemplateType) : undefined;
|
||||
}, [searchParams]);
|
||||
const page = Number(searchParams.get('page')) || 1;
|
||||
const perPage = Number(searchParams.get('perPage')) || 10;
|
||||
|
||||
const [view, setView] = useQueryState('view', parseAsStringLiteral(TEMPLATE_VIEWS).withDefault('team'));
|
||||
|
||||
@@ -77,8 +60,8 @@ export default function TemplatesPage() {
|
||||
|
||||
const teamTemplatesQuery = trpc.template.findTemplates.useQuery(
|
||||
{
|
||||
...findTemplatesSearchParams,
|
||||
type: typeFilter,
|
||||
page,
|
||||
perPage,
|
||||
folderId,
|
||||
},
|
||||
{
|
||||
@@ -88,8 +71,8 @@ export default function TemplatesPage() {
|
||||
|
||||
const orgTemplatesQuery = trpc.template.findOrganisationTemplates.useQuery(
|
||||
{
|
||||
page: findTemplatesSearchParams.page,
|
||||
perPage: findTemplatesSearchParams.perPage,
|
||||
page,
|
||||
perPage,
|
||||
},
|
||||
{
|
||||
enabled: isOrgView,
|
||||
@@ -146,12 +129,6 @@ export default function TemplatesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isOrgView && (
|
||||
<div className="mt-8">
|
||||
<TemplatesTableToolbar />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8">
|
||||
{activeQuery.data && activeQuery.data.count === 0 ? (
|
||||
<div className="flex h-96 flex-col items-center justify-center gap-y-4 text-muted-foreground/60">
|
||||
|
||||
@@ -67,9 +67,7 @@ test('[DOCUMENTS]: cancelling a pending document keeps it in the owner dashboard
|
||||
await checkDocumentTabCount(page, 'All', 1);
|
||||
|
||||
// The cancelled document is still listed.
|
||||
await page.getByRole('button', { name: /Status/ }).click();
|
||||
await page.getByRole('option', { name: 'Cancelled' }).click();
|
||||
await page.keyboard.press('Escape');
|
||||
await page.getByRole('tab', { name: 'Cancelled' }).click();
|
||||
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
|
||||
|
||||
// The envelope status is persisted as CANCELLED.
|
||||
@@ -133,9 +131,7 @@ test('[DOCUMENTS]: a cancelled document can be deleted, hiding it from the owner
|
||||
await expectToastTextToBeVisible(page, 'Document cancelled');
|
||||
|
||||
// Delete the now-cancelled document. Being terminal, it should soft delete (hide).
|
||||
await page.getByRole('button', { name: /Status/ }).click();
|
||||
await page.getByRole('option', { name: 'Cancelled' }).click();
|
||||
await page.keyboard.press('Escape');
|
||||
await page.getByRole('tab', { name: 'Cancelled' }).click();
|
||||
|
||||
const documentActionBtn = page
|
||||
.locator('tr', { hasText: 'Document 1 - Pending' })
|
||||
|
||||
@@ -807,8 +807,8 @@ test.describe('Find Documents UI - Data Isolation & No Leaking', () => {
|
||||
await checkDocumentTabCount(page, 'Draft', 1);
|
||||
await checkDocumentTabCount(page, 'Completed', 1);
|
||||
|
||||
// Verify no B docs leaked (default view shows all statuses)
|
||||
await page.goto(`/t/${teamA.url}/documents`);
|
||||
// Verify no B docs leaked
|
||||
await page.getByRole('tab', { name: 'All' }).click();
|
||||
await expect(page.getByRole('link', { name: 'A Own Draft' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'B Draft Private', exact: true })).not.toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'B Pending Private', exact: true })).not.toBeVisible();
|
||||
@@ -1156,7 +1156,7 @@ test.describe('Find Documents UI - Sender Filter', () => {
|
||||
await checkDocumentTabCount(page, 'All', 3);
|
||||
|
||||
// Filter by member1
|
||||
await page.getByRole('button', { name: /Sender/ }).click();
|
||||
await page.locator('button').filter({ hasText: 'Sender: All' }).click();
|
||||
await page.getByRole('option', { name: member1.name ?? '' }).click();
|
||||
await page.waitForURL(/senderIds/);
|
||||
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { getDocumentByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { PDF } from '@libpdf/core';
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
import { DocumentStatus, type Field } from '@prisma/client';
|
||||
import { apiCreateTestContext, apiSeedDraftDocument, apiSeedPendingDocument } from '../fixtures/api-seeds';
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { signSignaturePad } from '../fixtures/signature';
|
||||
|
||||
type TeamDocumentSettings = {
|
||||
includeAuditLog?: boolean;
|
||||
includeSigningCertificate?: boolean;
|
||||
};
|
||||
|
||||
const updateTeamDocumentSettings = async (teamId: number, data: TeamDocumentSettings) => {
|
||||
const teamSettings = await prisma.teamGlobalSettings.findFirstOrThrow({
|
||||
where: {
|
||||
team: {
|
||||
id: teamId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.teamGlobalSettings.update({
|
||||
where: {
|
||||
id: teamSettings.id,
|
||||
},
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
const getFirstEnvelopeItemSignedPageCount = async (envelopeId: string, token: string) => {
|
||||
const envelopeItem = await prisma.envelopeItem.findFirstOrThrow({
|
||||
where: {
|
||||
envelopeId,
|
||||
},
|
||||
});
|
||||
|
||||
const documentUrl = getEnvelopeItemPdfUrl({
|
||||
type: 'download',
|
||||
envelopeItem,
|
||||
token,
|
||||
version: 'signed',
|
||||
});
|
||||
|
||||
const response = await fetch(documentUrl);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
|
||||
const pdfData = await response.arrayBuffer();
|
||||
const pdfDoc = await PDF.load(new Uint8Array(pdfData));
|
||||
|
||||
return pdfDoc.getPageCount();
|
||||
};
|
||||
|
||||
const completeSigning = async ({
|
||||
page,
|
||||
signingUrl,
|
||||
recipientToken,
|
||||
field,
|
||||
}: {
|
||||
page: Page;
|
||||
signingUrl: string;
|
||||
recipientToken: string;
|
||||
field: Pick<Field, 'positionX' | 'positionY' | 'width' | 'height'>;
|
||||
}) => {
|
||||
await page.goto(signingUrl);
|
||||
|
||||
// V2 envelopes render fields on a Konva canvas — wait for the PDF and canvas.
|
||||
await expect(page.locator('img[data-page-number]').first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const canvas = page.locator('.konva-container canvas').first();
|
||||
await expect(canvas).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await signSignaturePad(page);
|
||||
|
||||
const canvasBox = await canvas.boundingBox();
|
||||
|
||||
if (!canvasBox) {
|
||||
throw new Error('Canvas bounding box not found');
|
||||
}
|
||||
|
||||
const x = (Number(field.positionX) / 100) * canvasBox.width + ((Number(field.width) / 100) * canvasBox.width) / 2;
|
||||
const y =
|
||||
(Number(field.positionY) / 100) * canvasBox.height + ((Number(field.height) / 100) * canvasBox.height) / 2;
|
||||
|
||||
await canvas.click({ position: { x, y } });
|
||||
|
||||
await expect(page.getByText('0 Fields Remaining').first()).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Are you sure?' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
await page.waitForURL(/\/complete$/);
|
||||
|
||||
await expect(async () => {
|
||||
const { status } = await getDocumentByToken({
|
||||
token: recipientToken,
|
||||
});
|
||||
|
||||
expect(status).toBe(DocumentStatus.COMPLETED);
|
||||
}).toPass();
|
||||
};
|
||||
|
||||
test.describe('Document audit log embedding', () => {
|
||||
test('new documents derive audit-log embedding from team settings', async ({ request }) => {
|
||||
const context = await apiCreateTestContext('e2e-audit-log-default');
|
||||
|
||||
await updateTeamDocumentSettings(context.team.id, {
|
||||
includeAuditLog: true,
|
||||
});
|
||||
|
||||
const { envelope } = await apiSeedDraftDocument(request, {
|
||||
context,
|
||||
});
|
||||
|
||||
expect(envelope.documentMeta.includeAuditLog).toBe(true);
|
||||
|
||||
await updateTeamDocumentSettings(context.team.id, {
|
||||
includeAuditLog: false,
|
||||
});
|
||||
|
||||
const documentMeta = await prisma.documentMeta.findFirstOrThrow({
|
||||
where: {
|
||||
id: envelope.documentMeta.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(documentMeta.includeAuditLog).toBe(true);
|
||||
});
|
||||
|
||||
test('meta.includeAuditLog true appends audit-log pages when team default is false', async ({ page, request }) => {
|
||||
const context = await apiCreateTestContext('e2e-audit-log-override-true');
|
||||
|
||||
await updateTeamDocumentSettings(context.team.id, {
|
||||
includeAuditLog: false,
|
||||
includeSigningCertificate: false,
|
||||
});
|
||||
|
||||
const { distributeResult, envelope } = await apiSeedPendingDocument(request, {
|
||||
context,
|
||||
meta: {
|
||||
includeAuditLog: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(envelope.documentMeta.includeAuditLog).toBe(true);
|
||||
|
||||
const signer = distributeResult.recipients[0];
|
||||
const signatureField = envelope.fields.find((field) => field.recipientId === signer.id);
|
||||
|
||||
if (!signatureField) {
|
||||
throw new Error('Signature field not found');
|
||||
}
|
||||
|
||||
const baselinePageCount = await getFirstEnvelopeItemSignedPageCount(envelope.id, signer.token);
|
||||
|
||||
await completeSigning({
|
||||
page,
|
||||
signingUrl: signer.signingUrl,
|
||||
recipientToken: signer.token,
|
||||
field: signatureField,
|
||||
});
|
||||
|
||||
await expect(async () => {
|
||||
const signedPageCount = await getFirstEnvelopeItemSignedPageCount(envelope.id, signer.token);
|
||||
|
||||
// The audit log may span multiple pages depending on the number of
|
||||
// audit events — assert growth rather than an exact page count.
|
||||
expect(signedPageCount).toBeGreaterThan(baselinePageCount);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('meta.includeAuditLog false skips embedded audit logs when team default is true', async ({ page, request }) => {
|
||||
const context = await apiCreateTestContext('e2e-audit-log-override-false');
|
||||
|
||||
await updateTeamDocumentSettings(context.team.id, {
|
||||
includeAuditLog: true,
|
||||
includeSigningCertificate: false,
|
||||
});
|
||||
|
||||
const { distributeResult, envelope, team, user } = await apiSeedPendingDocument(request, {
|
||||
context,
|
||||
meta: {
|
||||
includeAuditLog: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(envelope.documentMeta.includeAuditLog).toBe(false);
|
||||
|
||||
const signer = distributeResult.recipients[0];
|
||||
const signatureField = envelope.fields.find((field) => field.recipientId === signer.id);
|
||||
|
||||
if (!signatureField) {
|
||||
throw new Error('Signature field not found');
|
||||
}
|
||||
|
||||
const baselinePageCount = await getFirstEnvelopeItemSignedPageCount(envelope.id, signer.token);
|
||||
|
||||
await completeSigning({
|
||||
page,
|
||||
signingUrl: signer.signingUrl,
|
||||
recipientToken: signer.token,
|
||||
field: signatureField,
|
||||
});
|
||||
|
||||
await expect(async () => {
|
||||
const signedPageCount = await getFirstEnvelopeItemSignedPageCount(envelope.id, signer.token);
|
||||
|
||||
expect(signedPageCount).toBe(baselinePageCount);
|
||||
}).toPass();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents/${envelope.id}/logs`,
|
||||
});
|
||||
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
|
||||
await page.getByRole('button', { name: 'Download Audit Logs' }).click();
|
||||
|
||||
const download = await downloadPromise;
|
||||
|
||||
expect(download.suggestedFilename()).toContain('Audit Logs.pdf');
|
||||
});
|
||||
});
|
||||
@@ -2,29 +2,12 @@ import type { Page } from '@playwright/test';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
export const checkDocumentTabCount = async (page: Page, tabName: string, count: number) => {
|
||||
const statusMap: Record<string, string | undefined> = {
|
||||
Inbox: 'INBOX',
|
||||
Pending: 'PENDING',
|
||||
Completed: 'COMPLETED',
|
||||
Draft: 'DRAFT',
|
||||
Cancelled: 'CANCELLED',
|
||||
Rejected: 'REJECTED',
|
||||
All: undefined,
|
||||
};
|
||||
await page.getByRole('tab', { name: tabName }).click();
|
||||
|
||||
const currentUrl = new URL(page.url());
|
||||
const status = statusMap[tabName];
|
||||
|
||||
if (status) {
|
||||
currentUrl.searchParams.set('status', status);
|
||||
} else {
|
||||
currentUrl.searchParams.delete('status');
|
||||
if (tabName !== 'All') {
|
||||
await expect(page.getByRole('tab', { name: tabName })).toContainText(count.toString());
|
||||
}
|
||||
|
||||
currentUrl.searchParams.delete('page');
|
||||
|
||||
await page.goto(currentUrl.toString());
|
||||
|
||||
if (count === 0) {
|
||||
await expect(page.getByTestId('empty-document-state')).toBeVisible();
|
||||
return;
|
||||
|
||||
@@ -27,7 +27,7 @@ test('[TEAMS]: check team documents count', async ({ page }) => {
|
||||
await checkDocumentTabCount(page, 'All', 5);
|
||||
|
||||
// Apply filter.
|
||||
await page.getByRole('button', { name: /Sender/ }).click();
|
||||
await page.locator('button').filter({ hasText: 'Sender: All' }).click();
|
||||
await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
|
||||
await page.waitForURL(/senderIds/);
|
||||
|
||||
@@ -42,21 +42,6 @@ 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 }) => {
|
||||
const { team, teamOwner, teamMember2, teamMember4 } = await seedTeamDocuments();
|
||||
const { team: team2, teamOwner: team2Owner, teamMember2: team2Member2 } = await seedTeamDocuments();
|
||||
@@ -137,7 +122,7 @@ test('[TEAMS]: check team documents count with internal team email', async ({ pa
|
||||
await checkDocumentTabCount(page, 'All', 11);
|
||||
|
||||
// Apply filter.
|
||||
await page.getByRole('button', { name: /Sender/ }).click();
|
||||
await page.locator('button').filter({ hasText: 'Sender: All' }).click();
|
||||
await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
|
||||
await page.waitForURL(/senderIds/);
|
||||
|
||||
@@ -224,7 +209,7 @@ test('[TEAMS]: check team documents count with external team email', async ({ pa
|
||||
await checkDocumentTabCount(page, 'All', 9);
|
||||
|
||||
// Apply filter.
|
||||
await page.getByRole('button', { name: /Sender/ }).click();
|
||||
await page.locator('button').filter({ hasText: 'Sender: All' }).click();
|
||||
await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
|
||||
await page.waitForURL(/senderIds/);
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { TeamMemberRole, TemplateType } from '@prisma/client';
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { openDropdownMenu } from '../fixtures/generic';
|
||||
@@ -41,56 +40,6 @@ test('[TEMPLATES]: view templates', async ({ page }) => {
|
||||
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 }) => {
|
||||
const { team, owner, organisation } = await seedTeam({
|
||||
createTeamMembers: 1,
|
||||
|
||||
@@ -1,37 +1,19 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
|
||||
import type { NavigateOptions } from 'react-router';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
type SearchParamValues = Record<string, string | number | boolean | null | undefined>;
|
||||
type UpdateSearchParamsOptions = Pick<NavigateOptions, 'preventScrollReset' | 'replace' | 'state'>;
|
||||
|
||||
export const useUpdateSearchParams = (defaultOptions: UpdateSearchParamsOptions = {}) => {
|
||||
export const useUpdateSearchParams = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const searchParamsRef = useRef(searchParams);
|
||||
searchParamsRef.current = searchParams;
|
||||
return (params: Record<string, string | number | boolean | null | undefined>) => {
|
||||
const nextSearchParams = new URLSearchParams(searchParams?.toString() ?? '');
|
||||
|
||||
const defaultOptionsRef = useRef(defaultOptions);
|
||||
defaultOptionsRef.current = defaultOptions;
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null) {
|
||||
nextSearchParams.delete(key);
|
||||
} else {
|
||||
nextSearchParams.set(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
return useCallback(
|
||||
(params: SearchParamValues, options?: UpdateSearchParamsOptions) => {
|
||||
const nextSearchParams = new URLSearchParams(searchParamsRef.current?.toString() ?? '');
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null) {
|
||||
nextSearchParams.delete(key);
|
||||
} else {
|
||||
nextSearchParams.set(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
setSearchParams(nextSearchParams, {
|
||||
...defaultOptionsRef.current,
|
||||
...options,
|
||||
});
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
setSearchParams(nextSearchParams);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -206,7 +206,7 @@ export const run = async ({ payload, io }: { payload: TSealDocumentJobDefinition
|
||||
const usePlaywrightPdf = NEXT_PRIVATE_USE_PLAYWRIGHT_PDF();
|
||||
|
||||
const needsCertificate = settings.includeSigningCertificate;
|
||||
const needsAuditLog = settings.includeAuditLog;
|
||||
const needsAuditLog = envelope.documentMeta.includeAuditLog;
|
||||
|
||||
const newDocumentData: Array<{ oldDocumentDataId: string; newDocumentDataId: string }> = [];
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ export type CreateDocumentMetaOptions = {
|
||||
signingOrder?: DocumentSigningOrder;
|
||||
allowDictateNextSigner?: boolean;
|
||||
distributionMethod?: DocumentDistributionMethod;
|
||||
includeAuditLog?: boolean;
|
||||
typedSignatureEnabled?: boolean;
|
||||
uploadSignatureEnabled?: boolean;
|
||||
drawSignatureEnabled?: boolean;
|
||||
@@ -50,6 +51,7 @@ export const updateDocumentMeta = async ({
|
||||
emailReplyTo,
|
||||
emailSettings,
|
||||
distributionMethod,
|
||||
includeAuditLog,
|
||||
typedSignatureEnabled,
|
||||
uploadSignatureEnabled,
|
||||
drawSignatureEnabled,
|
||||
@@ -129,6 +131,7 @@ export const updateDocumentMeta = async ({
|
||||
emailReplyTo,
|
||||
emailSettings,
|
||||
distributionMethod,
|
||||
includeAuditLog,
|
||||
typedSignatureEnabled,
|
||||
uploadSignatureEnabled,
|
||||
drawSignatureEnabled,
|
||||
|
||||
@@ -18,31 +18,14 @@ import type { FindResultResponse } from '../../types/search-params';
|
||||
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
|
||||
export type PeriodSelectorValue = '' | 'all' | '7d' | '14d' | '30d';
|
||||
|
||||
const normalizeStatuses = (
|
||||
status: ExtendedDocumentStatus | ExtendedDocumentStatus[] | undefined,
|
||||
): ExtendedDocumentStatus[] => {
|
||||
if (!status) {
|
||||
return [ExtendedDocumentStatus.ALL];
|
||||
}
|
||||
|
||||
const arr = Array.isArray(status) ? status : [status];
|
||||
const deduped = Array.from(new Set(arr));
|
||||
|
||||
if (deduped.length === 0 || deduped.includes(ExtendedDocumentStatus.ALL)) {
|
||||
return [ExtendedDocumentStatus.ALL];
|
||||
}
|
||||
|
||||
return deduped;
|
||||
};
|
||||
export type PeriodSelectorValue = '' | '7d' | '14d' | '30d';
|
||||
|
||||
export type FindDocumentsOptions = {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
templateId?: number;
|
||||
source?: DocumentSource | DocumentSource[];
|
||||
status?: ExtendedDocumentStatus | ExtendedDocumentStatus[];
|
||||
source?: DocumentSource;
|
||||
status?: ExtendedDocumentStatus;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
orderBy?: {
|
||||
@@ -124,7 +107,7 @@ export const findDocuments = async ({
|
||||
teamId,
|
||||
templateId,
|
||||
source,
|
||||
status,
|
||||
status = ExtendedDocumentStatus.ALL,
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
orderBy,
|
||||
@@ -152,8 +135,6 @@ export const findDocuments = async ({
|
||||
const hasSearch = searchQuery.length > 0;
|
||||
const searchPattern = `%${searchQuery}%`;
|
||||
|
||||
const normalizedStatuses = normalizeStatuses(status);
|
||||
|
||||
// ─── Base query with common filters ──────────────────────────────────
|
||||
//
|
||||
// Every code path starts from this base: Envelope rows filtered by type,
|
||||
@@ -170,7 +151,7 @@ export const findDocuments = async ({
|
||||
folderId !== undefined ? qb.where('Envelope.folderId', '=', folderId) : qb.where('Envelope.folderId', 'is', null);
|
||||
|
||||
// Period filter
|
||||
if (period && period !== 'all') {
|
||||
if (period) {
|
||||
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
|
||||
const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day');
|
||||
|
||||
@@ -184,15 +165,7 @@ export const findDocuments = async ({
|
||||
|
||||
// Source filter (enum cast)
|
||||
if (source) {
|
||||
const sources = Array.isArray(source) ? source : [source];
|
||||
|
||||
if (sources.length > 0) {
|
||||
qb = qb.where(
|
||||
'Envelope.source',
|
||||
'in',
|
||||
sources.map((s) => sql.lit(s)),
|
||||
);
|
||||
}
|
||||
qb = qb.where('Envelope.source', '=', sql.lit(source));
|
||||
}
|
||||
|
||||
// Template filter
|
||||
@@ -231,36 +204,39 @@ export const findDocuments = async ({
|
||||
|
||||
// ─── Personal path filters ───────────────────────────────────────────
|
||||
|
||||
const buildPersonalStatusPredicate = (
|
||||
eb: EnvelopeExpressionBuilder,
|
||||
s: ExtendedDocumentStatus,
|
||||
): Expression<SqlBool> => {
|
||||
const applyPersonalFilters = (qb: EnvelopeQueryBuilder): EnvelopeQueryBuilder | null => {
|
||||
// Deleted filter: owned → deletedAt IS NULL, received → documentDeletedAt IS NULL
|
||||
const personalDeletedFilter = eb.or([
|
||||
eb.and([eb('Envelope.userId', '=', user.id), eb('Envelope.deletedAt', 'is', null)]),
|
||||
recipientExists(eb, user.email, (reb) => reb('Recipient.documentDeletedAt', 'is', null)),
|
||||
]);
|
||||
const personalDeletedFilter = (eb: EnvelopeExpressionBuilder) =>
|
||||
eb.or([
|
||||
eb.and([eb('Envelope.userId', '=', user.id), eb('Envelope.deletedAt', 'is', null)]),
|
||||
recipientExists(eb, user.email, (reb) => reb('Recipient.documentDeletedAt', 'is', null)),
|
||||
]);
|
||||
|
||||
return match<ExtendedDocumentStatus, Expression<SqlBool>>(s)
|
||||
return match<ExtendedDocumentStatus, EnvelopeQueryBuilder | null>(status)
|
||||
.with(ExtendedDocumentStatus.ALL, () =>
|
||||
eb.and([
|
||||
personalDeletedFilter,
|
||||
eb.or([
|
||||
eb('Envelope.userId', '=', user.id),
|
||||
eb.and([
|
||||
eb('Envelope.status', 'in', [
|
||||
sql.lit(DocumentStatus.COMPLETED),
|
||||
sql.lit(DocumentStatus.PENDING),
|
||||
sql.lit(DocumentStatus.CANCELLED),
|
||||
qb.where((eb) =>
|
||||
eb.and([
|
||||
personalDeletedFilter(eb),
|
||||
eb.or([
|
||||
eb('Envelope.userId', '=', user.id),
|
||||
eb.and([
|
||||
eb('Envelope.status', 'in', [
|
||||
sql.lit(DocumentStatus.COMPLETED),
|
||||
sql.lit(DocumentStatus.PENDING),
|
||||
sql.lit(DocumentStatus.CANCELLED),
|
||||
]),
|
||||
recipientExists(eb, user.email),
|
||||
]),
|
||||
recipientExists(eb, user.email),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.with(ExtendedDocumentStatus.INBOX, () =>
|
||||
eb.and([
|
||||
eb('Envelope.status', '!=', sql.lit(DocumentStatus.DRAFT)),
|
||||
qb.where('Envelope.status', '!=', sql.lit(ExtendedDocumentStatus.DRAFT)).where((eb) =>
|
||||
// Single EXISTS check: the recipient must be NOT_SIGNED, non-CC, and
|
||||
// not soft-deleted. This replaces the previous personalDeletedFilter +
|
||||
// separate recipientExists pair, eliminating a hashed SubPlan that
|
||||
// materialised all recipient rows for this email (~125k for heavy users).
|
||||
recipientExists(eb, user.email, (reb) =>
|
||||
reb.and([
|
||||
reb('Recipient.documentDeletedAt', 'is', null),
|
||||
@@ -268,69 +244,76 @@ export const findDocuments = async ({
|
||||
reb('role', '!=', sql.lit(RecipientRole.CC)),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.with(ExtendedDocumentStatus.DRAFT, () =>
|
||||
eb.and([
|
||||
eb('Envelope.userId', '=', user.id),
|
||||
eb('Envelope.deletedAt', 'is', null),
|
||||
eb('Envelope.status', '=', sql.lit(DocumentStatus.DRAFT)),
|
||||
]),
|
||||
qb
|
||||
.where('Envelope.userId', '=', user.id)
|
||||
.where('Envelope.deletedAt', 'is', null)
|
||||
.where('Envelope.status', '=', sql.lit(DocumentStatus.DRAFT)),
|
||||
)
|
||||
.with(ExtendedDocumentStatus.PENDING, () =>
|
||||
eb.and([
|
||||
eb('Envelope.status', '=', sql.lit(DocumentStatus.PENDING)),
|
||||
personalDeletedFilter,
|
||||
eb.or([
|
||||
eb('Envelope.userId', '=', user.id),
|
||||
recipientExists(eb, user.email, (reb) =>
|
||||
reb.and([
|
||||
reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.SIGNED)),
|
||||
reb('Recipient.role', '!=', sql.lit(RecipientRole.CC)),
|
||||
qb
|
||||
.where('Envelope.status', '=', sql.lit(DocumentStatus.PENDING))
|
||||
.where((eb) =>
|
||||
eb.and([
|
||||
personalDeletedFilter(eb),
|
||||
eb.or([
|
||||
eb('Envelope.userId', '=', user.id),
|
||||
recipientExists(eb, user.email, (reb) =>
|
||||
reb.and([
|
||||
reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.SIGNED)),
|
||||
reb('Recipient.role', '!=', sql.lit(RecipientRole.CC)),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.with(ExtendedDocumentStatus.COMPLETED, () =>
|
||||
eb.and([
|
||||
eb('Envelope.status', '=', sql.lit(DocumentStatus.COMPLETED)),
|
||||
personalDeletedFilter,
|
||||
eb.or([eb('Envelope.userId', '=', user.id), recipientExists(eb, user.email)]),
|
||||
]),
|
||||
qb
|
||||
.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.COMPLETED))
|
||||
.where((eb) =>
|
||||
eb.and([
|
||||
personalDeletedFilter(eb),
|
||||
eb.or([eb('Envelope.userId', '=', user.id), recipientExists(eb, user.email)]),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.with(ExtendedDocumentStatus.REJECTED, () =>
|
||||
eb.and([
|
||||
eb('Envelope.status', '=', sql.lit(DocumentStatus.REJECTED)),
|
||||
personalDeletedFilter,
|
||||
eb.or([
|
||||
eb('Envelope.userId', '=', user.id),
|
||||
recipientExists(eb, user.email, (reb) =>
|
||||
reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.REJECTED)),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
qb
|
||||
.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.REJECTED))
|
||||
.where((eb) =>
|
||||
eb.and([
|
||||
personalDeletedFilter(eb),
|
||||
eb.or([
|
||||
eb('Envelope.userId', '=', user.id),
|
||||
recipientExists(eb, user.email, (reb) =>
|
||||
reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.REJECTED)),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.with(ExtendedDocumentStatus.CANCELLED, () =>
|
||||
eb.and([
|
||||
eb('Envelope.status', '=', sql.lit(DocumentStatus.CANCELLED)),
|
||||
personalDeletedFilter,
|
||||
eb.or([eb('Envelope.userId', '=', user.id), recipientExists(eb, user.email)]),
|
||||
]),
|
||||
qb
|
||||
.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.CANCELLED))
|
||||
.where((eb) =>
|
||||
eb.and([
|
||||
personalDeletedFilter(eb),
|
||||
eb.or([eb('Envelope.userId', '=', user.id), recipientExists(eb, user.email)]),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.exhaustive();
|
||||
};
|
||||
|
||||
const applyPersonalFilters = (qb: EnvelopeQueryBuilder): EnvelopeQueryBuilder =>
|
||||
qb.where((eb) => eb.or(normalizedStatuses.map((s) => buildPersonalStatusPredicate(eb, s))));
|
||||
|
||||
// ─── Team path filters ───────────────────────────────────────────────
|
||||
|
||||
const buildTeamStatusPredicate = (
|
||||
eb: EnvelopeExpressionBuilder,
|
||||
const applyTeamFilters = (
|
||||
qb: EnvelopeQueryBuilder,
|
||||
teamData: Team & { teamEmail: TeamEmail | null; currentTeamRole: TeamMemberRole },
|
||||
s: ExtendedDocumentStatus,
|
||||
): Expression<SqlBool> | null => {
|
||||
): EnvelopeQueryBuilder | null => {
|
||||
const teamEmail = teamData.teamEmail?.email ?? null;
|
||||
|
||||
const allowedVisibilities = match(teamData.currentTeamRole)
|
||||
@@ -342,167 +325,139 @@ export const findDocuments = async ({
|
||||
.with(TeamMemberRole.MANAGER, () => [DocumentVisibility.EVERYONE, DocumentVisibility.MANAGER_AND_ABOVE])
|
||||
.otherwise(() => [DocumentVisibility.EVERYONE]);
|
||||
|
||||
const visibilityFilter = eb.or([
|
||||
eb(
|
||||
'Envelope.visibility',
|
||||
'in',
|
||||
allowedVisibilities.map((v) => sql.lit(v)),
|
||||
),
|
||||
eb('Envelope.userId', '=', user.id),
|
||||
recipientExists(eb, user.email),
|
||||
]);
|
||||
// Visibility: meets role threshold OR directly involved
|
||||
const visibilityFilter = (eb: EnvelopeExpressionBuilder) =>
|
||||
eb.or([
|
||||
eb(
|
||||
'Envelope.visibility',
|
||||
'in',
|
||||
allowedVisibilities.map((v) => sql.lit(v)),
|
||||
),
|
||||
eb('Envelope.userId', '=', user.id),
|
||||
recipientExists(eb, user.email),
|
||||
]);
|
||||
|
||||
const teamDeletedBranches = [
|
||||
eb.and([eb('Envelope.teamId', '=', teamData.id), eb('Envelope.deletedAt', 'is', null)]),
|
||||
];
|
||||
// Deleted filter for team path
|
||||
const teamDeletedFilter = (eb: EnvelopeExpressionBuilder) => {
|
||||
const branches = [eb.and([eb('Envelope.teamId', '=', teamData.id), eb('Envelope.deletedAt', 'is', null)])];
|
||||
|
||||
if (teamEmail) {
|
||||
teamDeletedBranches.push(eb.and([senderEmailIs(eb, teamEmail), eb('Envelope.deletedAt', 'is', null)]));
|
||||
teamDeletedBranches.push(recipientExists(eb, teamEmail, (reb) => reb('Recipient.documentDeletedAt', 'is', null)));
|
||||
}
|
||||
if (teamEmail) {
|
||||
branches.push(eb.and([senderEmailIs(eb, teamEmail), eb('Envelope.deletedAt', 'is', null)]));
|
||||
branches.push(recipientExists(eb, teamEmail, (reb) => reb('Recipient.documentDeletedAt', 'is', null)));
|
||||
}
|
||||
|
||||
const teamDeletedFilter = eb.or(teamDeletedBranches);
|
||||
return eb.or(branches);
|
||||
};
|
||||
|
||||
return match<ExtendedDocumentStatus, Expression<SqlBool> | null>(s)
|
||||
.with(ExtendedDocumentStatus.ALL, () => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
|
||||
return match<ExtendedDocumentStatus, EnvelopeQueryBuilder | null>(status)
|
||||
.with(ExtendedDocumentStatus.ALL, () =>
|
||||
qb.where((eb) => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
|
||||
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
accessBranches.push(
|
||||
eb.and([eb('Envelope.status', '!=', sql.lit(DocumentStatus.DRAFT)), recipientExists(eb, teamEmail)]),
|
||||
);
|
||||
}
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
accessBranches.push(
|
||||
eb.and([eb('status', '!=', sql.lit(ExtendedDocumentStatus.DRAFT)), recipientExists(eb, teamEmail)]),
|
||||
);
|
||||
}
|
||||
|
||||
return eb.and([teamDeletedFilter, visibilityFilter, eb.or(accessBranches)]);
|
||||
})
|
||||
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
|
||||
}),
|
||||
)
|
||||
.with(ExtendedDocumentStatus.INBOX, () => {
|
||||
if (!teamEmail) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return eb.and([
|
||||
eb('Envelope.status', '!=', sql.lit(DocumentStatus.DRAFT)),
|
||||
visibilityFilter,
|
||||
recipientExists(eb, teamEmail, (reb) =>
|
||||
reb.and([
|
||||
reb('Recipient.documentDeletedAt', 'is', null),
|
||||
reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.NOT_SIGNED)),
|
||||
reb('Recipient.role', '!=', sql.lit(RecipientRole.CC)),
|
||||
]),
|
||||
),
|
||||
]);
|
||||
})
|
||||
.with(ExtendedDocumentStatus.DRAFT, () => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
|
||||
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
}
|
||||
|
||||
return eb.and([
|
||||
eb('Envelope.status', '=', sql.lit(DocumentStatus.DRAFT)),
|
||||
teamDeletedFilter,
|
||||
visibilityFilter,
|
||||
eb.or(accessBranches),
|
||||
]);
|
||||
})
|
||||
.with(ExtendedDocumentStatus.PENDING, () => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
|
||||
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
accessBranches.push(
|
||||
return qb.where('Envelope.status', '!=', sql.lit(ExtendedDocumentStatus.DRAFT)).where((eb) =>
|
||||
eb.and([
|
||||
visibilityFilter(eb),
|
||||
// Single EXISTS check: the team-email recipient must be NOT_SIGNED,
|
||||
// non-CC, and not soft-deleted. Replaces teamDeletedFilter + separate
|
||||
// recipientExists, eliminating a hashed SubPlan (~79k rows).
|
||||
recipientExists(eb, teamEmail, (reb) =>
|
||||
reb.and([
|
||||
reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.SIGNED)),
|
||||
reb('Recipient.documentDeletedAt', 'is', null),
|
||||
reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.NOT_SIGNED)),
|
||||
reb('Recipient.role', '!=', sql.lit(RecipientRole.CC)),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return eb.and([
|
||||
eb('Envelope.status', '=', sql.lit(DocumentStatus.PENDING)),
|
||||
teamDeletedFilter,
|
||||
visibilityFilter,
|
||||
eb.or(accessBranches),
|
||||
]);
|
||||
]),
|
||||
);
|
||||
})
|
||||
.with(ExtendedDocumentStatus.COMPLETED, () => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
|
||||
.with(ExtendedDocumentStatus.DRAFT, () =>
|
||||
qb.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.DRAFT)).where((eb) => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
|
||||
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
accessBranches.push(recipientExists(eb, teamEmail));
|
||||
}
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
}
|
||||
|
||||
return eb.and([
|
||||
eb('Envelope.status', '=', sql.lit(DocumentStatus.COMPLETED)),
|
||||
teamDeletedFilter,
|
||||
visibilityFilter,
|
||||
eb.or(accessBranches),
|
||||
]);
|
||||
})
|
||||
.with(ExtendedDocumentStatus.REJECTED, () => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
|
||||
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
|
||||
}),
|
||||
)
|
||||
.with(ExtendedDocumentStatus.PENDING, () =>
|
||||
qb.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.PENDING)).where((eb) => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
|
||||
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
accessBranches.push(
|
||||
recipientExists(eb, teamEmail, (reb) =>
|
||||
reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.REJECTED)),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
accessBranches.push(
|
||||
recipientExists(eb, teamEmail, (reb) =>
|
||||
reb.and([
|
||||
reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.SIGNED)),
|
||||
reb('Recipient.role', '!=', sql.lit(RecipientRole.CC)),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return eb.and([
|
||||
eb('Envelope.status', '=', sql.lit(DocumentStatus.REJECTED)),
|
||||
teamDeletedFilter,
|
||||
visibilityFilter,
|
||||
eb.or(accessBranches),
|
||||
]);
|
||||
})
|
||||
.with(ExtendedDocumentStatus.CANCELLED, () => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
|
||||
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
|
||||
}),
|
||||
)
|
||||
.with(ExtendedDocumentStatus.COMPLETED, () =>
|
||||
qb.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.COMPLETED)).where((eb) => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
|
||||
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
accessBranches.push(recipientExists(eb, teamEmail));
|
||||
}
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
accessBranches.push(recipientExists(eb, teamEmail));
|
||||
}
|
||||
|
||||
return eb.and([
|
||||
eb('Envelope.status', '=', sql.lit(DocumentStatus.CANCELLED)),
|
||||
teamDeletedFilter,
|
||||
visibilityFilter,
|
||||
eb.or(accessBranches),
|
||||
]);
|
||||
})
|
||||
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
|
||||
}),
|
||||
)
|
||||
.with(ExtendedDocumentStatus.REJECTED, () =>
|
||||
qb.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.REJECTED)).where((eb) => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
|
||||
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
accessBranches.push(
|
||||
recipientExists(eb, teamEmail, (reb) =>
|
||||
reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.REJECTED)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
|
||||
}),
|
||||
)
|
||||
.with(ExtendedDocumentStatus.CANCELLED, () =>
|
||||
qb.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.CANCELLED)).where((eb) => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
|
||||
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
accessBranches.push(recipientExists(eb, teamEmail));
|
||||
}
|
||||
|
||||
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
|
||||
}),
|
||||
)
|
||||
.exhaustive();
|
||||
};
|
||||
|
||||
const applyTeamFilters = (
|
||||
qb: EnvelopeQueryBuilder,
|
||||
teamData: Team & { teamEmail: TeamEmail | null; currentTeamRole: TeamMemberRole },
|
||||
): EnvelopeQueryBuilder | null => {
|
||||
const teamEmail = teamData.teamEmail?.email ?? null;
|
||||
|
||||
// INBOX requires a team email; drop statuses that produce no predicate.
|
||||
const validStatuses = normalizedStatuses.filter((s) => !(s === ExtendedDocumentStatus.INBOX && !teamEmail));
|
||||
|
||||
if (validStatuses.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return qb.where((eb) => {
|
||||
const predicates = validStatuses
|
||||
.map((s) => buildTeamStatusPredicate(eb, teamData, s))
|
||||
.filter((p): p is Expression<SqlBool> => p !== null);
|
||||
|
||||
return eb.or(predicates);
|
||||
});
|
||||
};
|
||||
|
||||
// ─── Assemble and execute ────────────────────────────────────────────
|
||||
|
||||
const baseQuery = buildBaseQuery();
|
||||
|
||||
@@ -109,7 +109,7 @@ export const getStats = async ({ userId, teamId, period, search = '', folderId,
|
||||
folderId !== undefined ? qb.where('Envelope.folderId', '=', folderId) : qb.where('Envelope.folderId', 'is', null);
|
||||
|
||||
// Period filter
|
||||
if (period && period !== 'all') {
|
||||
if (period) {
|
||||
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
|
||||
const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day');
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ export const ZEnvelopeForSigningResponse = z.object({
|
||||
documentMeta: DocumentMetaSchema.pick({
|
||||
signingOrder: true,
|
||||
distributionMethod: true,
|
||||
includeAuditLog: true,
|
||||
timezone: true,
|
||||
dateFormat: true,
|
||||
redirectUrl: true,
|
||||
|
||||
@@ -108,6 +108,7 @@ export type CreateDocumentFromTemplateOptions = {
|
||||
language?: SupportedLanguageCodes;
|
||||
distributionMethod?: DocumentDistributionMethod;
|
||||
allowDictateNextSigner?: boolean;
|
||||
includeAuditLog?: boolean;
|
||||
emailSettings?: TDocumentEmailSettings;
|
||||
typedSignatureEnabled?: boolean;
|
||||
uploadSignatureEnabled?: boolean;
|
||||
@@ -534,6 +535,7 @@ export const createDocumentFromTemplate = async ({
|
||||
dateFormat: override?.dateFormat || template.documentMeta?.dateFormat,
|
||||
redirectUrl: override?.redirectUrl || template.documentMeta?.redirectUrl,
|
||||
distributionMethod: override?.distributionMethod || template.documentMeta?.distributionMethod,
|
||||
includeAuditLog: override?.includeAuditLog ?? template.documentMeta?.includeAuditLog,
|
||||
emailSettings: override?.emailSettings || template.documentMeta?.emailSettings,
|
||||
signingOrder: override?.signingOrder || template.documentMeta?.signingOrder,
|
||||
language: override?.language || template.documentMeta?.language || settings.documentLanguage,
|
||||
@@ -690,7 +692,7 @@ export const createDocumentFromTemplate = async ({
|
||||
|
||||
const date = new Date(selector.value);
|
||||
|
||||
if (isNaN(date.getTime())) {
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: `Invalid date value for field ${field.id}: ${selector.value}`,
|
||||
});
|
||||
|
||||
@@ -9,8 +9,7 @@ import { getMemberRoles } from '../team/get-member-roles';
|
||||
export type FindTemplatesOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
type?: TemplateType | TemplateType[];
|
||||
query?: string;
|
||||
type?: TemplateType;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
folderId?: string;
|
||||
@@ -20,7 +19,6 @@ export const findTemplates = async ({
|
||||
userId,
|
||||
teamId,
|
||||
type,
|
||||
query = '',
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
folderId,
|
||||
@@ -33,11 +31,9 @@ export const findTemplates = async ({
|
||||
},
|
||||
});
|
||||
|
||||
const templateTypeFilter = type ? { in: Array.isArray(type) ? type : [type] } : undefined;
|
||||
|
||||
const where: Prisma.EnvelopeWhereInput = {
|
||||
type: EnvelopeType.TEMPLATE,
|
||||
templateType: templateTypeFilter,
|
||||
templateType: type,
|
||||
AND: [
|
||||
{ teamId },
|
||||
{
|
||||
@@ -51,26 +47,6 @@ export const findTemplates = async ({
|
||||
],
|
||||
},
|
||||
folderId ? { folderId } : { folderId: null },
|
||||
...(query
|
||||
? [
|
||||
{
|
||||
OR: [
|
||||
{
|
||||
title: {
|
||||
contains: query,
|
||||
mode: 'insensitive' as const,
|
||||
},
|
||||
},
|
||||
{
|
||||
externalId: {
|
||||
contains: query,
|
||||
mode: 'insensitive' as const,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ export const generateSampleWebhookPayload = (event: WebhookTriggerEvents, webhoo
|
||||
drawSignatureEnabled: true,
|
||||
language: 'en',
|
||||
distributionMethod: DocumentDistributionMethod.EMAIL,
|
||||
includeAuditLog: false,
|
||||
emailSettings: null,
|
||||
},
|
||||
recipients: [
|
||||
|
||||
@@ -19,6 +19,7 @@ import { ZDocumentEmailSettingsSchema } from './document-email';
|
||||
export const ZDocumentMetaSchema = DocumentMetaSchema.pick({
|
||||
signingOrder: true,
|
||||
distributionMethod: true,
|
||||
includeAuditLog: true,
|
||||
id: true,
|
||||
subject: true,
|
||||
message: true,
|
||||
@@ -93,6 +94,10 @@ export const ZDocumentMetaDistributionMethodSchema = z
|
||||
.nativeEnum(DocumentDistributionMethod)
|
||||
.describe('The distribution method to use when sending the document to the recipients.');
|
||||
|
||||
export const ZDocumentMetaIncludeAuditLogSchema = z
|
||||
.boolean()
|
||||
.describe('Whether to include the audit logs in the sealed document PDF.');
|
||||
|
||||
export const ZDocumentMetaTypedSignatureEnabledSchema = z
|
||||
.boolean()
|
||||
.describe('Whether to allow recipients to sign using a typed signature.');
|
||||
@@ -116,6 +121,7 @@ export const ZDocumentMetaCreateSchema = z.object({
|
||||
timezone: ZDocumentMetaTimezoneSchema.optional(),
|
||||
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
|
||||
distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(),
|
||||
includeAuditLog: ZDocumentMetaIncludeAuditLogSchema.optional(),
|
||||
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
|
||||
allowDictateNextSigner: z.boolean().optional(),
|
||||
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
|
||||
|
||||
@@ -53,6 +53,7 @@ export const ZDocumentSchema = LegacyDocumentSchema.pick({
|
||||
documentMeta: DocumentMetaSchema.pick({
|
||||
signingOrder: true,
|
||||
distributionMethod: true,
|
||||
includeAuditLog: true,
|
||||
id: true,
|
||||
subject: true,
|
||||
message: true,
|
||||
|
||||
@@ -270,6 +270,7 @@ export const ZEditorEnvelopeSchema = EnvelopeSchema.pick({
|
||||
documentMeta: DocumentMetaSchema.pick({
|
||||
signingOrder: true,
|
||||
distributionMethod: true,
|
||||
includeAuditLog: true,
|
||||
id: true,
|
||||
subject: true,
|
||||
message: true,
|
||||
|
||||
@@ -40,6 +40,7 @@ export const ZEnvelopeSchema = EnvelopeSchema.pick({
|
||||
documentMeta: DocumentMetaSchema.pick({
|
||||
signingOrder: true,
|
||||
distributionMethod: true,
|
||||
includeAuditLog: true,
|
||||
id: true,
|
||||
subject: true,
|
||||
message: true,
|
||||
|
||||
@@ -56,6 +56,7 @@ export const ZTemplateSchema = TemplateSchema.pick({
|
||||
drawSignatureEnabled: true,
|
||||
allowDictateNextSigner: true,
|
||||
distributionMethod: true,
|
||||
includeAuditLog: true,
|
||||
redirectUrl: true,
|
||||
language: true,
|
||||
emailSettings: true,
|
||||
@@ -149,6 +150,7 @@ export const ZTemplateManySchema = TemplateSchema.pick({
|
||||
templateMeta: DocumentMetaSchema.pick({
|
||||
signingOrder: true,
|
||||
distributionMethod: true,
|
||||
includeAuditLog: true,
|
||||
}).nullable(),
|
||||
directLink: LegacyTemplateDirectLinkSchema.pick({
|
||||
token: true,
|
||||
|
||||
@@ -57,6 +57,7 @@ export const ZWebhookDocumentMetaSchema = z.object({
|
||||
drawSignatureEnabled: z.boolean(),
|
||||
language: z.string(),
|
||||
distributionMethod: z.nativeEnum(DocumentDistributionMethod),
|
||||
includeAuditLog: z.boolean().default(false),
|
||||
emailSettings: z.any().nullable(),
|
||||
});
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ export const extractDerivedDocumentMeta = (
|
||||
signingOrder: resolveSigningOrder({ signatureLevel, requested: meta.signingOrder }),
|
||||
allowDictateNextSigner: meta.allowDictateNextSigner ?? false,
|
||||
distributionMethod: meta.distributionMethod || DocumentDistributionMethod.EMAIL, // Todo: Make this a setting.
|
||||
includeAuditLog: meta.includeAuditLog ?? settings.includeAuditLog,
|
||||
|
||||
// Signature settings.
|
||||
typedSignatureEnabled: meta.typedSignatureEnabled ?? settings.typedSignatureEnabled,
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* From an unknown string, parse it into an integer array.
|
||||
*
|
||||
* Filter out unknown values.
|
||||
*/
|
||||
export const parseToIntegerArray = (value: unknown): number[] => {
|
||||
if (typeof value !== 'string') {
|
||||
return [];
|
||||
@@ -9,30 +14,6 @@ export const parseToIntegerArray = (value: unknown): number[] => {
|
||||
.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 = {
|
||||
returnEmptyRootString?: boolean;
|
||||
};
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "DocumentMeta" ADD COLUMN "includeAuditLog" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- Backfill includeAuditLog from the effective team/organisation setting
|
||||
UPDATE "DocumentMeta" AS dm
|
||||
SET "includeAuditLog" = COALESCE(tgs."includeAuditLog", ogs."includeAuditLog")
|
||||
FROM "Envelope" e
|
||||
JOIN "Team" t ON t."id" = e."teamId"
|
||||
JOIN "TeamGlobalSettings" tgs ON tgs."id" = t."teamGlobalSettingsId"
|
||||
JOIN "Organisation" o ON o."id" = t."organisationId"
|
||||
JOIN "OrganisationGlobalSettings" ogs ON ogs."id" = o."organisationGlobalSettingsId"
|
||||
WHERE e."documentMetaId" = dm."id";
|
||||
@@ -573,6 +573,7 @@ model DocumentMeta {
|
||||
|
||||
language String @default("en")
|
||||
distributionMethod DocumentDistributionMethod @default(EMAIL)
|
||||
includeAuditLog Boolean @default(false)
|
||||
|
||||
emailSettings Json? /// [DocumentEmailSettings] @zod.custom.use(ZDocumentEmailSettingsSchema)
|
||||
emailReplyTo String?
|
||||
|
||||
@@ -37,6 +37,7 @@ export const distributeDocumentRoute = authenticatedProcedure
|
||||
timezone: meta.timezone,
|
||||
redirectUrl: meta.redirectUrl,
|
||||
distributionMethod: meta.distributionMethod,
|
||||
includeAuditLog: meta.includeAuditLog,
|
||||
emailSettings: meta.emailSettings ?? undefined,
|
||||
language: meta.language,
|
||||
emailId: meta.emailId,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-emai
|
||||
import {
|
||||
ZDocumentMetaDateFormatSchema,
|
||||
ZDocumentMetaDistributionMethodSchema,
|
||||
ZDocumentMetaIncludeAuditLogSchema,
|
||||
ZDocumentMetaLanguageSchema,
|
||||
ZDocumentMetaMessageSchema,
|
||||
ZDocumentMetaRedirectUrlSchema,
|
||||
@@ -33,6 +34,7 @@ export const ZDistributeDocumentRequestSchema = z.object({
|
||||
timezone: ZDocumentMetaTimezoneSchema.optional(),
|
||||
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
|
||||
distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(),
|
||||
includeAuditLog: ZDocumentMetaIncludeAuditLogSchema.optional(),
|
||||
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
|
||||
language: ZDocumentMetaLanguageSchema.optional(),
|
||||
emailId: z.string().nullish(),
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { ZDocumentManySchema } from '@documenso/lib/types/document';
|
||||
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 { DocumentSource } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZFindDocumentsRequestSchema } from './find-documents.types';
|
||||
|
||||
export const ZFindDocumentsInternalRequestSchema = ZFindDocumentsRequestSchema.extend({
|
||||
period: z.enum(['all', '7d', '14d', '30d']).optional(),
|
||||
period: z.enum(['7d', '14d', '30d']).optional(),
|
||||
senderIds: z.array(z.number()).optional(),
|
||||
source: z.preprocess(parseCommaSeparatedValues, z.array(z.nativeEnum(DocumentSource)).optional()),
|
||||
status: z.preprocess(parseCommaSeparatedValues, z.array(z.nativeEnum(ExtendedDocumentStatus)).optional()),
|
||||
status: z.nativeEnum(ExtendedDocumentStatus).optional(),
|
||||
folderId: z.string().optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ZDocumentMetaDateFormatSchema,
|
||||
ZDocumentMetaDistributionMethodSchema,
|
||||
ZDocumentMetaDrawSignatureEnabledSchema,
|
||||
ZDocumentMetaIncludeAuditLogSchema,
|
||||
ZDocumentMetaLanguageSchema,
|
||||
ZDocumentMetaMessageSchema,
|
||||
ZDocumentMetaRedirectUrlSchema,
|
||||
@@ -59,6 +60,7 @@ export const ZCreateEmbeddingDocumentRequestSchema = z.object({
|
||||
timezone: ZDocumentMetaTimezoneSchema.optional(),
|
||||
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
|
||||
distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(),
|
||||
includeAuditLog: ZDocumentMetaIncludeAuditLogSchema.optional(),
|
||||
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
|
||||
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
|
||||
language: ZDocumentMetaLanguageSchema.optional(),
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ZDocumentMetaDateFormatSchema,
|
||||
ZDocumentMetaDistributionMethodSchema,
|
||||
ZDocumentMetaDrawSignatureEnabledSchema,
|
||||
ZDocumentMetaIncludeAuditLogSchema,
|
||||
ZDocumentMetaLanguageSchema,
|
||||
ZDocumentMetaMessageSchema,
|
||||
ZDocumentMetaRedirectUrlSchema,
|
||||
@@ -56,6 +57,7 @@ export const ZCreateEmbeddingTemplateRequestSchema = z.object({
|
||||
timezone: ZDocumentMetaTimezoneSchema.optional(),
|
||||
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
|
||||
distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(),
|
||||
includeAuditLog: ZDocumentMetaIncludeAuditLogSchema.optional(),
|
||||
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
|
||||
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
|
||||
language: ZDocumentMetaLanguageSchema.optional(),
|
||||
|
||||
@@ -21,6 +21,7 @@ export const ZGetMultiSignDocumentResponseSchema = ZDocumentLiteSchema.extend({
|
||||
documentMeta: DocumentMetaSchema.pick({
|
||||
signingOrder: true,
|
||||
distributionMethod: true,
|
||||
includeAuditLog: true,
|
||||
id: true,
|
||||
subject: true,
|
||||
message: true,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ZDocumentMetaDateFormatSchema,
|
||||
ZDocumentMetaDistributionMethodSchema,
|
||||
ZDocumentMetaDrawSignatureEnabledSchema,
|
||||
ZDocumentMetaIncludeAuditLogSchema,
|
||||
ZDocumentMetaLanguageSchema,
|
||||
ZDocumentMetaMessageSchema,
|
||||
ZDocumentMetaRedirectUrlSchema,
|
||||
@@ -60,6 +61,7 @@ export const ZUpdateEmbeddingDocumentRequestSchema = z.object({
|
||||
timezone: ZDocumentMetaTimezoneSchema.optional(),
|
||||
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
|
||||
distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(),
|
||||
includeAuditLog: ZDocumentMetaIncludeAuditLogSchema.optional(),
|
||||
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
|
||||
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
|
||||
language: ZDocumentMetaLanguageSchema.optional(),
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ZDocumentMetaDateFormatSchema,
|
||||
ZDocumentMetaDistributionMethodSchema,
|
||||
ZDocumentMetaDrawSignatureEnabledSchema,
|
||||
ZDocumentMetaIncludeAuditLogSchema,
|
||||
ZDocumentMetaLanguageSchema,
|
||||
ZDocumentMetaMessageSchema,
|
||||
ZDocumentMetaRedirectUrlSchema,
|
||||
@@ -60,6 +61,7 @@ export const ZUpdateEmbeddingTemplateRequestSchema = z.object({
|
||||
timezone: ZDocumentMetaTimezoneSchema.optional(),
|
||||
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
|
||||
distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(),
|
||||
includeAuditLog: ZDocumentMetaIncludeAuditLogSchema.optional(),
|
||||
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
|
||||
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
|
||||
language: ZDocumentMetaLanguageSchema.optional(),
|
||||
|
||||
@@ -37,6 +37,7 @@ export const distributeEnvelopeRoute = authenticatedProcedure
|
||||
timezone: meta.timezone,
|
||||
redirectUrl: meta.redirectUrl,
|
||||
distributionMethod: meta.distributionMethod,
|
||||
includeAuditLog: meta.includeAuditLog,
|
||||
emailSettings: meta.emailSettings ?? undefined,
|
||||
language: meta.language,
|
||||
emailId: meta.emailId,
|
||||
|
||||
@@ -23,6 +23,7 @@ export const ZDistributeEnvelopeRequestSchema = z.object({
|
||||
timezone: true,
|
||||
dateFormat: true,
|
||||
distributionMethod: true,
|
||||
includeAuditLog: true,
|
||||
redirectUrl: true,
|
||||
language: true,
|
||||
emailId: true,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ZDocumentMetaDateFormatSchema,
|
||||
ZDocumentMetaDistributionMethodSchema,
|
||||
ZDocumentMetaDrawSignatureEnabledSchema,
|
||||
ZDocumentMetaIncludeAuditLogSchema,
|
||||
ZDocumentMetaLanguageSchema,
|
||||
ZDocumentMetaMessageSchema,
|
||||
ZDocumentMetaRedirectUrlSchema,
|
||||
@@ -89,6 +90,7 @@ export const ZUseEnvelopePayloadSchema = z.object({
|
||||
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
|
||||
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
|
||||
distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(),
|
||||
includeAuditLog: ZDocumentMetaIncludeAuditLogSchema.optional(),
|
||||
emailSettings: ZDocumentEmailSettingsSchema.optional(),
|
||||
language: ZDocumentMetaLanguageSchema.optional(),
|
||||
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
||||
|
||||
@@ -61,6 +61,7 @@ export const getTemplatesByIdsRoute = authenticatedProcedure
|
||||
select: {
|
||||
signingOrder: true,
|
||||
distributionMethod: true,
|
||||
includeAuditLog: true,
|
||||
},
|
||||
},
|
||||
directLink: {
|
||||
@@ -106,6 +107,7 @@ export const getTemplatesByIdsRoute = authenticatedProcedure
|
||||
? {
|
||||
signingOrder: envelope.documentMeta.signingOrder,
|
||||
distributionMethod: envelope.documentMeta.distributionMethod,
|
||||
includeAuditLog: envelope.documentMeta.includeAuditLog,
|
||||
}
|
||||
: null,
|
||||
directLink: envelope.directLink
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
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 DataTableProps<TData, TValue> = {
|
||||
export interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
columnVisibility?: VisibilityState;
|
||||
data: TData[];
|
||||
@@ -45,7 +45,7 @@ export type DataTableProps<TData, TValue> = {
|
||||
rowSelection?: RowSelectionState;
|
||||
onRowSelectionChange?: (selection: RowSelectionState) => void;
|
||||
getRowId?: (row: TData) => string;
|
||||
};
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
columns,
|
||||
@@ -165,7 +165,7 @@ export function DataTable<TData, TValue>({
|
||||
)}
|
||||
</TableRow>
|
||||
) : skeleton?.enable ? (
|
||||
Array.from({ length: skeleton.rows }, (_, i) => (
|
||||
Array.from({ length: skeleton.rows }).map((_, i) => (
|
||||
<TableRow key={`skeleton-row-${i}`}>{skeleton.component ?? <Skeleton />}</TableRow>
|
||||
))
|
||||
) : (
|
||||
@@ -178,7 +178,7 @@ export function DataTable<TData, TValue>({
|
||||
</p>
|
||||
|
||||
{hasFilters && onClearFilters !== undefined && (
|
||||
<button type="button" onClick={() => onClearFilters()} className="mt-1 text-foreground text-sm">
|
||||
<button onClick={() => onClearFilters()} className="mt-1 text-foreground text-sm">
|
||||
<Trans>Clear filters</Trans>
|
||||
</button>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user