{data && data.count === 0 ? (
-
+
) : (
+ ZTemplatesSearchParamsSchema.safeParse(Object.fromEntries(searchParams.entries())).data || {},
+ [searchParams],
+ );
const [rowSelection, setRowSelection] = useSessionStorage(
'templates-bulk-selection',
@@ -50,8 +62,7 @@ export default function TemplatesPage() {
const templateRootPath = formatTemplatesPath(team.url);
const { data, isLoading, isLoadingError } = trpc.template.findTemplates.useQuery({
- page: page,
- perPage: perPage,
+ ...findTemplatesSearchParams,
folderId,
});
@@ -74,6 +85,10 @@ export default function TemplatesPage() {
+
+
+
+
{data && data.count === 0 ? (
diff --git a/packages/app-tests/e2e/fixtures/documents.ts b/packages/app-tests/e2e/fixtures/documents.ts
index 160dc1030..3a2aa422b 100644
--- a/packages/app-tests/e2e/fixtures/documents.ts
+++ b/packages/app-tests/e2e/fixtures/documents.ts
@@ -2,12 +2,27 @@ import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
export const checkDocumentTabCount = async (page: Page, tabName: string, count: number) => {
- await page.getByRole('tab', { name: tabName }).click();
+ const statusMap: Record
= {
+ Inbox: 'INBOX',
+ Pending: 'PENDING',
+ Completed: 'COMPLETED',
+ Draft: 'DRAFT',
+ All: undefined,
+ };
- if (tabName !== 'All') {
- await expect(page.getByRole('tab', { name: tabName })).toContainText(count.toString());
+ const currentUrl = new URL(page.url());
+ const status = statusMap[tabName];
+
+ if (status) {
+ currentUrl.searchParams.set('status', status);
+ } else {
+ currentUrl.searchParams.delete('status');
}
+ currentUrl.searchParams.delete('page');
+
+ await page.goto(currentUrl.toString());
+
if (count === 0) {
await expect(page.getByTestId('empty-document-state')).toBeVisible();
return;
diff --git a/packages/app-tests/e2e/teams/team-documents.spec.ts b/packages/app-tests/e2e/teams/team-documents.spec.ts
index 39efbefd6..31e7be995 100644
--- a/packages/app-tests/e2e/teams/team-documents.spec.ts
+++ b/packages/app-tests/e2e/teams/team-documents.spec.ts
@@ -36,7 +36,7 @@ test('[TEAMS]: check team documents count', async ({ page }) => {
await checkDocumentTabCount(page, 'All', 5);
// Apply filter.
- await page.locator('button').filter({ hasText: 'Sender: All' }).click();
+ await page.getByRole('button', { name: /Sender/ }).click();
await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
await page.waitForURL(/senderIds/);
@@ -51,6 +51,21 @@ test('[TEAMS]: check team documents count', async ({ page }) => {
}
});
+test('[TEAMS]: supports filtering documents by multiple statuses', async ({ page }) => {
+ const { team, teamOwner } = await seedTeamDocuments();
+
+ await apiSignin({
+ page,
+ email: teamOwner.email,
+ redirectPath: `/t/${team.url}/documents?status=PENDING,DRAFT`,
+ });
+
+ await expect(page).toHaveURL(/status=PENDING,DRAFT/);
+ await expect(page.getByTestId('data-table-count')).toContainText('Showing 4');
+
+ await apiSignout({ page });
+});
+
test('[TEAMS]: check team documents count with internal team email', async ({ page }) => {
const { team, teamOwner, teamMember2, teamMember4 } = await seedTeamDocuments();
const {
@@ -135,7 +150,7 @@ test('[TEAMS]: check team documents count with internal team email', async ({ pa
await checkDocumentTabCount(page, 'All', 11);
// Apply filter.
- await page.locator('button').filter({ hasText: 'Sender: All' }).click();
+ await page.getByRole('button', { name: /Sender/ }).click();
await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
await page.waitForURL(/senderIds/);
@@ -222,7 +237,7 @@ test('[TEAMS]: check team documents count with external team email', async ({ pa
await checkDocumentTabCount(page, 'All', 9);
// Apply filter.
- await page.locator('button').filter({ hasText: 'Sender: All' }).click();
+ await page.getByRole('button', { name: /Sender/ }).click();
await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
await page.waitForURL(/senderIds/);
diff --git a/packages/app-tests/e2e/templates/manage-templates.spec.ts b/packages/app-tests/e2e/templates/manage-templates.spec.ts
index 4081c3e6d..b919daa45 100644
--- a/packages/app-tests/e2e/templates/manage-templates.spec.ts
+++ b/packages/app-tests/e2e/templates/manage-templates.spec.ts
@@ -1,6 +1,7 @@
import { expect, test } from '@playwright/test';
-import { TeamMemberRole } from '@prisma/client';
+import { TeamMemberRole, TemplateType } from '@prisma/client';
+import { prisma } from '@documenso/prisma';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedTemplate } from '@documenso/prisma/seed/templates';
@@ -41,6 +42,56 @@ test('[TEMPLATES]: view templates', async ({ page }) => {
await expect(page.getByTestId('data-table-count')).toContainText('Showing 2 results');
});
+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,
diff --git a/packages/lib/client-only/hooks/use-update-search-params.ts b/packages/lib/client-only/hooks/use-update-search-params.ts
index 067bb41a5..0f59ed6fa 100644
--- a/packages/lib/client-only/hooks/use-update-search-params.ts
+++ b/packages/lib/client-only/hooks/use-update-search-params.ts
@@ -1,9 +1,13 @@
+import type { NavigateOptions } from 'react-router';
import { useSearchParams } from 'react-router';
-export const useUpdateSearchParams = () => {
+type SearchParamValues = Record;
+type UpdateSearchParamsOptions = Pick;
+
+export const useUpdateSearchParams = (defaultOptions: UpdateSearchParamsOptions = {}) => {
const [searchParams, setSearchParams] = useSearchParams();
- return (params: Record) => {
+ return (params: SearchParamValues, options?: UpdateSearchParamsOptions) => {
const nextSearchParams = new URLSearchParams(searchParams?.toString() ?? '');
Object.entries(params).forEach(([key, value]) => {
@@ -14,6 +18,9 @@ export const useUpdateSearchParams = () => {
}
});
- setSearchParams(nextSearchParams);
+ setSearchParams(nextSearchParams, {
+ ...defaultOptions,
+ ...options,
+ });
};
};
diff --git a/packages/lib/server-only/document/find-documents.ts b/packages/lib/server-only/document/find-documents.ts
index 8b1eafcc7..43d6366f7 100644
--- a/packages/lib/server-only/document/find-documents.ts
+++ b/packages/lib/server-only/document/find-documents.ts
@@ -11,14 +11,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 = '' | '7d' | '14d' | '30d';
+export type PeriodSelectorValue = '' | 'all' | '7d' | '14d' | '30d';
export type FindDocumentsOptions = {
userId: number;
teamId?: number;
templateId?: number;
- source?: DocumentSource;
- status?: ExtendedDocumentStatus;
+ source?: DocumentSource | DocumentSource[];
+ status?: ExtendedDocumentStatus | ExtendedDocumentStatus[];
page?: number;
perPage?: number;
orderBy?: {
@@ -36,7 +36,7 @@ export const findDocuments = async ({
teamId,
templateId,
source,
- status = ExtendedDocumentStatus.ALL,
+ status,
page = 1,
perPage = 10,
orderBy,
@@ -69,6 +69,8 @@ export const findDocuments = async ({
const orderByDirection = orderBy?.direction ?? 'desc';
const teamMemberRole = team?.currentTeamRole ?? null;
+ const normalizedStatuses = normalizeStatuses(status);
+
const searchFilter: Prisma.EnvelopeWhereInput = {
OR: [
{ title: { contains: query, mode: 'insensitive' } },
@@ -111,10 +113,16 @@ export const findDocuments = async ({
},
];
- let filters: Prisma.EnvelopeWhereInput | null = findDocumentsFilter(status, user, folderId);
+ let filters: Prisma.EnvelopeWhereInput | null = mergeStatusFilters(
+ normalizedStatuses.map((currentStatus) => findDocumentsFilter(currentStatus, user, folderId)),
+ );
if (team) {
- filters = findTeamDocumentsFilter(status, team, visibilityFilters, folderId);
+ filters = mergeStatusFilters(
+ normalizedStatuses.map((currentStatus) =>
+ findTeamDocumentsFilter(currentStatus, team, visibilityFilters, folderId),
+ ),
+ );
}
if (filters === null) {
@@ -193,8 +201,12 @@ export const findDocuments = async ({
}
if (source) {
+ const sources = Array.isArray(source) ? source : [source];
+
whereAndClause.push({
- source,
+ source: {
+ in: sources,
+ },
});
}
@@ -203,7 +215,7 @@ export const findDocuments = async ({
AND: whereAndClause,
};
- if (period) {
+ if (period && period !== 'all') {
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day');
@@ -219,11 +231,7 @@ export const findDocuments = async ({
};
}
- if (folderId !== undefined) {
- whereClause.folderId = folderId;
- } else {
- whereClause.folderId = null;
- }
+ whereClause.folderId = folderId ?? null;
const [data, count] = await Promise.all([
prisma.envelope.findMany({
@@ -279,6 +287,39 @@ export const findDocuments = async ({
} satisfies FindResultResponse;
};
+const normalizeStatuses = (status: FindDocumentsOptions['status']) => {
+ if (!status) {
+ return [ExtendedDocumentStatus.ALL];
+ }
+
+ const statuses = Array.isArray(status) ? status : [status];
+ const dedupedStatuses = Array.from(new Set(statuses));
+
+ if (dedupedStatuses.includes(ExtendedDocumentStatus.ALL)) {
+ return [ExtendedDocumentStatus.ALL];
+ }
+
+ return dedupedStatuses;
+};
+
+const mergeStatusFilters = (filters: Array) => {
+ const validFilters = filters.filter(
+ (filter): filter is Prisma.EnvelopeWhereInput => filter !== null,
+ );
+
+ if (validFilters.length === 0) {
+ return null;
+ }
+
+ if (validFilters.length === 1) {
+ return validFilters[0];
+ }
+
+ return {
+ OR: validFilters,
+ } satisfies Prisma.EnvelopeWhereInput;
+};
+
const findDocumentsFilter = (
status: ExtendedDocumentStatus,
user: Pick,
diff --git a/packages/lib/server-only/document/get-stats.ts b/packages/lib/server-only/document/get-stats.ts
index 4d47b4e48..2632000ed 100644
--- a/packages/lib/server-only/document/get-stats.ts
+++ b/packages/lib/server-only/document/get-stats.ts
@@ -25,7 +25,7 @@ export const getStats = async ({
}: GetStatsInput) => {
let createdAt: Prisma.EnvelopeWhereInput['createdAt'];
- if (period) {
+ if (period && period !== 'all') {
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day');
diff --git a/packages/lib/server-only/template/find-templates.ts b/packages/lib/server-only/template/find-templates.ts
index 621459255..6e11697ac 100644
--- a/packages/lib/server-only/template/find-templates.ts
+++ b/packages/lib/server-only/template/find-templates.ts
@@ -10,7 +10,8 @@ import { getMemberRoles } from '../team/get-member-roles';
export type FindTemplatesOptions = {
userId: number;
teamId: number;
- type?: TemplateType;
+ type?: TemplateType | TemplateType[];
+ query?: string;
page?: number;
perPage?: number;
folderId?: string;
@@ -20,12 +21,15 @@ export const findTemplates = async ({
userId,
teamId,
type,
+ query = '',
page = 1,
perPage = 10,
folderId,
}: FindTemplatesOptions) => {
const whereFilter: Prisma.EnvelopeWhereInput[] = [];
+ const templateTypeFilter = type ? { in: Array.isArray(type) ? type : [type] } : undefined;
+
const { teamRole } = await getMemberRoles({
teamId,
reference: {
@@ -54,11 +58,30 @@ export const findTemplates = async ({
whereFilter.push({ folderId: null });
}
+ if (query) {
+ whereFilter.push({
+ OR: [
+ {
+ title: {
+ contains: query,
+ mode: 'insensitive',
+ },
+ },
+ {
+ externalId: {
+ contains: query,
+ mode: 'insensitive',
+ },
+ },
+ ],
+ });
+ }
+
const [data, count] = await Promise.all([
prisma.envelope.findMany({
where: {
type: EnvelopeType.TEMPLATE,
- templateType: type,
+ templateType: templateTypeFilter,
AND: whereFilter,
},
include: {
@@ -87,7 +110,7 @@ export const findTemplates = async ({
prisma.envelope.count({
where: {
type: EnvelopeType.TEMPLATE,
- templateType: type,
+ templateType: templateTypeFilter,
AND: whereFilter,
},
}),
diff --git a/packages/lib/utils/params.ts b/packages/lib/utils/params.ts
index 5ae2f37c0..d9a5b5f6c 100644
--- a/packages/lib/utils/params.ts
+++ b/packages/lib/utils/params.ts
@@ -1,8 +1,3 @@
-/**
- * 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 [];
@@ -14,6 +9,30 @@ 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;
};
diff --git a/packages/trpc/server/document-router/find-documents-internal.types.ts b/packages/trpc/server/document-router/find-documents-internal.types.ts
index 16e8edb66..968d1a48d 100644
--- a/packages/trpc/server/document-router/find-documents-internal.types.ts
+++ b/packages/trpc/server/document-router/find-documents-internal.types.ts
@@ -1,15 +1,21 @@
+import { DocumentSource } from '@prisma/client';
import { z } from 'zod';
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 { ZFindDocumentsRequestSchema } from './find-documents.types';
export const ZFindDocumentsInternalRequestSchema = ZFindDocumentsRequestSchema.extend({
- period: z.enum(['7d', '14d', '30d']).optional(),
+ period: z.enum(['all', '7d', '14d', '30d']).optional(),
senderIds: z.array(z.number()).optional(),
- status: z.nativeEnum(ExtendedDocumentStatus).optional(),
+ source: z.preprocess(parseCommaSeparatedValues, z.array(z.nativeEnum(DocumentSource)).optional()),
+ status: z.preprocess(
+ parseCommaSeparatedValues,
+ z.array(z.nativeEnum(ExtendedDocumentStatus)).optional(),
+ ),
folderId: z.string().optional(),
});
diff --git a/packages/trpc/server/template-router/schema.ts b/packages/trpc/server/template-router/schema.ts
index 5b02d5cc4..ca4fdf01a 100644
--- a/packages/trpc/server/template-router/schema.ts
+++ b/packages/trpc/server/template-router/schema.ts
@@ -30,6 +30,7 @@ import {
ZTemplateManySchema,
ZTemplateSchema,
} from '@documenso/lib/types/template';
+import { parseCommaSeparatedValues } from '@documenso/lib/utils/params';
import { LegacyTemplateDirectLinkSchema } from '@documenso/prisma/types/template-legacy-schema';
import { ZDocumentExternalIdSchema } from '@documenso/trpc/server/document-router/schema';
@@ -289,7 +290,9 @@ export const ZUpdateTemplateRequestSchema = z.object({
export const ZUpdateTemplateResponseSchema = ZTemplateLiteSchema;
export const ZFindTemplatesRequestSchema = ZFindSearchParamsSchema.extend({
- type: z.nativeEnum(TemplateType).describe('Filter templates by type.').optional(),
+ type: z
+ .preprocess(parseCommaSeparatedValues, z.array(z.nativeEnum(TemplateType)).optional())
+ .describe('Filter templates by type.'),
folderId: z.string().describe('The ID of the folder to filter templates by.').optional(),
});
diff --git a/packages/ui/primitives/data-table-faceted-filter.tsx b/packages/ui/primitives/data-table-faceted-filter.tsx
new file mode 100644
index 000000000..455e1c083
--- /dev/null
+++ b/packages/ui/primitives/data-table-faceted-filter.tsx
@@ -0,0 +1,179 @@
+import * as React from 'react';
+
+import { Trans } from '@lingui/react/macro';
+import { Check, PlusCircle } from 'lucide-react';
+
+import { cn } from '../lib/utils';
+import { Badge } from './badge';
+import { Button } from './button';
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+ CommandSeparator,
+} from './command';
+import { Popover, PopoverContent, PopoverTrigger } from './popover';
+import { Separator } from './separator';
+
+export type DataTableFacetedFilterOption = {
+ label: string;
+ value: string;
+ icon?: React.ComponentType<{ className?: string }>;
+ iconClassName?: string;
+};
+
+export type DataTableFacetedFilterProps = {
+ title: string;
+ options: DataTableFacetedFilterOption[];
+ selectedValues: string[];
+ onSelectedValuesChange: (values: string[]) => void;
+ singleSelect?: boolean;
+ counts?: Record;
+ 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 (
+
+
+
+
+
+
+
+ {showSearch && }
+
+
+
+ No results found.
+
+
+
+ {options.map((option) => {
+ const isSelected = selectedValuesSet.has(option.value);
+
+ return (
+ onSelect(option.value)}>
+
+
+
+
+ {option.icon && (
+
+ )}
+
+ {option.label}
+
+ {counts && counts[option.value] !== undefined && (
+
+ {counts[option.value]}
+
+ )}
+
+ );
+ })}
+
+
+ {selectedValues.length > 0 && (
+ <>
+
+
+
+ onSelectedValuesChange([])}
+ className="justify-center text-center"
+ >
+ Clear filters
+
+
+ >
+ )}
+
+
+
+
+ );
+};
diff --git a/packages/ui/primitives/data-table.tsx b/packages/ui/primitives/data-table.tsx
index 140293755..88deafe64 100644
--- a/packages/ui/primitives/data-table.tsx
+++ b/packages/ui/primitives/data-table.tsx
@@ -18,7 +18,7 @@ export type DataTableChildren = (_table: TTable) => React.ReactNod
export type { ColumnDef as DataTableColumnDef, RowSelectionState } from '@tanstack/react-table';
-export interface DataTableProps {
+export type DataTableProps = {
columns: ColumnDef[];
columnVisibility?: VisibilityState;
data: TData[];
@@ -45,7 +45,7 @@ export interface DataTableProps {
rowSelection?: RowSelectionState;
onRowSelectionChange?: (selection: RowSelectionState) => void;
getRowId?: (row: TData) => string;
-}
+};
export function DataTable({
columns,
@@ -167,7 +167,7 @@ export function DataTable({
)}
) : skeleton?.enable ? (
- Array.from({ length: skeleton.rows }).map((_, i) => (
+ Array.from({ length: skeleton.rows }, (_, i) => (
{skeleton.component ?? }
))
) : (
@@ -181,6 +181,7 @@ export function DataTable({
{hasFilters && onClearFilters !== undefined && (