feat(inbox): add partially approved status filter

This commit is contained in:
ephraimduncan
2026-09-24 12:02:06 +00:00
parent 39ae85483a
commit f2f7ba2878
9 changed files with 142 additions and 50 deletions
@@ -1,10 +1,11 @@
import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import type { TInboxStatus } from '@documenso/trpc/server/document-router/find-inbox.types';
import { SignatureIcon } from '@documenso/ui/icons/signature';
import { cn } from '@documenso/ui/lib/utils';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { CheckCircle2, Clock, File, TimerOff, XCircle } from 'lucide-react';
import { CheckCircle2, Clock, ClockCheck, File, TimerOff, XCircle } from 'lucide-react';
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
import type { HTMLAttributes } from 'react';
@@ -15,13 +16,19 @@ type FriendlyStatus = {
color: string;
};
export const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus> = {
export const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus | TInboxStatus, FriendlyStatus> = {
PENDING: {
label: msg`Pending`,
labelExtended: msg`Document pending`,
icon: Clock,
color: 'text-blue-600 dark:text-blue-300',
},
PARTIALLY_APPROVED: {
label: msg`Partially Approved`,
labelExtended: msg`Document partially approved`,
icon: ClockCheck,
color: 'text-blue-600 dark:text-blue-300',
},
COMPLETED: {
label: msg`Completed`,
labelExtended: msg`Document completed`,
@@ -66,7 +73,7 @@ export const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus>
};
export type DocumentStatusProps = HTMLAttributes<HTMLSpanElement> & {
status: ExtendedDocumentStatus;
status: ExtendedDocumentStatus | TInboxStatus;
inheritColor?: boolean;
};
@@ -1,14 +1,16 @@
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { STATS_COUNT_CAP } from '@documenso/lib/constants/document';
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
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 { parseAsString, useQueryStates } from 'nuqs';
import { useMemo } from 'react';
import type { DocumentStatusProps } from '~/components/general/document/document-status';
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';
@@ -24,7 +26,7 @@ type DocumentsTableStatusFilterProps = {
* The statuses available for selection. Defaults to every status that
* makes sense for the documents page.
*/
statuses?: ExtendedDocumentStatus[];
statuses?: readonly DocumentStatusProps['status'][];
};
export const DocumentsTableStatusFilter = ({
@@ -35,9 +37,11 @@ export const DocumentsTableStatusFilter = ({
const organisation = useOptionalCurrentOrganisation();
// Any string is read here since each page offers its own statuses, which
// are matched against the selectable statuses below.
const [{ status }, setSearchParams] = useQueryStates(
{
status: documentsSearchParams.status,
status: parseAsString,
page: documentsSearchParams.page,
},
{ history: 'push' },
@@ -78,7 +82,7 @@ export const DocumentsTableStatusFilter = ({
options={selectableStatuses.map((value) => ({
value,
label: <DocumentStatus status={value} />,
trailing: stats ? formatStatsCount(stats[value]) : undefined,
trailing: stats && isExtendedDocumentStatus(value) ? formatStatsCount(stats[value]) : undefined,
}))}
testId="documents-table-status-filter"
/>
@@ -86,7 +90,7 @@ export const DocumentsTableStatusFilter = ({
{/* Visually hidden document counts, for screen readers and tests. */}
{stats && (
<span className="sr-only" data-testid="documents-status-counts">
{[...selectableStatuses, ExtendedDocumentStatus.ALL].map((value) => (
{[...selectableStatuses, ExtendedDocumentStatus.ALL].filter(isExtendedDocumentStatus).map((value) => (
<span key={value}>
{_(FRIENDLY_STATUS_MAP[value].label)}:{' '}
<span data-testid={`documents-status-count-${value}`}>{stats[value]}</span>
@@ -22,7 +22,7 @@ import { match } from 'ts-pattern';
import { DocumentStatus } from '~/components/general/document/document-status';
import { useOptionalCurrentTeam } from '~/providers/team';
import { inboxSearchParams, resolveInboxStatus } from '~/utils/inbox-search-params';
import { inboxSearchParams } from '~/utils/inbox-search-params';
import { EnvelopeDownloadDialog } from '../dialogs/envelope-download-dialog';
import { StackAvatarsWithTooltip } from '../general/stack-avatars-with-tooltip';
@@ -32,6 +32,7 @@ type DocumentsTableRow = TFindInboxResponse['data'][number];
export const InboxTable = () => {
const { _, i18n } = useLingui();
const { user } = useSession();
const team = useOptionalCurrentTeam();
const [isPending, startTransition] = useTransition();
@@ -41,7 +42,7 @@ export const InboxTable = () => {
history: 'push',
});
const status = resolveInboxStatus(findInboxSearchParams.status);
const status = findInboxSearchParams.status ?? undefined;
const query = findInboxSearchParams.query ?? '';
const { data, isLoading, isLoadingError } = trpc.document.inbox.find.useQuery({
@@ -81,7 +82,7 @@ export const InboxTable = () => {
{
header: _(msg`Status`),
accessorKey: 'status',
cell: ({ row }) => <DocumentStatus status={row.original.status} />,
cell: ({ row }) => <DocumentStatus status={getInboxStatus(row.original, user.email)} />,
size: 140,
},
{
@@ -89,7 +90,7 @@ export const InboxTable = () => {
cell: ({ row }) => <InboxTableActionButton row={row.original} />,
},
] satisfies DataTableColumnDef<DocumentsTableRow>[];
}, [team]);
}, [team, user.email]);
const onPaginationChange = (page: number, perPage: number) => {
startTransition(() => {
@@ -130,6 +131,9 @@ export const InboxTable = () => {
.with({ status: DocumentStatusEnum.COMPLETED }, () => (
<Trans>Documents that you have completed will appear here</Trans>
))
.with({ status: 'PARTIALLY_APPROVED' }, () => (
<Trans>Documents that are waiting on other recipients will appear here</Trans>
))
.with({ status: DocumentStatusEnum.REJECTED }, () => (
<Trans>Documents that have been rejected will appear here</Trans>
))
@@ -259,3 +263,20 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
))
.otherwise(() => <div></div>);
};
/**
* Shows a pending document as partially approved once the user has completed
* their part, which matches the inbox status filter.
*/
const getInboxStatus = (row: DocumentsTableRow, email: string) => {
const isWaitingOnOthers =
row.status === DocumentStatusEnum.PENDING &&
row.recipients.every(
(recipient) =>
recipient.email !== email ||
recipient.role === RecipientRole.CC ||
recipient.signingStatus !== SigningStatus.NOT_SIGNED,
);
return isWaitingOnOthers ? 'PARTIALLY_APPROVED' : row.status;
};
@@ -1,3 +1,4 @@
import { INBOX_STATUSES } from '@documenso/trpc/server/document-router/find-inbox.types';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { InboxIcon } from 'lucide-react';
@@ -6,7 +7,6 @@ import { DocumentSearch } from '~/components/general/document/document-search';
import { OrganisationInvitations } from '~/components/general/organisations/organisation-invitations';
import { DocumentsTableStatusFilter } from '~/components/tables/documents-table-status-filter';
import { InboxTable } from '~/components/tables/inbox-table';
import { INBOX_SELECTABLE_STATUSES } from '~/utils/inbox-search-params';
import { appMetaTags } from '~/utils/meta';
export function meta() {
@@ -34,7 +34,7 @@ export default function InboxPage() {
<DocumentSearch />
</div>
<DocumentsTableStatusFilter statuses={INBOX_SELECTABLE_STATUSES} />
<DocumentsTableStatusFilter statuses={INBOX_STATUSES} />
</div>
<InboxTable />
+6 -21
View File
@@ -1,33 +1,18 @@
import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { INBOX_STATUSES, type TInboxStatus } from '@documenso/trpc/server/document-router/find-inbox.types';
import { INBOX_STATUSES } from '@documenso/trpc/server/document-router/find-inbox.types';
import { parseAsStringLiteral } from 'nuqs';
import { documentsSearchParams } from './documents-search-params';
/**
* The statuses that can be selected from the inbox status filter.
*/
export const INBOX_SELECTABLE_STATUSES: ExtendedDocumentStatus[] = [...INBOX_STATUSES];
/**
* Shared nuqs parsers for the inbox page URL state.
*
* Reuses the documents parsers so the shared filter components
* (`DocumentSearch`, `DocumentsTableStatusFilter`) read and write the same
* params on both pages.
* Reuses the documents parsers so the shared `DocumentSearch` component reads
* and writes the same params on both pages. An unsupported `status` parses to
* `null`, which shows every non-draft document.
*/
export const inboxSearchParams = {
status: documentsSearchParams.status,
status: parseAsStringLiteral(INBOX_STATUSES),
page: documentsSearchParams.page,
perPage: documentsSearchParams.perPage,
query: documentsSearchParams.query,
};
/**
* Narrows the URL `status` param to a status supported by the inbox.
*
* Returns `undefined` when it is missing or not selectable, which shows every
* non-draft document.
*/
export const resolveInboxStatus = (status: ExtendedDocumentStatus | null): TInboxStatus | undefined => {
return INBOX_STATUSES.find((value) => value === status);
};
@@ -10,7 +10,7 @@ import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { DocumentStatus, EnvelopeType, RecipientRole, TeamMemberRole } from '@prisma/client';
import { DocumentStatus, EnvelopeType, RecipientRole, SigningStatus, TeamMemberRole } from '@prisma/client';
import { apiSignin, apiSignout } from '../../fixtures/authentication';
@@ -345,7 +345,7 @@ test.describe('Inbox Find - Status Filter Hardening', () => {
}
// Sanity check that the valid filters, and the unfiltered view, never include the draft.
for (const status of ['PENDING', 'COMPLETED', 'REJECTED', 'CANCELLED', undefined]) {
for (const status of ['PENDING', 'PARTIALLY_APPROVED', 'COMPLETED', 'REJECTED', 'CANCELLED', undefined]) {
const { res, data } = await trpcInboxFind(page, { status });
expect(res.ok(), `status "${status}" should be accepted`).toBeTruthy();
@@ -358,6 +358,7 @@ test.describe('Inbox Find - Status Filter Hardening', () => {
test('should scope each status filter to exactly that status', async ({ page }) => {
const { user: sender, team: senderTeam } = await seedUser();
const { user: recipient } = await seedUser();
const { user: otherRecipient } = await seedUser();
await seedPendingDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'Scoped Pending Document' },
@@ -375,10 +376,21 @@ test.describe('Inbox Find - Status Filter Hardening', () => {
createDocumentOptions: { title: 'Scoped Rejected Document', status: DocumentStatus.REJECTED },
});
// The recipient has signed, but the other recipient has not.
const partiallyApproved = await seedPendingDocument(sender, senderTeam.id, [recipient, otherRecipient], {
createDocumentOptions: { title: 'Scoped Partially Approved Document' },
});
await prisma.recipient.updateMany({
where: { envelopeId: partiallyApproved.id, email: recipient.email },
data: { signingStatus: SigningStatus.SIGNED },
});
await apiSignin({ page, email: recipient.email });
const expectations = [
{ status: 'PENDING', expected: ['Scoped Pending Document'] },
{ status: 'PARTIALLY_APPROVED', expected: ['Scoped Partially Approved Document'] },
{ status: 'COMPLETED', expected: ['Scoped Completed Document'] },
{ status: 'CANCELLED', expected: ['Scoped Cancelled Document'] },
{ status: 'REJECTED', expected: ['Scoped Rejected Document'] },
+37 -1
View File
@@ -10,7 +10,7 @@ import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { DocumentStatus, RecipientRole, TeamMemberRole } from '@prisma/client';
import { DocumentStatus, RecipientRole, SigningStatus, TeamMemberRole } from '@prisma/client';
import { apiSignin } from '../fixtures/authentication';
@@ -37,6 +37,7 @@ const searchInbox = async (page: Page, query: string) => {
// spelling "Canceled" for the `Cancelled` source string.
const INBOX_STATUS_LABELS = {
[DocumentStatus.PENDING]: 'Pending',
PARTIALLY_APPROVED: 'Partially Approved',
[DocumentStatus.COMPLETED]: 'Completed',
[DocumentStatus.REJECTED]: 'Rejected',
[DocumentStatus.CANCELLED]: 'Canceled',
@@ -125,6 +126,41 @@ test.describe('Inbox - Search & Status Filter', () => {
}
});
test('should show signed pending documents as partially approved', async ({ page }) => {
const { user: sender, team: senderTeam } = await seedUser();
const { user: recipient } = await seedUser();
const { user: otherRecipient } = await seedUser();
await seedPendingDocument(sender, senderTeam.id, [recipient, otherRecipient], {
createDocumentOptions: { title: 'Inbox Unsigned Document' },
});
const signed = await seedPendingDocument(sender, senderTeam.id, [recipient, otherRecipient], {
createDocumentOptions: { title: 'Inbox Signed Document' },
});
await prisma.recipient.updateMany({
where: { envelopeId: signed.id, email: recipient.email },
data: { signingStatus: SigningStatus.SIGNED },
});
await apiSignin({ page, email: recipient.email, redirectPath: '/inbox' });
await expect(inboxRow(page, 'Inbox Unsigned Document')).toContainText('Pending');
await expect(inboxRow(page, 'Inbox Signed Document')).toContainText('Partially Approved');
await selectInboxStatus(page, DocumentStatus.PENDING);
await expect(inboxRow(page, 'Inbox Unsigned Document')).toBeVisible();
await expect(inboxRow(page, 'Inbox Signed Document')).not.toBeVisible();
await selectInboxStatus(page, 'PARTIALLY_APPROVED');
await expect(page.getByTestId('documents-table-status-filter')).toContainText('Partially Approved');
await expect(inboxRow(page, 'Inbox Signed Document')).toBeVisible();
await expect(inboxRow(page, 'Inbox Unsigned Document')).not.toBeVisible();
});
test('should filter documents by title', async ({ page }) => {
const { user: sender, team: senderTeam } = await seedUser();
const { user: recipient } = await seedUser();
@@ -2,7 +2,8 @@ import type { FindResultResponse } from '@documenso/lib/types/search-params';
import { mapEnvelopesToDocumentMany } from '@documenso/lib/utils/document';
import { prisma } from '@documenso/prisma';
import type { Envelope, Prisma } from '@prisma/client';
import { DocumentStatus, EnvelopeType, RecipientRole } from '@prisma/client';
import { DocumentStatus, EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
import { match, P } from 'ts-pattern';
import { authenticatedProcedure } from '../trpc';
import { type TInboxStatus, ZFindInboxRequestSchema, ZFindInboxResponseSchema } from './find-inbox.types';
@@ -63,20 +64,41 @@ export const findInbox = async ({ userId, page = 1, perPage = 10, query = '', st
const orderByDirection = orderBy?.direction ?? 'desc';
const searchQuery = query.trim();
const userRecipient = {
email: user.email,
role: {
not: RecipientRole.CC,
},
} satisfies Prisma.RecipientWhereInput;
const unsignedUserRecipient = {
...userRecipient,
signingStatus: SigningStatus.NOT_SIGNED,
} satisfies Prisma.RecipientWhereInput;
const statusWhere = match(status)
.with(undefined, () => ({
status: { not: DocumentStatus.DRAFT },
recipients: { some: userRecipient },
}))
.with(DocumentStatus.PENDING, () => ({
status: DocumentStatus.PENDING,
recipients: { some: unsignedUserRecipient },
}))
.with('PARTIALLY_APPROVED', () => ({
status: DocumentStatus.PENDING,
recipients: { some: userRecipient, none: unsignedUserRecipient },
}))
.with(P.union(DocumentStatus.COMPLETED, DocumentStatus.REJECTED, DocumentStatus.CANCELLED), (value) => ({
status: value,
recipients: { some: userRecipient },
}))
.exhaustive() satisfies Prisma.EnvelopeWhereInput;
const whereClause: Prisma.EnvelopeWhereInput = {
type: EnvelopeType.DOCUMENT,
status: status ?? {
not: DocumentStatus.DRAFT,
},
deletedAt: null,
recipients: {
some: {
email: user.email,
role: {
not: RecipientRole.CC,
},
},
},
...statusWhere,
};
if (searchQuery.length > 0) {
@@ -10,9 +10,14 @@ import { z } from 'zod';
*
* Every document status except DRAFT, since drafts have not been sent to
* recipients yet and must never be visible in the inbox.
*
* PENDING only covers documents that still need the user to act.
* PARTIALLY_APPROVED covers pending documents that the user has completed and
* that wait on other recipients.
*/
export const INBOX_STATUSES = [
DocumentStatus.PENDING,
'PARTIALLY_APPROVED',
DocumentStatus.COMPLETED,
DocumentStatus.REJECTED,
DocumentStatus.CANCELLED,