mirror of
https://github.com/documenso/documenso.git
synced 2026-07-25 01:15:49 +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:
@@ -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