mirror of
https://github.com/documenso/documenso.git
synced 2026-07-17 08:40:00 +10:00
feat: add faceted table toolbars and multi-value filters
Consolidating document and template filtering into shared faceted toolbars makes filtering easier to discover and use. Supporting comma-separated query params enables multi-select filters across UI, server queries, and E2E coverage.
This commit is contained in:
@@ -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<string, string | undefined> = {
|
||||
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;
|
||||
|
||||
@@ -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/);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Check, PlusCircle } from 'lucide-react';
|
||||
|
||||
import { cn } from '../lib/utils';
|
||||
import { Badge } from './badge';
|
||||
import { Button } from './button';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from './command';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './popover';
|
||||
import { Separator } from './separator';
|
||||
|
||||
export type DataTableFacetedFilterOption = {
|
||||
label: string;
|
||||
value: string;
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
iconClassName?: string;
|
||||
};
|
||||
|
||||
export type DataTableFacetedFilterProps = {
|
||||
title: string;
|
||||
options: DataTableFacetedFilterOption[];
|
||||
selectedValues: string[];
|
||||
onSelectedValuesChange: (values: string[]) => void;
|
||||
singleSelect?: boolean;
|
||||
counts?: Record<string, number>;
|
||||
showSearch?: boolean;
|
||||
};
|
||||
|
||||
export const DataTableFacetedFilter = ({
|
||||
title,
|
||||
options,
|
||||
selectedValues,
|
||||
onSelectedValuesChange,
|
||||
singleSelect,
|
||||
counts,
|
||||
showSearch = true,
|
||||
}: DataTableFacetedFilterProps) => {
|
||||
const selectedValuesSet = new Set(selectedValues);
|
||||
|
||||
const selectedOptions = options.filter((option) => selectedValuesSet.has(option.value));
|
||||
|
||||
const onSelect = (value: string) => {
|
||||
if (singleSelect) {
|
||||
const nextValue = selectedValuesSet.has(value) ? [] : [value];
|
||||
|
||||
onSelectedValuesChange(nextValue);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const nextValues = new Set(selectedValuesSet);
|
||||
|
||||
if (nextValues.has(value)) {
|
||||
nextValues.delete(value);
|
||||
} else {
|
||||
nextValues.add(value);
|
||||
}
|
||||
|
||||
onSelectedValuesChange(Array.from(nextValues));
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-9 border-dashed">
|
||||
<PlusCircle className="mr-2 h-4 w-4" />
|
||||
{title}
|
||||
|
||||
{selectedValues.length > 0 && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||
|
||||
<Badge
|
||||
variant="neutral"
|
||||
size="small"
|
||||
className="rounded-sm px-1 font-normal lg:hidden"
|
||||
>
|
||||
{selectedValues.length}
|
||||
</Badge>
|
||||
|
||||
<div className="hidden gap-1 lg:flex">
|
||||
{selectedValues.length > 2 ? (
|
||||
<Badge variant="neutral" size="small" className="rounded-sm px-1 font-normal">
|
||||
{selectedValues.length} <Trans>selected</Trans>
|
||||
</Badge>
|
||||
) : (
|
||||
selectedOptions.map((option) => (
|
||||
<Badge
|
||||
key={option.value}
|
||||
variant="neutral"
|
||||
size="small"
|
||||
className="rounded-sm px-1 font-normal"
|
||||
>
|
||||
{option.label}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent className="w-[220px] p-0" align="start">
|
||||
<Command shouldFilter={showSearch}>
|
||||
{showSearch && <CommandInput placeholder={title} />}
|
||||
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
<Trans>No results found.</Trans>
|
||||
</CommandEmpty>
|
||||
|
||||
<CommandGroup>
|
||||
{options.map((option) => {
|
||||
const isSelected = selectedValuesSet.has(option.value);
|
||||
|
||||
return (
|
||||
<CommandItem key={option.value} onSelect={() => onSelect(option.value)}>
|
||||
<div
|
||||
className={cn(
|
||||
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
|
||||
isSelected
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible',
|
||||
)}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</div>
|
||||
|
||||
{option.icon && (
|
||||
<option.icon
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
option.iconClassName ?? 'text-muted-foreground',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<span>{option.label}</span>
|
||||
|
||||
{counts && counts[option.value] !== undefined && (
|
||||
<span className="ml-auto flex h-4 w-4 items-center justify-center font-mono text-xs text-muted-foreground">
|
||||
{counts[option.value]}
|
||||
</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
|
||||
{selectedValues.length > 0 && (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
onSelect={() => onSelectedValuesChange([])}
|
||||
className="justify-center text-center"
|
||||
>
|
||||
<Trans>Clear filters</Trans>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -18,7 +18,7 @@ export type DataTableChildren<TData> = (_table: TTable<TData>) => React.ReactNod
|
||||
|
||||
export type { ColumnDef as DataTableColumnDef, RowSelectionState } from '@tanstack/react-table';
|
||||
|
||||
export interface DataTableProps<TData, TValue> {
|
||||
export type DataTableProps<TData, TValue> = {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
columnVisibility?: VisibilityState;
|
||||
data: TData[];
|
||||
@@ -45,7 +45,7 @@ export interface DataTableProps<TData, TValue> {
|
||||
rowSelection?: RowSelectionState;
|
||||
onRowSelectionChange?: (selection: RowSelectionState) => void;
|
||||
getRowId?: (row: TData) => string;
|
||||
}
|
||||
};
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
columns,
|
||||
@@ -167,7 +167,7 @@ export function DataTable<TData, TValue>({
|
||||
)}
|
||||
</TableRow>
|
||||
) : skeleton?.enable ? (
|
||||
Array.from({ length: skeleton.rows }).map((_, i) => (
|
||||
Array.from({ length: skeleton.rows }, (_, i) => (
|
||||
<TableRow key={`skeleton-row-${i}`}>{skeleton.component ?? <Skeleton />}</TableRow>
|
||||
))
|
||||
) : (
|
||||
@@ -181,6 +181,7 @@ export function DataTable<TData, TValue>({
|
||||
|
||||
{hasFilters && onClearFilters !== undefined && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClearFilters()}
|
||||
className="mt-1 text-sm text-foreground"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user