mirror of
https://github.com/documenso/documenso.git
synced 2026-10-03 10:13:52 +10:00
feat: add inbox filters (#3372)
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
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';
|
||||
@@ -14,13 +14,26 @@ import { FilterPill } from '~/components/general/filter-pill';
|
||||
import { documentsSearchParams } from '~/utils/documents-search-params';
|
||||
|
||||
type DocumentsTableStatusFilterProps = {
|
||||
stats: TFindDocumentsInternalResponse['stats'];
|
||||
/**
|
||||
* Per-status document counts, shown next to each option. When omitted no
|
||||
* counts are rendered.
|
||||
*/
|
||||
stats?: TFindDocumentsInternalResponse['stats'];
|
||||
|
||||
/**
|
||||
* The statuses available for selection. Defaults to every status that
|
||||
* makes sense for the documents page.
|
||||
*/
|
||||
statuses?: ExtendedDocumentStatus[];
|
||||
};
|
||||
|
||||
export const DocumentsTableStatusFilter = ({ stats }: DocumentsTableStatusFilterProps) => {
|
||||
export const DocumentsTableStatusFilter = ({
|
||||
stats,
|
||||
statuses = SELECTABLE_STATUSES,
|
||||
}: DocumentsTableStatusFilterProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
const organisation = useOptionalCurrentOrganisation();
|
||||
|
||||
const [{ status }, setSearchParams] = useQueryStates(
|
||||
{
|
||||
@@ -32,14 +45,14 @@ export const DocumentsTableStatusFilter = ({ stats }: DocumentsTableStatusFilter
|
||||
|
||||
const selectableStatuses = useMemo(
|
||||
() =>
|
||||
SELECTABLE_STATUSES.filter((value) => {
|
||||
if (organisation.type === OrganisationType.PERSONAL) {
|
||||
statuses.filter((value) => {
|
||||
if (organisation?.type === OrganisationType.PERSONAL) {
|
||||
return value !== ExtendedDocumentStatus.INBOX;
|
||||
}
|
||||
|
||||
return true;
|
||||
}),
|
||||
[organisation.type],
|
||||
[organisation?.type, statuses],
|
||||
);
|
||||
|
||||
const selectedStatus = useMemo(
|
||||
@@ -65,20 +78,22 @@ export const DocumentsTableStatusFilter = ({ stats }: DocumentsTableStatusFilter
|
||||
options={selectableStatuses.map((value) => ({
|
||||
value,
|
||||
label: <DocumentStatus status={value} />,
|
||||
trailing: formatStatsCount(stats[value]),
|
||||
trailing: stats ? formatStatsCount(stats[value]) : undefined,
|
||||
}))}
|
||||
testId="documents-table-status-filter"
|
||||
/>
|
||||
|
||||
{/* Visually hidden document counts, for screen readers and tests. */}
|
||||
<span className="sr-only" data-testid="documents-status-counts">
|
||||
{[...selectableStatuses, ExtendedDocumentStatus.ALL].map((value) => (
|
||||
<span key={value}>
|
||||
{_(FRIENDLY_STATUS_MAP[value].label)}:{' '}
|
||||
<span data-testid={`documents-status-count-${value}`}>{stats[value]}</span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
{stats && (
|
||||
<span className="sr-only" data-testid="documents-status-counts">
|
||||
{[...selectableStatuses, ExtendedDocumentStatus.ALL].map((value) => (
|
||||
<span key={value}>
|
||||
{_(FRIENDLY_STATUS_MAP[value].label)}:{' '}
|
||||
<span data-testid={`documents-status-count-${value}`}>{stats[value]}</span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,22 +16,17 @@ import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentStatus as DocumentStatusEnum, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
import { CheckCircleIcon, DownloadIcon, EyeIcon, Loader, PencilIcon } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { useQueryStates } from 'nuqs';
|
||||
import { useMemo, useTransition } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
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 { EnvelopeDownloadDialog } from '../dialogs/envelope-download-dialog';
|
||||
import { StackAvatarsWithTooltip } from '../general/stack-avatars-with-tooltip';
|
||||
|
||||
export type DocumentsTableProps = {
|
||||
data?: TFindInboxResponse;
|
||||
isLoading?: boolean;
|
||||
isLoadingError?: boolean;
|
||||
};
|
||||
|
||||
type DocumentsTableRow = TFindInboxResponse['data'][number];
|
||||
|
||||
export const InboxTable = () => {
|
||||
@@ -40,17 +35,24 @@ export const InboxTable = () => {
|
||||
const team = useOptionalCurrentTeam();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
|
||||
const page = searchParams?.get?.('page') ? Number(searchParams.get('page')) : undefined;
|
||||
const perPage = searchParams?.get?.('perPage') ? Number(searchParams.get('perPage')) : undefined;
|
||||
const [findInboxSearchParams] = useQueryStates(inboxSearchParams, {
|
||||
history: 'push',
|
||||
});
|
||||
|
||||
const status = resolveInboxStatus(findInboxSearchParams.status);
|
||||
const query = findInboxSearchParams.query ?? '';
|
||||
|
||||
const { data, isLoading, isLoadingError } = trpc.document.inbox.find.useQuery({
|
||||
page: page || 1,
|
||||
perPage: perPage || 10,
|
||||
page: Math.max(findInboxSearchParams.page ?? 1, 1),
|
||||
perPage: Math.min(Math.max(findInboxSearchParams.perPage ?? 10, 1), 100),
|
||||
query: query || undefined,
|
||||
status,
|
||||
});
|
||||
|
||||
const hasSearchQuery = query.trim().length > 0;
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
@@ -123,7 +125,20 @@ export const InboxTable = () => {
|
||||
emptyState={
|
||||
<div className="flex h-60 flex-col items-center justify-center gap-y-4 text-muted-foreground/60">
|
||||
<p>
|
||||
<Trans>Documents that require your attention will appear here</Trans>
|
||||
{match({ hasSearchQuery, status })
|
||||
.with({ hasSearchQuery: true }, () => <Trans>No documents match your search</Trans>)
|
||||
.with({ status: DocumentStatusEnum.COMPLETED }, () => (
|
||||
<Trans>Documents that you have completed will appear here</Trans>
|
||||
))
|
||||
.with({ status: DocumentStatusEnum.REJECTED }, () => (
|
||||
<Trans>Documents that have been rejected will appear here</Trans>
|
||||
))
|
||||
.with({ status: DocumentStatusEnum.CANCELLED }, () => (
|
||||
<Trans>Documents that have been cancelled will appear here</Trans>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<Trans>Documents that require your attention will appear here</Trans>
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -2,8 +2,11 @@ import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { InboxIcon } from 'lucide-react';
|
||||
|
||||
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() {
|
||||
@@ -26,6 +29,14 @@ export default function InboxPage() {
|
||||
<OrganisationInvitations className="mt-4" />
|
||||
</div>
|
||||
|
||||
<div className="mb-8 flex flex-wrap items-center gap-x-2 gap-y-4">
|
||||
<div className="w-56">
|
||||
<DocumentSearch />
|
||||
</div>
|
||||
|
||||
<DocumentsTableStatusFilter statuses={INBOX_SELECTABLE_STATUSES} />
|
||||
</div>
|
||||
|
||||
<InboxTable />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
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 { 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.
|
||||
*/
|
||||
export const inboxSearchParams = {
|
||||
status: documentsSearchParams.status,
|
||||
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);
|
||||
};
|
||||
Reference in New Issue
Block a user