mirror of
https://github.com/documenso/documenso.git
synced 2026-10-04 02:33:54 +10:00
feat(inbox): add partially approved status filter
This commit is contained in:
@@ -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 />
|
||||
|
||||
@@ -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'] },
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user