Compare commits

...
Author SHA1 Message Date
ephraimduncan 2be3964d84 fix(inbox): show partially approved only when the user signed
A recipient rejection sets the recipient to REJECTED before the seal job sets the
document to REJECTED. During this time, the inbox showed the document as partially
approved. At this time, a pending document is partially approved only when the user
signed each non-CC recipient.
2026-09-24 13:29:44 +00:00
ephraimduncan c3483aeaa8 chore(inbox): remove unneeded comments 2026-09-24 12:17:00 +00:00
ephraimduncan 0823c3328f refactor(inbox): move partially approved into extended document status 2026-09-24 12:11:01 +00:00
ephraimduncan f2f7ba2878 feat(inbox): add partially approved status filter 2026-09-24 12:02:06 +00:00
13 changed files with 159 additions and 58 deletions
@@ -4,7 +4,7 @@ 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';
@@ -22,6 +22,12 @@ export const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus>
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`,
@@ -1,7 +1,6 @@
import { useOptionalCurrentOrganisation } 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';
@@ -15,16 +14,16 @@ import { documentsSearchParams } from '~/utils/documents-search-params';
type DocumentsTableStatusFilterProps = {
/**
* Per-status document counts, shown next to each option. When omitted no
* counts are rendered.
* Per-status document counts, shown next to each option. When omitted, or
* when a status has no count, no count is rendered.
*/
stats?: TFindDocumentsInternalResponse['stats'];
stats?: Partial<Record<ExtendedDocumentStatus, number>>;
/**
* The statuses available for selection. Defaults to every status that
* makes sense for the documents page.
*/
statuses?: ExtendedDocumentStatus[];
statuses?: readonly ExtendedDocumentStatus[];
};
export const DocumentsTableStatusFilter = ({
@@ -78,7 +77,7 @@ export const DocumentsTableStatusFilter = ({
options={selectableStatuses.map((value) => ({
value,
label: <DocumentStatus status={value} />,
trailing: stats ? formatStatsCount(stats[value]) : undefined,
trailing: formatStatsCount(stats?.[value]),
}))}
testId="documents-table-status-filter"
/>
@@ -108,6 +107,10 @@ const SELECTABLE_STATUSES: ExtendedDocumentStatus[] = [
ExtendedDocumentStatus.EXPIRED,
];
const formatStatsCount = (count: number) => {
const formatStatsCount = (count: number | undefined) => {
if (count === undefined) {
return undefined;
}
return count >= STATS_COUNT_CAP ? `${STATS_COUNT_CAP.toLocaleString()}+` : count.toString();
};
@@ -1,6 +1,7 @@
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { trpc } from '@documenso/trpc/react';
import type { TFindInboxResponse } from '@documenso/trpc/server/document-router/find-inbox.types';
import { Button } from '@documenso/ui/primitives/button';
@@ -22,7 +23,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 +33,7 @@ type DocumentsTableRow = TFindInboxResponse['data'][number];
export const InboxTable = () => {
const { _, i18n } = useLingui();
const { user } = useSession();
const team = useOptionalCurrentTeam();
const [isPending, startTransition] = useTransition();
@@ -41,7 +43,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 +83,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 +91,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 +132,9 @@ export const InboxTable = () => {
.with({ status: DocumentStatusEnum.COMPLETED }, () => (
<Trans>Documents that you have completed will appear here</Trans>
))
.with({ status: ExtendedDocumentStatus.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 +264,16 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
))
.otherwise(() => <div></div>);
};
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.SIGNED,
);
return isWaitingOnOthers ? ExtendedDocumentStatus.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,30 @@ test.describe('Inbox Find - Status Filter Hardening', () => {
createDocumentOptions: { title: 'Scoped Rejected Document', status: DocumentStatus.REJECTED },
});
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 },
});
// The recipient has rejected, but the seal job has not yet moved the document to REJECTED.
const rejecting = await seedPendingDocument(sender, senderTeam.id, [recipient, otherRecipient], {
createDocumentOptions: { title: 'Scoped Rejecting Document' },
});
await prisma.recipient.updateMany({
where: { envelopeId: rejecting.id, email: recipient.email },
data: { signingStatus: SigningStatus.REJECTED },
});
await apiSignin({ page, email: recipient.email });
const expectations = [
{ status: 'PENDING', expected: ['Scoped Pending Document'] },
{ status: 'PENDING', expected: ['Scoped Rejecting Document', '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'] },
+38 -1
View File
@@ -8,9 +8,10 @@ import {
} from '@documenso/prisma/seed/documents';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
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 +38,7 @@ const searchInbox = async (page: Page, query: string) => {
// spelling "Canceled" for the `Cancelled` source string.
const INBOX_STATUS_LABELS = {
[DocumentStatus.PENDING]: 'Pending',
[ExtendedDocumentStatus.PARTIALLY_APPROVED]: 'Partially Approved',
[DocumentStatus.COMPLETED]: 'Completed',
[DocumentStatus.REJECTED]: 'Rejected',
[DocumentStatus.CANCELLED]: 'Canceled',
@@ -125,6 +127,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, ExtendedDocumentStatus.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();
@@ -13,7 +13,7 @@ export const getDocumentStats = async () => {
},
});
const stats: Record<Exclude<ExtendedDocumentStatus, 'INBOX' | 'EXPIRED'>, number> = {
const stats: Record<Exclude<ExtendedDocumentStatus, 'INBOX' | 'EXPIRED' | 'PARTIALLY_APPROVED'>, number> = {
[ExtendedDocumentStatus.DRAFT]: 0,
[ExtendedDocumentStatus.PENDING]: 0,
[ExtendedDocumentStatus.COMPLETED]: 0,
@@ -326,6 +326,7 @@ export const findDocuments = async ({
]),
),
)
.with(ExtendedDocumentStatus.PARTIALLY_APPROVED, () => null) // Only the inbox offers this status.
.exhaustive();
};
@@ -488,6 +489,7 @@ export const findDocuments = async ({
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), hasExpiredRecipient(eb), eb.or(accessBranches)]);
}),
)
.with(ExtendedDocumentStatus.PARTIALLY_APPROVED, () => null) // Only the inbox offers this status.
.exhaustive();
};
@@ -301,7 +301,7 @@ export const getStats = async ({ userId, teamId, period, search = '', folderId,
// `expired` is intentionally excluded from `all` — it overlaps PENDING.
const all = Math.min(draft + pending + completed + rejected + cancelled + inbox, STATS_COUNT_CAP);
const stats: Record<ExtendedDocumentStatus, number> = {
const stats: Record<Exclude<ExtendedDocumentStatus, 'PARTIALLY_APPROVED'>, number> = {
[ExtendedDocumentStatus.DRAFT]: draft,
[ExtendedDocumentStatus.PENDING]: pending,
[ExtendedDocumentStatus.COMPLETED]: completed,
@@ -5,6 +5,7 @@ export const ExtendedDocumentStatus = {
INBOX: 'INBOX',
ALL: 'ALL',
EXPIRED: 'EXPIRED',
PARTIALLY_APPROVED: 'PARTIALLY_APPROVED',
} as const;
export type ExtendedDocumentStatus = (typeof ExtendedDocumentStatus)[keyof typeof ExtendedDocumentStatus];
@@ -1,8 +1,10 @@
import type { FindResultResponse } from '@documenso/lib/types/search-params';
import { mapEnvelopesToDocumentMany } from '@documenso/lib/utils/document';
import { prisma } from '@documenso/prisma';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
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 +65,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: { not: SigningStatus.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(ExtendedDocumentStatus.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) {
@@ -2,7 +2,7 @@
import { ZDocumentManySchema } from '@documenso/lib/types/document';
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
import { DocumentStatus } from '@prisma/client';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { z } from 'zod';
/**
@@ -10,12 +10,17 @@ 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,
DocumentStatus.COMPLETED,
DocumentStatus.REJECTED,
DocumentStatus.CANCELLED,
ExtendedDocumentStatus.PENDING,
ExtendedDocumentStatus.PARTIALLY_APPROVED,
ExtendedDocumentStatus.COMPLETED,
ExtendedDocumentStatus.REJECTED,
ExtendedDocumentStatus.CANCELLED,
] as const;
export const ZInboxStatusSchema = z.enum(INBOX_STATUSES);