feat: add faceted table toolbars and multi-value filters

Consolidating document and template filtering into shared faceted
toolbars makes filtering easier to discover and use.
Supporting comma-separated query params enables multi-select filters
across UI, server queries, and E2E coverage.
This commit is contained in:
ephraimduncan
2026-02-16 18:40:23 +00:00
parent ff9e6acb7a
commit 335fee09a9
23 changed files with 992 additions and 420 deletions
@@ -1,9 +1,13 @@
import type { NavigateOptions } from 'react-router';
import { useSearchParams } from 'react-router';
export const useUpdateSearchParams = () => {
type SearchParamValues = Record<string, string | number | boolean | null | undefined>;
type UpdateSearchParamsOptions = Pick<NavigateOptions, 'preventScrollReset' | 'replace' | 'state'>;
export const useUpdateSearchParams = (defaultOptions: UpdateSearchParamsOptions = {}) => {
const [searchParams, setSearchParams] = useSearchParams();
return (params: Record<string, string | number | boolean | null | undefined>) => {
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,
});
};
};
@@ -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<typeof data>;
};
const normalizeStatuses = (status: FindDocumentsOptions['status']) => {
if (!status) {
return [ExtendedDocumentStatus.ALL];
}
const statuses = Array.isArray(status) ? status : [status];
const dedupedStatuses = Array.from(new Set(statuses));
if (dedupedStatuses.includes(ExtendedDocumentStatus.ALL)) {
return [ExtendedDocumentStatus.ALL];
}
return dedupedStatuses;
};
const mergeStatusFilters = (filters: Array<Prisma.EnvelopeWhereInput | null>) => {
const validFilters = filters.filter(
(filter): filter is Prisma.EnvelopeWhereInput => filter !== null,
);
if (validFilters.length === 0) {
return null;
}
if (validFilters.length === 1) {
return validFilters[0];
}
return {
OR: validFilters,
} satisfies Prisma.EnvelopeWhereInput;
};
const findDocumentsFilter = (
status: ExtendedDocumentStatus,
user: Pick<User, 'id' | 'email' | 'name'>,
@@ -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');
@@ -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,
},
}),
+24 -5
View File
@@ -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;
};