From 335fee09a94ff1e04e3371278fcc4856b9bb2a04 Mon Sep 17 00:00:00 2001 From: ephraimduncan Date: Mon, 16 Feb 2026 18:40:23 +0000 Subject: [PATCH] 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. --- .../forms/search-param-selector.tsx | 51 ----- .../general/document/document-search.tsx | 48 ----- .../components/general/period-selector.tsx | 70 ------- .../template-page-view-documents-table.tsx | 79 ++----- .../tables/documents-table-sender-filter.tsx | 66 ------ .../tables/documents-table-toolbar.tsx | 182 +++++++++++++++++ .../tables/table-toolbar.constants.ts | 21 ++ .../template-documents-table-toolbar.tsx | 192 ++++++++++++++++++ .../tables/templates-table-toolbar.tsx | 123 +++++++++++ .../t.$teamUrl+/documents._index.tsx | 121 ++++------- .../t.$teamUrl+/templates._index.tsx | 23 ++- packages/app-tests/e2e/fixtures/documents.ts | 21 +- .../e2e/teams/team-documents.spec.ts | 21 +- .../e2e/templates/manage-templates.spec.ts | 53 ++++- .../hooks/use-update-search-params.ts | 13 +- .../server-only/document/find-documents.ts | 67 ++++-- .../lib/server-only/document/get-stats.ts | 2 +- .../server-only/template/find-templates.ts | 29 ++- packages/lib/utils/params.ts | 29 ++- .../find-documents-internal.types.ts | 10 +- .../trpc/server/template-router/schema.ts | 5 +- .../primitives/data-table-faceted-filter.tsx | 179 ++++++++++++++++ packages/ui/primitives/data-table.tsx | 7 +- 23 files changed, 992 insertions(+), 420 deletions(-) delete mode 100644 apps/remix/app/components/forms/search-param-selector.tsx delete mode 100644 apps/remix/app/components/general/document/document-search.tsx delete mode 100644 apps/remix/app/components/general/period-selector.tsx delete mode 100644 apps/remix/app/components/tables/documents-table-sender-filter.tsx create mode 100644 apps/remix/app/components/tables/documents-table-toolbar.tsx create mode 100644 apps/remix/app/components/tables/table-toolbar.constants.ts create mode 100644 apps/remix/app/components/tables/template-documents-table-toolbar.tsx create mode 100644 apps/remix/app/components/tables/templates-table-toolbar.tsx create mode 100644 packages/ui/primitives/data-table-faceted-filter.tsx diff --git a/apps/remix/app/components/forms/search-param-selector.tsx b/apps/remix/app/components/forms/search-param-selector.tsx deleted file mode 100644 index 5992004a5..000000000 --- a/apps/remix/app/components/forms/search-param-selector.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import React, { useMemo } from 'react'; - -import { useLocation, useNavigate } from 'react-router'; -import { useSearchParams } from 'react-router'; - -import { Select, SelectContent, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select'; - -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 ( - - ); -}; diff --git a/apps/remix/app/components/general/document/document-search.tsx b/apps/remix/app/components/general/document/document-search.tsx deleted file mode 100644 index 92477853a..000000000 --- a/apps/remix/app/components/general/document/document-search.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; - -import { msg } from '@lingui/core/macro'; -import { useLingui } from '@lingui/react'; -import { useSearchParams } from 'react-router'; - -import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; -import { Input } from '@documenso/ui/primitives/input'; - -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 ( - setSearchTerm(e.target.value)} - /> - ); -}; diff --git a/apps/remix/app/components/general/period-selector.tsx b/apps/remix/app/components/general/period-selector.tsx deleted file mode 100644 index 025925a92..000000000 --- a/apps/remix/app/components/general/period-selector.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { useMemo } from 'react'; - -import { Trans } from '@lingui/react/macro'; -import { useLocation, useNavigate, useSearchParams } from 'react-router'; - -import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@documenso/ui/primitives/select'; - -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 ( - - ); -}; diff --git a/apps/remix/app/components/general/template/template-page-view-documents-table.tsx b/apps/remix/app/components/general/template/template-page-view-documents-table.tsx index 6072a8846..9974cb5a4 100644 --- a/apps/remix/app/components/general/template/template-page-view-documents-table.tsx +++ b/apps/remix/app/components/general/template/template-page-view-documents-table.tsx @@ -4,49 +4,42 @@ import type { MessageDescriptor } from '@lingui/core'; 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 type { DocumentSource } from '@prisma/client'; import { InfoIcon } from 'lucide-react'; import { DateTime } from 'luxon'; import { useSearchParams } from 'react-router'; -import { z } from 'zod'; 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'; -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 = ZUrlSearchParamsSchema.extend({ - source: z - .nativeEnum(DocumentSource) - .optional() - .catch(() => undefined), - status: z - .nativeEnum(DocumentStatusEnum) - .optional() - .catch(() => undefined), +const ZDocumentSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({ + page: true, + perPage: true, + query: true, + period: true, + status: true, + source: true, }); type TemplatePageViewDocumentsTableProps = { @@ -67,7 +60,7 @@ export const TemplatePageViewDocumentsTable = ({ Object.fromEntries(searchParams ?? []), ); - const { data, isLoading, isLoadingError } = trpc.document.find.useQuery( + const { data, isLoading, isLoadingError } = trpc.document.findDocumentsInternal.useQuery( { templateId, page: parsedSearchParams.page, @@ -75,6 +68,7 @@ export const TemplatePageViewDocumentsTable = ({ query: parsedSearchParams.query, source: parsedSearchParams.source, status: parsedSearchParams.status, + period: parsedSearchParams.period, }, { placeholderData: (previousData) => previousData, @@ -133,8 +127,8 @@ export const TemplatePageViewDocumentsTable = ({ - -
    + +
    • Template @@ -183,49 +177,8 @@ export const TemplatePageViewDocumentsTable = ({ }, []); return ( -
      -
      - - - - [...DocumentStatusEnum.COMPLETED].includes(value as unknown as string) - } - > - - Any Status - - - Completed - - - Pending - - - Draft - - - - - [...DocumentSource.TEMPLATE].includes(value as unknown as string) - } - > - - Any Source - - - Template - - - Direct Link - - - - -
      +
      + { - 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 ( - - - Sender: All - -

      - } - enableClearAllButton={true} - inputPlaceholder={msg`Search`} - loading={!isMounted || isLoading} - options={comboBoxOptions} - selectedValues={senderIds} - onChange={onChange} - /> - ); -}; diff --git a/apps/remix/app/components/tables/documents-table-toolbar.tsx b/apps/remix/app/components/tables/documents-table-toolbar.tsx new file mode 100644 index 000000000..fd9380a4e --- /dev/null +++ b/apps/remix/app/components/tables/documents-table-toolbar.tsx @@ -0,0 +1,182 @@ +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 selectedStatusValues = parseToStringArray(searchParams.get('status')); + const selectedSenderValues = parseToStringArray(searchParams.get('senderIds')); + + 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 }); + }, [debouncedSearchTerm, query, searchTerm]); + + 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(() => { + 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 ( +
      +
      + setSearchTerm(event.target.value)} + placeholder={_(msg`Search documents...`)} + className="h-9 w-full pe-9" + /> + + {searchTerm.length > 0 && ( + + )} +
      + + { + updateSearchParams({ + status: toCommaSeparatedSearchParam(values), + page: undefined, + }); + }} + /> + + {teamId !== undefined && ( + { + updateSearchParams({ + senderIds: toCommaSeparatedSearchParam(values), + page: undefined, + }); + }} + /> + )} + + { + const nextPeriod = values[0]; + + updateSearchParams({ + period: nextPeriod ?? undefined, + page: undefined, + }); + }} + /> + + {hasActiveFilters && ( + + )} +
      + ); +}; diff --git a/apps/remix/app/components/tables/table-toolbar.constants.ts b/apps/remix/app/components/tables/table-toolbar.constants.ts new file mode 100644 index 000000000..0913fbc6d --- /dev/null +++ b/apps/remix/app/components/tables/table-toolbar.constants.ts @@ -0,0 +1,21 @@ +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', + }, +]; diff --git a/apps/remix/app/components/tables/template-documents-table-toolbar.tsx b/apps/remix/app/components/tables/template-documents-table-toolbar.tsx new file mode 100644 index 000000000..b68d3e014 --- /dev/null +++ b/apps/remix/app/components/tables/template-documents-table-toolbar.tsx @@ -0,0 +1,192 @@ +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 selectedStatusValues = parseToStringArray(searchParams.get('status')); + const selectedSourceValues = parseToStringArray(searchParams.get('source')); + + 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 }); + }, [debouncedSearchTerm, query, searchTerm]); + + const statusOptions = useMemo( + () => [ + { + 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( + () => [ + { + label: _(msg`Template`), + value: DocumentSource.TEMPLATE, + icon: FileText, + }, + { + label: _(msg`Direct Link`), + value: DocumentSource.TEMPLATE_DIRECT_LINK, + icon: LinkIcon, + }, + ], + [_], + ); + + const periodOptions = useMemo(() => { + 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 ( +
      +
      + setSearchTerm(event.target.value)} + placeholder={_(msg`Search documents...`)} + className="h-9 w-full pe-9" + /> + + {searchTerm.length > 0 && ( + + )} +
      + + { + updateSearchParams({ + status: toCommaSeparatedSearchParam(values), + page: undefined, + }); + }} + /> + + { + updateSearchParams({ + source: toCommaSeparatedSearchParam(values), + page: undefined, + }); + }} + /> + + { + const nextPeriod = values[0]; + + updateSearchParams({ + period: nextPeriod ?? undefined, + page: undefined, + }); + }} + /> + + {hasActiveFilters && ( + + )} +
      + ); +}; diff --git a/apps/remix/app/components/tables/templates-table-toolbar.tsx b/apps/remix/app/components/tables/templates-table-toolbar.tsx new file mode 100644 index 000000000..d872f1c8b --- /dev/null +++ b/apps/remix/app/components/tables/templates-table-toolbar.tsx @@ -0,0 +1,123 @@ +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 selectedTypeValues = parseToStringArray(searchParams.get('type')); + + 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 }); + }, [debouncedSearchTerm, query, searchTerm]); + + const typeOptions = useMemo( + () => [ + { + 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 ( +
      +
      + setSearchTerm(event.target.value)} + placeholder={_(msg`Search templates...`)} + className="h-9 w-full pe-9" + /> + + {searchTerm.length > 0 && ( + + )} +
      + + { + updateSearchParams({ + type: toCommaSeparatedSearchParam(values), + page: undefined, + }); + }} + /> + + {hasActiveFilters && ( + + )} +
      + ); +}; diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx index b0db12bab..779a15028 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx @@ -1,36 +1,33 @@ import { useEffect, useMemo, useState } from 'react'; +import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; import { EnvelopeType } from '@prisma/client'; import { FolderType, OrganisationType } from '@prisma/client'; import { useParams, useSearchParams } from 'react-router'; -import { Link } from 'react-router'; import { z } from 'zod'; import { useSessionStorage } from '@documenso/lib/client-only/hooks/use-session-storage'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; import { parseToIntegerArray } from '@documenso/lib/utils/params'; -import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; import { trpc } from '@documenso/trpc/react'; import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/find-documents-internal.types'; 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 { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; +import type { DataTableFacetedFilterOption } from '@documenso/ui/primitives/data-table-faceted-filter'; import { DocumentMoveToFolderDialog } from '~/components/dialogs/document-move-to-folder-dialog'; import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog'; import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog'; -import { DocumentSearch } from '~/components/general/document/document-search'; -import { DocumentStatus } from '~/components/general/document/document-status'; +import { FRIENDLY_STATUS_MAP } 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 { DocumentsTableSenderFilter } from '~/components/tables/documents-table-sender-filter'; +import { DocumentsTableToolbar } from '~/components/tables/documents-table-toolbar'; import { EnvelopesTableBulkActionBar } from '~/components/tables/envelopes-table-bulk-action-bar'; import { useCurrentTeam } from '~/providers/team'; import { appMetaTags } from '~/utils/meta'; @@ -50,6 +47,8 @@ const ZSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({ }); export default function DocumentsPage() { + const { _ } = useLingui(); + const organisation = useCurrentOrganisation(); const team = useCurrentTeam(); @@ -89,35 +88,37 @@ export default function DocumentsPage() { folderId, }); - const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => { - const params = new URLSearchParams(searchParams); + const statusOptions = useMemo(() => { + return [ + ExtendedDocumentStatus.INBOX, + ExtendedDocumentStatus.PENDING, + ExtendedDocumentStatus.COMPLETED, + ExtendedDocumentStatus.DRAFT, + ExtendedDocumentStatus.REJECTED, + ] + .filter((status) => { + if (organisation.type === OrganisationType.PERSONAL) { + return status !== ExtendedDocumentStatus.INBOX; + } - params.set('status', value); + return true; + }) + .map((status) => { + const { label, icon, color } = FRIENDLY_STATUS_MAP[status]; - if (value === ExtendedDocumentStatus.ALL) { - params.delete('status'); - } + return { + label: _(label), + value: status, + icon, + iconClassName: color, + }; + }); + }, [organisation.type, _]); - if (value === ExtendedDocumentStatus.INBOX && organisation.type === OrganisationType.PERSONAL) { - params.delete('status'); - } + const selectedStatuses = findDocumentSearchParams.status ?? []; - 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; - }; + const selectedStatus = + selectedStatuses.length === 1 ? selectedStatuses[0] : ExtendedDocumentStatus.ALL; useEffect(() => { if (data?.stats) { @@ -143,60 +144,20 @@ export default function DocumentsPage() { Documents

      + -
      - - - {[ - ExtendedDocumentStatus.INBOX, - ExtendedDocumentStatus.PENDING, - ExtendedDocumentStatus.COMPLETED, - ExtendedDocumentStatus.DRAFT, - ExtendedDocumentStatus.ALL, - ] - .filter((value) => { - if (organisation.type === OrganisationType.PERSONAL) { - return value !== ExtendedDocumentStatus.INBOX; - } - - return true; - }) - .map((value) => ( - - - - - {value !== ExtendedDocumentStatus.ALL && ( - {stats[value]} - )} - - - ))} - - - - {team && } - -
      - -
      -
      - -
      -
      +
      +
      {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 && (