diff --git a/apps/remix/app/components/general/document/document-search.tsx b/apps/remix/app/components/general/document/document-search.tsx index 9079be8f8..bb0819008 100644 --- a/apps/remix/app/components/general/document/document-search.tsx +++ b/apps/remix/app/components/general/document/document-search.tsx @@ -2,38 +2,24 @@ import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounce import { Input } from '@documenso/ui/primitives/input'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; -import { useCallback, useEffect, useState } from 'react'; -import { useSearchParams } from 'react-router'; +import { useQueryState } from 'nuqs'; +import { useEffect, useState } from 'react'; -export const DocumentSearch = ({ initialValue = '' }: { initialValue?: string }) => { +import { documentsSearchParams } from '~/utils/documents-search-params'; + +export const DocumentSearch = () => { const { _ } = useLingui(); - const [searchParams, setSearchParams] = useSearchParams(); + const [query, setQuery] = useQueryState('query', documentsSearchParams.query); - const [searchTerm, setSearchTerm] = useState(initialValue); + const [searchTerm, setSearchTerm] = useState(query ?? ''); 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); + if (debouncedSearchTerm !== (query ?? '')) { + void setQuery(debouncedSearchTerm || null); } - }, [debouncedSearchTerm, searchParams]); + }, [debouncedSearchTerm, query, setQuery]); return ( void; + selectedLabel?: ReactNode; +}; + +export type FilterPillMultipleProps = FilterPillCommonProps & { + multiple: true; + value: string[]; + onChange: (value: string[]) => void; +}; + +export type FilterPillProps = FilterPillSingleProps | FilterPillMultipleProps; + +/** + * A faceted filter pill. + * + * Renders as a dashed "add a filter" pill at rest, and shows the current + * selection inline once a value is picked. Selecting the active option + * again (or the Clear row) removes it. + * + * Single select by default, closing on pick. When `multiple` is set the + * popover stays open for toggling, and the trigger shows the first two + * selections followed by a "+N more" chip. + */ +export const FilterPill = (props: FilterPillProps) => { + const { icon: Icon, label, options, enableSearch, searchPlaceholder, loading, testId } = props; + + const [open, setOpen] = useState(false); + + const selectedValues = props.multiple ? props.value : props.value === null ? [] : [props.value]; + + const selectedOptions = selectedValues + .map((value) => options.find((option) => option.value === value)) + .filter((option): option is FilterPillOption => option !== undefined); + + const hasSelection = selectedOptions.length > 0; + const extraCount = selectedOptions.length - 2; + + const onSelect = (nextValue: string) => { + if (props.multiple) { + const newValues = selectedValues.includes(nextValue) + ? selectedValues.filter((value) => value !== nextValue) + : [...selectedValues, nextValue]; + + props.onChange(newValues); + return; + } + + props.onChange(nextValue === props.value ? null : nextValue); + setOpen(false); + }; + + const onClear = () => { + if (props.multiple) { + props.onChange([]); + } else { + props.onChange(null); + } + + setOpen(false); + }; + + return ( + + + + + + + + {enableSearch && } + + + + No results found. + + + + {options.map((option) => ( + onSelect(option.value)}> + + + {option.label} + + {option.trailing !== undefined && ( + {option.trailing} + )} + + ))} + + + {hasSelection && ( + <> + + + + Clear + + + + )} + + + + + ); +}; diff --git a/apps/remix/app/components/tables/documents-table-period-filter.tsx b/apps/remix/app/components/tables/documents-table-period-filter.tsx new file mode 100644 index 000000000..b051b41fa --- /dev/null +++ b/apps/remix/app/components/tables/documents-table-period-filter.tsx @@ -0,0 +1,40 @@ +import { Trans } from '@lingui/react/macro'; +import { CalendarIcon } from 'lucide-react'; +import { useQueryStates } from 'nuqs'; + +import { FilterPill } from '~/components/general/filter-pill'; +import { DOCUMENTS_PERIOD_VALUES, documentsSearchParams } from '~/utils/documents-search-params'; + +const PERIOD_OPTIONS = [ + { value: '7d', label: Last 7 days }, + { value: '14d', label: Last 14 days }, + { value: '30d', label: Last 30 days }, +]; + +export const DocumentsTablePeriodFilter = () => { + const [{ period }, setSearchParams] = useQueryStates( + { + period: documentsSearchParams.period, + page: documentsSearchParams.page, + }, + { history: 'push' }, + ); + + const onChange = (newPeriod: string | null) => { + void setSearchParams({ + period: DOCUMENTS_PERIOD_VALUES.find((value) => value === newPeriod) ?? null, + page: null, + }); + }; + + return ( + Period} + value={period} + onChange={onChange} + options={PERIOD_OPTIONS} + testId="documents-table-period-filter" + /> + ); +}; diff --git a/apps/remix/app/components/tables/documents-table-sender-filter.tsx b/apps/remix/app/components/tables/documents-table-sender-filter.tsx index c4c2bbd4a..1d398fb02 100644 --- a/apps/remix/app/components/tables/documents-table-sender-filter.tsx +++ b/apps/remix/app/components/tables/documents-table-sender-filter.tsx @@ -1,63 +1,61 @@ import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted'; import { trpc } from '@documenso/trpc/react'; -import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox'; import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import { useLocation, useNavigate, useSearchParams } from 'react-router'; +import { UserIcon } from 'lucide-react'; +import { useQueryStates } from 'nuqs'; + +import { FilterPill } from '~/components/general/filter-pill'; +import { documentsSearchParams } from '~/utils/documents-search-params'; type DocumentsTableSenderFilterProps = { teamId: number; }; export const DocumentsTableSenderFilter = ({ teamId }: DocumentsTableSenderFilterProps) => { - const { pathname } = useLocation(); - const [searchParams] = useSearchParams(); - const navigate = useNavigate(); + const { _ } = useLingui(); const isMounted = useIsMounted(); - const senderIds = (searchParams?.get('senderIds') ?? '').split(',').filter((value) => value !== ''); + const [{ senderIds }, setSearchParams] = useQueryStates( + { + senderIds: documentsSearchParams.senderIds, + page: documentsSearchParams.page, + }, + { history: 'push' }, + ); + + const selectedSenderIds = (senderIds ?? []).map((senderId) => senderId.toString()); const { data, isLoading } = trpc.team.member.getMany.useQuery({ teamId, }); - const comboBoxOptions = (data ?? []).map((member) => ({ + const options = (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 }); + void setSearchParams({ + senderIds: newSenderIds.length > 0 ? newSenderIds.map(Number) : null, + page: null, + }); }; return ( - - - Sender: All - -

- } - enableClearAllButton={true} - inputPlaceholder={msg`Search`} - loading={!isMounted || isLoading} - options={comboBoxOptions} - selectedValues={senderIds} + Sender} + value={selectedSenderIds} onChange={onChange} + options={options} + enableSearch + searchPlaceholder={_(msg`Search members...`)} + loading={!isMounted || isLoading} + testId="documents-table-sender-filter" /> ); }; diff --git a/apps/remix/app/components/tables/documents-table-status-filter.tsx b/apps/remix/app/components/tables/documents-table-status-filter.tsx new file mode 100644 index 000000000..25abc1164 --- /dev/null +++ b/apps/remix/app/components/tables/documents-table-status-filter.tsx @@ -0,0 +1,98 @@ +import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +import { STATS_COUNT_CAP } from '@documenso/lib/constants/document'; +import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; +import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/find-documents-internal.types'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { OrganisationType } from '@prisma/client'; +import { ListFilterIcon } from 'lucide-react'; +import { useQueryStates } from 'nuqs'; +import { useMemo } from 'react'; + +import { DocumentStatus, FRIENDLY_STATUS_MAP } from '~/components/general/document/document-status'; +import { FilterPill } from '~/components/general/filter-pill'; +import { documentsSearchParams } from '~/utils/documents-search-params'; + +type DocumentsTableStatusFilterProps = { + stats: TFindDocumentsInternalResponse['stats']; +}; + +export const DocumentsTableStatusFilter = ({ stats }: DocumentsTableStatusFilterProps) => { + const { _ } = useLingui(); + + const organisation = useCurrentOrganisation(); + + const [{ status }, setSearchParams] = useQueryStates( + { + status: documentsSearchParams.status, + page: documentsSearchParams.page, + }, + { history: 'push' }, + ); + + const selectableStatuses = useMemo( + () => + SELECTABLE_STATUSES.filter((value) => { + if (organisation.type === OrganisationType.PERSONAL) { + return value !== ExtendedDocumentStatus.INBOX; + } + + return true; + }), + [organisation.type], + ); + + const selectedStatus = useMemo( + () => selectableStatuses.find((value) => value === status) ?? null, + [selectableStatuses, status], + ); + + const onChange = (newStatus: string | null) => { + void setSearchParams({ + status: selectableStatuses.find((value) => value === newStatus) ?? null, + page: null, + }); + }; + + return ( + <> + Status} + value={selectedStatus} + onChange={onChange} + selectedLabel={selectedStatus && } + options={selectableStatuses.map((value) => ({ + value, + label: , + trailing: formatStatsCount(stats[value]), + }))} + testId="documents-table-status-filter" + /> + + {/* Visually hidden document counts, for screen readers and tests. */} + + {[...selectableStatuses, ExtendedDocumentStatus.ALL].map((value) => ( + + {_(FRIENDLY_STATUS_MAP[value].label)}:{' '} + {stats[value]} + + ))} + + + ); +}; + +const SELECTABLE_STATUSES: ExtendedDocumentStatus[] = [ + ExtendedDocumentStatus.INBOX, + ExtendedDocumentStatus.PENDING, + ExtendedDocumentStatus.COMPLETED, + ExtendedDocumentStatus.CANCELLED, + ExtendedDocumentStatus.DRAFT, + ExtendedDocumentStatus.REJECTED, + ExtendedDocumentStatus.EXPIRED, +]; + +const formatStatsCount = (count: number) => { + return count >= STATS_COUNT_CAP ? `${STATS_COUNT_CAP.toLocaleString()}+` : count.toString(); +}; 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 37883b796..9f5015542 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx @@ -1,28 +1,20 @@ import { useSessionStorage } from '@documenso/lib/client-only/hooks/use-session-storage'; -import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; -import { STATS_COUNT_CAP } from '@documenso/lib/constants/document'; import { SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc'; 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 { Button } from '@documenso/ui/primitives/button'; import type { RowSelectionState } from '@documenso/ui/primitives/data-table'; -import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; import { msg } from '@lingui/core/macro'; import { Trans } from '@lingui/react/macro'; -import { - EnvelopeType, - FolderType, - OrganisationType, - type DocumentStatus as PrismaDocumentStatus, -} from '@prisma/client'; +import { EnvelopeType, FolderType, type DocumentStatus as PrismaDocumentStatus } from '@prisma/client'; +import { XIcon } from 'lucide-react'; +import { useQueryStates } from 'nuqs'; import { useEffect, useMemo, useState } from 'react'; -import { Link, useNavigate, useParams, useSearchParams } from 'react-router'; -import { z } from 'zod'; +import { useNavigate, useParams } from 'react-router'; import { EnvelopesBulkCancelDialog } from '~/components/dialogs/envelopes-bulk-cancel-dialog'; import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog'; @@ -32,15 +24,16 @@ import { } from '~/components/dialogs/envelopes-bulk-download-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 { 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 { DocumentsTablePeriodFilter } from '~/components/tables/documents-table-period-filter'; import { DocumentsTableSenderFilter } from '~/components/tables/documents-table-sender-filter'; +import { DocumentsTableStatusFilter } from '~/components/tables/documents-table-status-filter'; import { EnvelopesTableBulkActionBar } from '~/components/tables/envelopes-table-bulk-action-bar'; import { useCurrentTeam } from '~/providers/team'; +import { documentsSearchParams } from '~/utils/documents-search-params'; import { appMetaTags } from '~/utils/meta'; export function meta() { @@ -55,22 +48,10 @@ type EnvelopeMetaCache = Record ZSearchParamsSchema.safeParse(Object.fromEntries(searchParams.entries())).data || {}, - [searchParams], - ); + const [findDocumentSearchParams, setFindDocumentSearchParams] = useQueryStates(documentsSearchParams, { + history: 'push', + }); const { data, isLoading, isLoadingError } = trpc.document.findDocumentsInternal.useQuery( { - ...findDocumentSearchParams, + status: findDocumentSearchParams.status ?? undefined, + period: findDocumentSearchParams.period ?? undefined, + senderIds: findDocumentSearchParams.senderIds ?? undefined, + page: findDocumentSearchParams.page ?? undefined, + perPage: findDocumentSearchParams.perPage ?? undefined, + query: findDocumentSearchParams.query ?? undefined, folderId, }, { @@ -167,34 +152,21 @@ export default function DocumentsPage() { .filter((item): item is EnvelopeBulkDownloadItem => item !== null); }, [selectedEnvelopeIds, envelopeMetaCache]); - const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => { - const params = new URLSearchParams(searchParams); + const hasActiveFilters = useMemo(() => { + return Boolean( + (findDocumentSearchParams.status && findDocumentSearchParams.status !== ExtendedDocumentStatus.ALL) || + findDocumentSearchParams.senderIds?.length || + findDocumentSearchParams.period, + ); + }, [findDocumentSearchParams]); - params.set('status', value); - - if (value === ExtendedDocumentStatus.ALL) { - params.delete('status'); - } - - if (value === ExtendedDocumentStatus.INBOX && organisation.type === OrganisationType.PERSONAL) { - params.delete('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 onResetFilters = () => { + void setFindDocumentSearchParams({ + status: null, + senderIds: null, + period: null, + page: null, + }); }; useEffect(() => { @@ -208,69 +180,40 @@ export default function DocumentsPage() {
-
-
- - {team.avatarImageId && } - {team.name.slice(0, 1)} - +
+ + {team.avatarImageId && } + {team.name.slice(0, 1)} + -

- Documents -

+

+ Documents +

+
+ +
+
+
-
- - - {[ - ExtendedDocumentStatus.INBOX, - ExtendedDocumentStatus.PENDING, - ExtendedDocumentStatus.COMPLETED, - ExtendedDocumentStatus.CANCELLED, - ExtendedDocumentStatus.DRAFT, - ExtendedDocumentStatus.REJECTED, - ExtendedDocumentStatus.EXPIRED, - ExtendedDocumentStatus.ALL, - ] - .filter((value) => { - if (organisation.type === OrganisationType.PERSONAL) { - return value !== ExtendedDocumentStatus.INBOX; - } + - return true; - }) - .map((value) => ( - - - + {team && } - {value !== ExtendedDocumentStatus.ALL && ( - - {stats[value] >= STATS_COUNT_CAP ? `${STATS_COUNT_CAP.toLocaleString()}+` : stats[value]} - - )} - - - ))} - - + - {team && } - -
- -
-
- -
-
+ {hasActiveFilters && ( + + )}
{data && data.count === 0 ? ( - + ) : ( { @@ -207,11 +203,7 @@ test('[DOCUMENTS]: deleting pending documents should permanently remove it', asy await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible(); // Check document counts. - await checkDocumentTabCount(page, 'Inbox', 0); - await checkDocumentTabCount(page, 'Pending', 0); - await checkDocumentTabCount(page, 'Completed', 1); - await checkDocumentTabCount(page, 'Draft', 1); - await checkDocumentTabCount(page, 'All', 2); + await checkDocumentCounts(page, { inbox: 0, pending: 0, completed: 1, draft: 1, all: 2 }); }); test('[DOCUMENTS]: deleting completed documents as an owner should hide it from only the owner', async ({ page }) => { @@ -239,11 +231,7 @@ test('[DOCUMENTS]: deleting completed documents as an owner should hide it from // Check document counts. await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible(); - await checkDocumentTabCount(page, 'Inbox', 0); - await checkDocumentTabCount(page, 'Pending', 1); - await checkDocumentTabCount(page, 'Completed', 0); - await checkDocumentTabCount(page, 'Draft', 1); - await checkDocumentTabCount(page, 'All', 2); + await checkDocumentCounts(page, { inbox: 0, pending: 1, completed: 0, draft: 1, all: 2 }); // Sign into the recipient account. await apiSignout({ page }); @@ -255,11 +243,7 @@ test('[DOCUMENTS]: deleting completed documents as an owner should hide it from // Check document counts. await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).toBeVisible(); - await checkDocumentTabCount(page, 'Inbox', 1); - await checkDocumentTabCount(page, 'Pending', 0); - await checkDocumentTabCount(page, 'Completed', 1); - await checkDocumentTabCount(page, 'Draft', 0); - await checkDocumentTabCount(page, 'All', 2); + await checkDocumentCounts(page, { inbox: 1, pending: 0, completed: 1, draft: 0, all: 2 }); }); test('[DOCUMENTS]: deleting documents as a recipient should only hide it for them', async ({ page }) => { @@ -300,11 +284,7 @@ test('[DOCUMENTS]: deleting documents as a recipient should only hide it for the // Check document counts. await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible(); await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible(); - await checkDocumentTabCount(page, 'Inbox', 0); - await checkDocumentTabCount(page, 'Pending', 0); - await checkDocumentTabCount(page, 'Completed', 0); - await checkDocumentTabCount(page, 'Draft', 0); - await checkDocumentTabCount(page, 'All', 0); + await checkDocumentCounts(page, { inbox: 0, pending: 0, completed: 0, draft: 0, all: 0 }); // Sign into the sender account. await apiSignout({ page }); @@ -315,11 +295,7 @@ test('[DOCUMENTS]: deleting documents as a recipient should only hide it for the }); // Check document counts for sender. - await checkDocumentTabCount(page, 'Inbox', 0); - await checkDocumentTabCount(page, 'Pending', 1); - await checkDocumentTabCount(page, 'Completed', 1); - await checkDocumentTabCount(page, 'Draft', 1); - await checkDocumentTabCount(page, 'All', 3); + await checkDocumentCounts(page, { inbox: 0, pending: 1, completed: 1, draft: 1, all: 3 }); // Sign into the other recipient account. await apiSignout({ page }); @@ -330,9 +306,5 @@ test('[DOCUMENTS]: deleting documents as a recipient should only hide it for the }); // Check document counts for other recipient. - await checkDocumentTabCount(page, 'Inbox', 1); - await checkDocumentTabCount(page, 'Pending', 0); - await checkDocumentTabCount(page, 'Completed', 1); - await checkDocumentTabCount(page, 'Draft', 0); - await checkDocumentTabCount(page, 'All', 2); + await checkDocumentCounts(page, { inbox: 1, pending: 0, completed: 1, draft: 0, all: 2 }); }); diff --git a/packages/app-tests/e2e/documents/find-documents.spec.ts b/packages/app-tests/e2e/documents/find-documents.spec.ts index 960a09863..143c6225b 100644 --- a/packages/app-tests/e2e/documents/find-documents.spec.ts +++ b/packages/app-tests/e2e/documents/find-documents.spec.ts @@ -20,7 +20,7 @@ import { } from '@prisma/client'; import { apiSignin, apiSignout } from '../fixtures/authentication'; -import { checkDocumentTabCount } from '../fixtures/documents'; +import { checkDocumentCounts, checkDocumentTabCount, toggleDocumentSenderFilter } from '../fixtures/documents'; test.describe.configure({ mode: 'parallel', @@ -61,10 +61,7 @@ test.describe('Find Documents UI - Personal Context', () => { redirectPath: `/t/${team.url}/documents`, }); - await checkDocumentTabCount(page, 'All', 3); - await checkDocumentTabCount(page, 'Draft', 1); - await checkDocumentTabCount(page, 'Pending', 1); - await checkDocumentTabCount(page, 'Completed', 1); + await checkDocumentCounts(page, { draft: 1, pending: 1, completed: 1, all: 3 }); }); test('received documents from other teams should NOT appear in personal context', async ({ page }) => { @@ -140,10 +137,9 @@ test.describe('Find Documents UI - Personal Context', () => { redirectPath: `/t/${ownerTeam.url}/documents`, }); - // Inbox should be 0 since there's no team email and received docs are on sender's team - await checkDocumentTabCount(page, 'Inbox', 0); - // Owner's own doc should still show in All - await checkDocumentTabCount(page, 'All', 1); + // Inbox should be 0 since there's no team email and received docs are on sender's team. + // Owner's own doc should still show in All. + await checkDocumentCounts(page, { inbox: 0, all: 1 }); await expect(page.getByRole('link', { name: 'Owner Draft Control' })).toBeVisible(); }); @@ -707,9 +703,8 @@ test.describe('Find Documents UI - Team with Team Email', () => { redirectPath: `/t/${team.url}/documents`, }); - await checkDocumentTabCount(page, 'Inbox', 0); - // But pending should still show - await checkDocumentTabCount(page, 'Pending', 1); + // Inbox should be 0, but pending should still show. + await checkDocumentCounts(page, { inbox: 0, pending: 1 }); }); test('documents sent BY team email user should appear in team context', async ({ page }) => { @@ -810,12 +805,9 @@ test.describe('Find Documents UI - Data Isolation & No Leaking', () => { }); // UserA should see only their own docs - await checkDocumentTabCount(page, 'All', 3); - await checkDocumentTabCount(page, 'Draft', 1); - await checkDocumentTabCount(page, 'Completed', 1); + await checkDocumentCounts(page, { draft: 1, completed: 1, all: 3 }); // Verify no B docs leaked - await page.getByRole('tab', { name: 'All' }).click(); await expect(page.getByRole('link', { name: 'A Own Draft' })).toBeVisible(); await expect(page.getByRole('link', { name: 'B Draft Private', exact: true })).not.toBeVisible(); await expect(page.getByRole('link', { name: 'B Pending Private', exact: true })).not.toBeVisible(); @@ -966,9 +958,9 @@ test.describe('Find Documents UI - Data Isolation & No Leaking', () => { redirectPath: `/t/${outsideTeam.url}/documents`, }); - // Only the outside user's own draft should appear (cross-team docs are not visible) - await checkDocumentTabCount(page, 'Inbox', 0); // No team email → 0 - await checkDocumentTabCount(page, 'All', 1); // Check All tab last so we can verify visible links + // Only the outside user's own draft should appear (cross-team docs are not visible). + // Inbox is 0 since there is no team email. + await checkDocumentCounts(page, { inbox: 0, all: 1 }); await expect(page.getByRole('link', { name: 'Outside Own Draft' })).toBeVisible(); await expect(page.getByRole('link', { name: 'Team Doc For Outside User', exact: true })).not.toBeVisible(); await expect(page.getByRole('link', { name: 'Team Doc For Other User Only', exact: true })).not.toBeVisible(); @@ -1013,12 +1005,10 @@ test.describe('Find Documents UI - Tab Counts Consistency', () => { redirectPath: `/t/${ownerTeam.url}/documents`, }); - // Only owner's own docs appear (received docs are on sender's team) - await checkDocumentTabCount(page, 'Draft', 2); - await checkDocumentTabCount(page, 'Pending', 1); - await checkDocumentTabCount(page, 'Inbox', 0); // No team email → inbox returns null → 0 - await checkDocumentTabCount(page, 'Completed', 1); // Only owned completed (received is on sender's team) - await checkDocumentTabCount(page, 'All', 4); // 2 drafts + 1 pending + 1 completed + // Only owner's own docs appear (received docs are on sender's team). + // Inbox is 0 since there is no team email, and only the owned completed + // doc counts (received is on sender's team). All = 2 drafts + 1 pending + 1 completed. + await checkDocumentCounts(page, { inbox: 0, draft: 2, pending: 1, completed: 1, all: 4 }); }); test('team context tab counts should be accurate with mixed documents', async ({ page }) => { @@ -1070,10 +1060,7 @@ test.describe('Find Documents UI - Tab Counts Consistency', () => { redirectPath: `/t/${team.url}/documents`, }); - await checkDocumentTabCount(page, 'Draft', 2); - await checkDocumentTabCount(page, 'Pending', 1); - await checkDocumentTabCount(page, 'Completed', 1); - await checkDocumentTabCount(page, 'All', 4); + await checkDocumentCounts(page, { draft: 2, pending: 1, completed: 1, all: 4 }); }); test('team with team email tab counts should include received documents', async ({ page }) => { @@ -1107,11 +1094,9 @@ test.describe('Find Documents UI - Tab Counts Consistency', () => { redirectPath: `/t/${team.url}/documents`, }); - await checkDocumentTabCount(page, 'Draft', 1); - await checkDocumentTabCount(page, 'Inbox', 1); // One pending doc received by team email (NOT_SIGNED) - await checkDocumentTabCount(page, 'Pending', 1); // Own pending - await checkDocumentTabCount(page, 'Completed', 1); // Received completed via email - await checkDocumentTabCount(page, 'All', 4); // All of the above + // Inbox = one pending doc received by team email (NOT_SIGNED), pending = own + // pending, completed = received completed via email, all = all of the above. + await checkDocumentCounts(page, { inbox: 1, draft: 1, pending: 1, completed: 1, all: 4 }); }); }); @@ -1163,9 +1148,7 @@ test.describe('Find Documents UI - Sender Filter', () => { await checkDocumentTabCount(page, 'All', 3); // Filter by member1 - await page.locator('button').filter({ hasText: 'Sender: All' }).click(); - await page.getByRole('option', { name: member1.name ?? '' }).click(); - await page.waitForURL(/senderIds/); + await toggleDocumentSenderFilter(page, member1.name ?? ''); // Should only show member1's doc await checkDocumentTabCount(page, 'All', 1); diff --git a/packages/app-tests/e2e/fixtures/documents.ts b/packages/app-tests/e2e/fixtures/documents.ts index 160dc1030..fbc49241a 100644 --- a/packages/app-tests/e2e/fixtures/documents.ts +++ b/packages/app-tests/e2e/fixtures/documents.ts @@ -1,11 +1,116 @@ 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(); +type DocumentStatusCounts = { + inbox?: number; + pending?: number; + completed?: number; + draft?: number; + cancelled?: number; + rejected?: number; + expired?: number; + all?: number; +}; - if (tabName !== 'All') { - await expect(page.getByRole('tab', { name: tabName })).toContainText(count.toString()); +const STATUS_KEYS = { + inbox: 'INBOX', + pending: 'PENDING', + completed: 'COMPLETED', + draft: 'DRAFT', + cancelled: 'CANCELLED', + rejected: 'REJECTED', + expired: 'EXPIRED', + all: 'ALL', +} as const; + +/** + * Check the counts for multiple document statuses in one go via the + * visually hidden stats rendered alongside the status filter. + * + * When `all` is provided the status filter is also cleared and the + * unfiltered table count (or empty state) is verified. + */ +export const checkDocumentCounts = async (page: Page, counts: DocumentStatusCounts) => { + for (const [key, status] of Object.entries(STATUS_KEYS)) { + const count = counts[key as keyof typeof STATUS_KEYS]; + + if (count === undefined) { + continue; + } + + await expect(page.getByTestId(`documents-status-count-${status}`)).toHaveText(count.toString()); + } + + if (counts.all !== undefined) { + await clearDocumentStatusFilter(page); + + if (counts.all === 0) { + await expect(page.getByTestId('empty-document-state')).toBeVisible(); + return; + } + + await expect(page.getByTestId('data-table-count')).toContainText(`Showing ${counts.all}`); + } +}; + +/** + * Select a status in the documents status filter pill. + * + * No-op if the status is already selected, since selecting the active + * option again would clear the filter. + */ +export const selectDocumentStatusFilter = async (page: Page, statusName: string) => { + const currentStatus = new URL(page.url()).searchParams.get('status'); + + if (currentStatus === statusName.toUpperCase()) { + return; + } + + await page.getByTestId('documents-table-status-filter').click(); + await page.getByRole('option', { name: statusName }).click(); +}; + +/** + * Toggle a sender in the documents sender filter pill. + * + * The sender filter is a multi select, so the popover stays open after + * picking and is closed with Escape. + */ +export const toggleDocumentSenderFilter = async (page: Page, senderName: string) => { + await page.getByTestId('documents-table-sender-filter').click(); + await page.getByRole('option', { name: senderName }).click(); + await page.waitForURL(/senderIds/); + await page.keyboard.press('Escape'); +}; + +/** + * Clear the documents status filter pill, returning to the "All" view. + */ +export const clearDocumentStatusFilter = async (page: Page) => { + const currentStatus = new URL(page.url()).searchParams.get('status'); + + if (!currentStatus) { + return; + } + + await page.getByTestId('documents-table-status-filter').click(); + await page.getByRole('option', { name: 'Clear' }).click(); +}; + +/** + * Apply a status filter (or 'All' to clear it) and verify both the hidden + * stats count and the resulting table. + * + * The count is not asserted against the stats for 'All', since tests use it + * with search queries applied which only the table respects. + */ +export const checkDocumentTabCount = async (page: Page, tabName: string, count: number) => { + if (tabName === 'All') { + await clearDocumentStatusFilter(page); + } else { + await expect(page.getByTestId(`documents-status-count-${tabName.toUpperCase()}`)).toHaveText(count.toString()); + + await selectDocumentStatusFilter(page, tabName); } if (count === 0) { diff --git a/packages/app-tests/e2e/teams/team-documents.spec.ts b/packages/app-tests/e2e/teams/team-documents.spec.ts index 6d5f2ca08..c8a7df77e 100644 --- a/packages/app-tests/e2e/teams/team-documents.spec.ts +++ b/packages/app-tests/e2e/teams/team-documents.spec.ts @@ -5,7 +5,7 @@ import { expect, test } from '@playwright/test'; import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@prisma/client'; import { apiSignin, apiSignout } from '../fixtures/authentication'; -import { checkDocumentTabCount } from '../fixtures/documents'; +import { checkDocumentCounts, checkDocumentTabCount, toggleDocumentSenderFilter } from '../fixtures/documents'; import { expectTextToBeVisible, expectToastTextToBeVisible, openDropdownMenu } from '../fixtures/generic'; test('[TEAMS]: check team documents count', async ({ page }) => { @@ -20,23 +20,13 @@ test('[TEAMS]: check team documents count', async ({ page }) => { }); // Check document counts. - await checkDocumentTabCount(page, 'Inbox', 0); - await checkDocumentTabCount(page, 'Pending', 2); - await checkDocumentTabCount(page, 'Completed', 1); - await checkDocumentTabCount(page, 'Draft', 2); - await checkDocumentTabCount(page, 'All', 5); + await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 1, draft: 2, all: 5 }); // Apply filter. - await page.locator('button').filter({ hasText: 'Sender: All' }).click(); - await page.getByRole('option', { name: teamMember2.name ?? '' }).click(); - await page.waitForURL(/senderIds/); + await toggleDocumentSenderFilter(page, teamMember2.name ?? ''); // Check counts after filtering. - await checkDocumentTabCount(page, 'Inbox', 0); - await checkDocumentTabCount(page, 'Pending', 2); - await checkDocumentTabCount(page, 'Completed', 0); - await checkDocumentTabCount(page, 'Draft', 1); - await checkDocumentTabCount(page, 'All', 3); + await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 0, draft: 1, all: 3 }); await apiSignout({ page }); } @@ -115,23 +105,13 @@ test('[TEAMS]: check team documents count with internal team email', async ({ pa }); // Check document counts. - await checkDocumentTabCount(page, 'Inbox', 2); - await checkDocumentTabCount(page, 'Pending', 3); - await checkDocumentTabCount(page, 'Completed', 3); - await checkDocumentTabCount(page, 'Draft', 3); - await checkDocumentTabCount(page, 'All', 11); + await checkDocumentCounts(page, { inbox: 2, pending: 3, completed: 3, draft: 3, all: 11 }); // Apply filter. - await page.locator('button').filter({ hasText: 'Sender: All' }).click(); - await page.getByRole('option', { name: teamMember2.name ?? '' }).click(); - await page.waitForURL(/senderIds/); + await toggleDocumentSenderFilter(page, teamMember2.name ?? ''); // Check counts after filtering. - await checkDocumentTabCount(page, 'Inbox', 0); - await checkDocumentTabCount(page, 'Pending', 2); - await checkDocumentTabCount(page, 'Completed', 0); - await checkDocumentTabCount(page, 'Draft', 1); - await checkDocumentTabCount(page, 'All', 3); + await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 0, draft: 1, all: 3 }); await apiSignout({ page }); } @@ -202,23 +182,13 @@ test('[TEAMS]: check team documents count with external team email', async ({ pa }); // Check document counts. - await checkDocumentTabCount(page, 'Inbox', 3); - await checkDocumentTabCount(page, 'Pending', 2); - await checkDocumentTabCount(page, 'Completed', 2); - await checkDocumentTabCount(page, 'Draft', 2); - await checkDocumentTabCount(page, 'All', 9); + await checkDocumentCounts(page, { inbox: 3, pending: 2, completed: 2, draft: 2, all: 9 }); // Apply filter. - await page.locator('button').filter({ hasText: 'Sender: All' }).click(); - await page.getByRole('option', { name: teamMember2.name ?? '' }).click(); - await page.waitForURL(/senderIds/); + await toggleDocumentSenderFilter(page, teamMember2.name ?? ''); // Check counts after filtering. - await checkDocumentTabCount(page, 'Inbox', 0); - await checkDocumentTabCount(page, 'Pending', 2); - await checkDocumentTabCount(page, 'Completed', 0); - await checkDocumentTabCount(page, 'Draft', 1); - await checkDocumentTabCount(page, 'All', 3); + await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 0, draft: 1, all: 3 }); }); test('[TEAMS]: resend pending team document', async ({ page }) => { @@ -273,11 +243,7 @@ test('[TEAMS]: delete draft team document', async ({ page }) => { }); // Check document counts. - await checkDocumentTabCount(page, 'Inbox', 0); - await checkDocumentTabCount(page, 'Pending', 2); - await checkDocumentTabCount(page, 'Completed', 1); - await checkDocumentTabCount(page, 'Draft', 1); - await checkDocumentTabCount(page, 'All', 4); + await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 1, draft: 1, all: 4 }); await apiSignout({ page }); } @@ -316,11 +282,7 @@ test('[TEAMS]: delete pending team document', async ({ page }) => { }); // Check document counts. - await checkDocumentTabCount(page, 'Inbox', 0); - await checkDocumentTabCount(page, 'Pending', 1); - await checkDocumentTabCount(page, 'Completed', 1); - await checkDocumentTabCount(page, 'Draft', 2); - await checkDocumentTabCount(page, 'All', 4); + await checkDocumentCounts(page, { inbox: 0, pending: 1, completed: 1, draft: 2, all: 4 }); await apiSignout({ page }); } @@ -359,11 +321,7 @@ test('[TEAMS]: delete completed team document', async ({ page }) => { }); // Check document counts. - await checkDocumentTabCount(page, 'Inbox', 0); - await checkDocumentTabCount(page, 'Pending', 2); - await checkDocumentTabCount(page, 'Completed', 0); - await checkDocumentTabCount(page, 'Draft', 2); - await checkDocumentTabCount(page, 'All', 4); + await checkDocumentCounts(page, { inbox: 0, pending: 2, completed: 0, draft: 2, all: 4 }); await apiSignout({ page }); }