feat: replace document status tabs with filter pills (#3145)

Swaps the tab row and dropdowns for faceted filter pills (status,
sender, period) with a shared reset, and moves URL param handling
to nuqs.

<img width="2198" height="1674" alt="image"
src="https://github.com/user-attachments/assets/6996431c-09c8-45c3-bc30-f0a1e503c941"
/>
This commit is contained in:
Lucas Smith
2026-08-03 22:52:11 +10:00
committed by GitHub
parent b3c609a549
commit 9c27ce6d18
12 changed files with 592 additions and 307 deletions
@@ -7,7 +7,7 @@ import { expect, type Page, test } from '@playwright/test';
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import { checkDocumentTabCount } from '../fixtures/documents';
import { checkDocumentCounts, selectDocumentStatusFilter } from '../fixtures/documents';
import { expectToastTextToBeVisible, openDropdownMenu } from '../fixtures/generic';
test.describe.configure({ mode: 'serial' });
@@ -61,13 +61,10 @@ test('[DOCUMENTS]: cancelling a pending document keeps it in the owner dashboard
await expectToastTextToBeVisible(page, 'Document cancelled');
// The document must remain in the dashboard, unlike deleting a pending document.
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 0);
await checkDocumentTabCount(page, 'Cancelled', 1);
await checkDocumentTabCount(page, 'All', 1);
await checkDocumentCounts(page, { inbox: 0, pending: 0, cancelled: 1, all: 1 });
// The cancelled document is still listed.
await page.getByRole('tab', { name: 'Cancelled' }).click();
await selectDocumentStatusFilter(page, 'Cancelled');
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
// The envelope status is persisted as CANCELLED.
@@ -131,7 +128,7 @@ test('[DOCUMENTS]: a cancelled document can be deleted, hiding it from the owner
await expectToastTextToBeVisible(page, 'Document cancelled');
// Delete the now-cancelled document. Being terminal, it should soft delete (hide).
await page.getByRole('tab', { name: 'Cancelled' }).click();
await selectDocumentStatusFilter(page, 'Cancelled');
const documentActionBtn = page
.locator('tr', { hasText: 'Document 1 - Pending' })
@@ -3,7 +3,7 @@ import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import { checkDocumentTabCount } from '../fixtures/documents';
import { checkDocumentCounts } from '../fixtures/documents';
import { expectToastTextToBeVisible, openDropdownMenu } from '../fixtures/generic';
test.describe.configure({ mode: 'serial' });
@@ -174,11 +174,7 @@ test('[DOCUMENTS]: deleting draft documents should permanently remove it', async
await expect(page.getByRole('row', { name: /Document 1 - Draft/ })).not.toBeVisible();
// Check document counts.
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 1);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'Draft', 0);
await checkDocumentTabCount(page, 'All', 2);
await checkDocumentCounts(page, { inbox: 0, pending: 1, completed: 1, draft: 0, all: 2 });
});
test('[DOCUMENTS]: deleting pending documents should permanently remove it', async ({ page }) => {
@@ -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 });
});
@@ -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);
+109 -4
View File
@@ -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) {
@@ -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 });
}