mirror of
https://github.com/documenso/documenso.git
synced 2026-08-15 02:53:32 +10:00
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:
@@ -2,38 +2,24 @@ import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounce
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { useQueryState } from 'nuqs';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export const DocumentSearch = ({ initialValue = '' }: { initialValue?: string }) => {
|
||||
import { documentsSearchParams } from '~/utils/documents-search-params';
|
||||
|
||||
export const DocumentSearch = () => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [query, setQuery] = useQueryState('query', documentsSearchParams.query);
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState(initialValue);
|
||||
const [searchTerm, setSearchTerm] = useState(query ?? '');
|
||||
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(term: string) => {
|
||||
const params = new URLSearchParams(searchParams?.toString() ?? '');
|
||||
if (term) {
|
||||
params.set('query', term);
|
||||
} else {
|
||||
params.delete('query');
|
||||
}
|
||||
|
||||
setSearchParams(params);
|
||||
},
|
||||
[searchParams],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const currentQueryParam = searchParams.get('query') || '';
|
||||
|
||||
if (debouncedSearchTerm !== currentQueryParam) {
|
||||
handleSearch(debouncedSearchTerm);
|
||||
if (debouncedSearchTerm !== (query ?? '')) {
|
||||
void setQuery(debouncedSearchTerm || null);
|
||||
}
|
||||
}, [debouncedSearchTerm, searchParams]);
|
||||
}, [debouncedSearchTerm, query, setQuery]);
|
||||
|
||||
return (
|
||||
<Input
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@documenso/ui/primitives/command';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { CheckIcon, ChevronDownIcon } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
export type FilterPillOption = {
|
||||
value: string;
|
||||
label: ReactNode;
|
||||
trailing?: string;
|
||||
};
|
||||
|
||||
type FilterPillCommonProps = {
|
||||
icon: LucideIcon;
|
||||
label: ReactNode;
|
||||
options: FilterPillOption[];
|
||||
enableSearch?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
loading?: boolean;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
export type FilterPillSingleProps = FilterPillCommonProps & {
|
||||
multiple?: false;
|
||||
value: string | null;
|
||||
onChange: (value: string | null) => void;
|
||||
selectedLabel?: ReactNode;
|
||||
};
|
||||
|
||||
export type FilterPillMultipleProps = FilterPillCommonProps & {
|
||||
multiple: true;
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
};
|
||||
|
||||
export type FilterPillProps = FilterPillSingleProps | FilterPillMultipleProps;
|
||||
|
||||
/**
|
||||
* A faceted filter pill.
|
||||
*
|
||||
* Renders as a dashed "add a filter" pill at rest, and shows the current
|
||||
* selection inline once a value is picked. Selecting the active option
|
||||
* again (or the Clear row) removes it.
|
||||
*
|
||||
* Single select by default, closing on pick. When `multiple` is set the
|
||||
* popover stays open for toggling, and the trigger shows the first two
|
||||
* selections followed by a "+N more" chip.
|
||||
*/
|
||||
export const FilterPill = (props: FilterPillProps) => {
|
||||
const { icon: Icon, label, options, enableSearch, searchPlaceholder, loading, testId } = props;
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const selectedValues = props.multiple ? props.value : props.value === null ? [] : [props.value];
|
||||
|
||||
const selectedOptions = selectedValues
|
||||
.map((value) => options.find((option) => option.value === value))
|
||||
.filter((option): option is FilterPillOption => option !== undefined);
|
||||
|
||||
const hasSelection = selectedOptions.length > 0;
|
||||
const extraCount = selectedOptions.length - 2;
|
||||
|
||||
const onSelect = (nextValue: string) => {
|
||||
if (props.multiple) {
|
||||
const newValues = selectedValues.includes(nextValue)
|
||||
? selectedValues.filter((value) => value !== nextValue)
|
||||
: [...selectedValues, nextValue];
|
||||
|
||||
props.onChange(newValues);
|
||||
return;
|
||||
}
|
||||
|
||||
props.onChange(nextValue === props.value ? null : nextValue);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const onClear = () => {
|
||||
if (props.multiple) {
|
||||
props.onChange([]);
|
||||
} else {
|
||||
props.onChange(null);
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={loading}
|
||||
className={cn('border-dashed text-muted-foreground', {
|
||||
'border-solid text-foreground': hasSelection,
|
||||
})}
|
||||
data-testid={testId}
|
||||
>
|
||||
<Icon className="mr-2 h-4 w-4" />
|
||||
{label}
|
||||
|
||||
{hasSelection && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||
|
||||
{props.multiple ? (
|
||||
<span className="flex items-center gap-x-1">
|
||||
{selectedOptions.slice(0, 2).map((option) => (
|
||||
<Badge key={option.value} variant="neutral" size="small">
|
||||
{option.label}
|
||||
</Badge>
|
||||
))}
|
||||
|
||||
{extraCount > 0 && (
|
||||
<Badge variant="neutral" size="small">
|
||||
<Trans>+{extraCount} more</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-medium">{props.selectedLabel ?? selectedOptions[0].label}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<ChevronDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent className="w-52 p-0" align="start">
|
||||
<Command>
|
||||
{enableSearch && <CommandInput placeholder={searchPlaceholder} />}
|
||||
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
<Trans>No results found.</Trans>
|
||||
</CommandEmpty>
|
||||
|
||||
<CommandGroup>
|
||||
{options.map((option) => (
|
||||
<CommandItem key={option.value} onSelect={() => onSelect(option.value)}>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4 shrink-0',
|
||||
selectedValues.includes(option.value) ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
|
||||
{option.label}
|
||||
|
||||
{option.trailing !== undefined && (
|
||||
<span className="ml-auto pl-4 text-muted-foreground text-xs">{option.trailing}</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
|
||||
{hasSelection && (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup>
|
||||
<CommandItem className="justify-center text-center text-muted-foreground" onSelect={onClear}>
|
||||
<Trans>Clear</Trans>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { CalendarIcon } from 'lucide-react';
|
||||
import { useQueryStates } from 'nuqs';
|
||||
|
||||
import { FilterPill } from '~/components/general/filter-pill';
|
||||
import { DOCUMENTS_PERIOD_VALUES, documentsSearchParams } from '~/utils/documents-search-params';
|
||||
|
||||
const PERIOD_OPTIONS = [
|
||||
{ value: '7d', label: <Trans>Last 7 days</Trans> },
|
||||
{ value: '14d', label: <Trans>Last 14 days</Trans> },
|
||||
{ value: '30d', label: <Trans>Last 30 days</Trans> },
|
||||
];
|
||||
|
||||
export const DocumentsTablePeriodFilter = () => {
|
||||
const [{ period }, setSearchParams] = useQueryStates(
|
||||
{
|
||||
period: documentsSearchParams.period,
|
||||
page: documentsSearchParams.page,
|
||||
},
|
||||
{ history: 'push' },
|
||||
);
|
||||
|
||||
const onChange = (newPeriod: string | null) => {
|
||||
void setSearchParams({
|
||||
period: DOCUMENTS_PERIOD_VALUES.find((value) => value === newPeriod) ?? null,
|
||||
page: null,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<FilterPill
|
||||
icon={CalendarIcon}
|
||||
label={<Trans>Period</Trans>}
|
||||
value={period}
|
||||
onChange={onChange}
|
||||
options={PERIOD_OPTIONS}
|
||||
testId="documents-table-period-filter"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,63 +1,61 @@
|
||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router';
|
||||
import { UserIcon } from 'lucide-react';
|
||||
import { useQueryStates } from 'nuqs';
|
||||
|
||||
import { FilterPill } from '~/components/general/filter-pill';
|
||||
import { documentsSearchParams } from '~/utils/documents-search-params';
|
||||
|
||||
type DocumentsTableSenderFilterProps = {
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const DocumentsTableSenderFilter = ({ teamId }: DocumentsTableSenderFilterProps) => {
|
||||
const { pathname } = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const { _ } = useLingui();
|
||||
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
const senderIds = (searchParams?.get('senderIds') ?? '').split(',').filter((value) => value !== '');
|
||||
const [{ senderIds }, setSearchParams] = useQueryStates(
|
||||
{
|
||||
senderIds: documentsSearchParams.senderIds,
|
||||
page: documentsSearchParams.page,
|
||||
},
|
||||
{ history: 'push' },
|
||||
);
|
||||
|
||||
const selectedSenderIds = (senderIds ?? []).map((senderId) => senderId.toString());
|
||||
|
||||
const { data, isLoading } = trpc.team.member.getMany.useQuery({
|
||||
teamId,
|
||||
});
|
||||
|
||||
const comboBoxOptions = (data ?? []).map((member) => ({
|
||||
const options = (data ?? []).map((member) => ({
|
||||
label: member.name ?? member.email,
|
||||
value: member.userId.toString(),
|
||||
}));
|
||||
|
||||
const onChange = (newSenderIds: string[]) => {
|
||||
if (!pathname) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
|
||||
params.set('senderIds', newSenderIds.join(','));
|
||||
|
||||
if (newSenderIds.length === 0) {
|
||||
params.delete('senderIds');
|
||||
}
|
||||
|
||||
void navigate(`${pathname}?${params.toString()}`, { preventScrollReset: true });
|
||||
void setSearchParams({
|
||||
senderIds: newSenderIds.length > 0 ? newSenderIds.map(Number) : null,
|
||||
page: null,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<MultiSelectCombobox
|
||||
emptySelectionPlaceholder={
|
||||
<p className="font-normal text-muted-foreground">
|
||||
<Trans>
|
||||
<span className="text-muted-foreground/70">Sender:</span> All
|
||||
</Trans>
|
||||
</p>
|
||||
}
|
||||
enableClearAllButton={true}
|
||||
inputPlaceholder={msg`Search`}
|
||||
loading={!isMounted || isLoading}
|
||||
options={comboBoxOptions}
|
||||
selectedValues={senderIds}
|
||||
<FilterPill
|
||||
multiple
|
||||
icon={UserIcon}
|
||||
label={<Trans>Sender</Trans>}
|
||||
value={selectedSenderIds}
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
enableSearch
|
||||
searchPlaceholder={_(msg`Search members...`)}
|
||||
loading={!isMounted || isLoading}
|
||||
testId="documents-table-sender-filter"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useCurrentOrganisation } 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';
|
||||
import { ListFilterIcon } from 'lucide-react';
|
||||
import { useQueryStates } from 'nuqs';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
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';
|
||||
|
||||
type DocumentsTableStatusFilterProps = {
|
||||
stats: TFindDocumentsInternalResponse['stats'];
|
||||
};
|
||||
|
||||
export const DocumentsTableStatusFilter = ({ stats }: DocumentsTableStatusFilterProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const [{ status }, setSearchParams] = useQueryStates(
|
||||
{
|
||||
status: documentsSearchParams.status,
|
||||
page: documentsSearchParams.page,
|
||||
},
|
||||
{ history: 'push' },
|
||||
);
|
||||
|
||||
const selectableStatuses = useMemo(
|
||||
() =>
|
||||
SELECTABLE_STATUSES.filter((value) => {
|
||||
if (organisation.type === OrganisationType.PERSONAL) {
|
||||
return value !== ExtendedDocumentStatus.INBOX;
|
||||
}
|
||||
|
||||
return true;
|
||||
}),
|
||||
[organisation.type],
|
||||
);
|
||||
|
||||
const selectedStatus = useMemo(
|
||||
() => selectableStatuses.find((value) => value === status) ?? null,
|
||||
[selectableStatuses, status],
|
||||
);
|
||||
|
||||
const onChange = (newStatus: string | null) => {
|
||||
void setSearchParams({
|
||||
status: selectableStatuses.find((value) => value === newStatus) ?? null,
|
||||
page: null,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilterPill
|
||||
icon={ListFilterIcon}
|
||||
label={<Trans>Status</Trans>}
|
||||
value={selectedStatus}
|
||||
onChange={onChange}
|
||||
selectedLabel={selectedStatus && <DocumentStatus status={selectedStatus} className="[&>svg]:mr-1.5" />}
|
||||
options={selectableStatuses.map((value) => ({
|
||||
value,
|
||||
label: <DocumentStatus status={value} />,
|
||||
trailing: formatStatsCount(stats[value]),
|
||||
}))}
|
||||
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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const SELECTABLE_STATUSES: ExtendedDocumentStatus[] = [
|
||||
ExtendedDocumentStatus.INBOX,
|
||||
ExtendedDocumentStatus.PENDING,
|
||||
ExtendedDocumentStatus.COMPLETED,
|
||||
ExtendedDocumentStatus.CANCELLED,
|
||||
ExtendedDocumentStatus.DRAFT,
|
||||
ExtendedDocumentStatus.REJECTED,
|
||||
ExtendedDocumentStatus.EXPIRED,
|
||||
];
|
||||
|
||||
const formatStatsCount = (count: number) => {
|
||||
return count >= STATS_COUNT_CAP ? `${STATS_COUNT_CAP.toLocaleString()}+` : count.toString();
|
||||
};
|
||||
@@ -1,28 +1,20 @@
|
||||
import { useSessionStorage } from '@documenso/lib/client-only/hooks/use-session-storage';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { STATS_COUNT_CAP } from '@documenso/lib/constants/document';
|
||||
import { SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { parseToIntegerArray } from '@documenso/lib/utils/params';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/find-documents-internal.types';
|
||||
import { ZFindDocumentsInternalRequestSchema } from '@documenso/trpc/server/document-router/find-documents-internal.types';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import type { RowSelectionState } from '@documenso/ui/primitives/data-table';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import {
|
||||
EnvelopeType,
|
||||
FolderType,
|
||||
OrganisationType,
|
||||
type DocumentStatus as PrismaDocumentStatus,
|
||||
} from '@prisma/client';
|
||||
import { EnvelopeType, FolderType, type DocumentStatus as PrismaDocumentStatus } from '@prisma/client';
|
||||
import { XIcon } from 'lucide-react';
|
||||
import { useQueryStates } from 'nuqs';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
|
||||
import { EnvelopesBulkCancelDialog } from '~/components/dialogs/envelopes-bulk-cancel-dialog';
|
||||
import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog';
|
||||
@@ -32,15 +24,16 @@ import {
|
||||
} from '~/components/dialogs/envelopes-bulk-download-dialog';
|
||||
import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog';
|
||||
import { DocumentSearch } from '~/components/general/document/document-search';
|
||||
import { DocumentStatus } from '~/components/general/document/document-status';
|
||||
import { EnvelopeDropZoneWrapper } from '~/components/general/envelope/envelope-drop-zone-wrapper';
|
||||
import { FolderGrid } from '~/components/general/folder/folder-grid';
|
||||
import { PeriodSelector } from '~/components/general/period-selector';
|
||||
import { DocumentsTable } from '~/components/tables/documents-table';
|
||||
import { DocumentsTableEmptyState } from '~/components/tables/documents-table-empty-state';
|
||||
import { DocumentsTablePeriodFilter } from '~/components/tables/documents-table-period-filter';
|
||||
import { DocumentsTableSenderFilter } from '~/components/tables/documents-table-sender-filter';
|
||||
import { DocumentsTableStatusFilter } from '~/components/tables/documents-table-status-filter';
|
||||
import { EnvelopesTableBulkActionBar } from '~/components/tables/envelopes-table-bulk-action-bar';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { documentsSearchParams } from '~/utils/documents-search-params';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
export function meta() {
|
||||
@@ -55,22 +48,10 @@ type EnvelopeMetaCache = Record<string, { title: string; status: PrismaDocumentS
|
||||
const EMPTY_ROW_SELECTION: RowSelectionState = {};
|
||||
const EMPTY_ENVELOPE_META_CACHE: EnvelopeMetaCache = {};
|
||||
|
||||
const ZSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({
|
||||
status: true,
|
||||
period: true,
|
||||
page: true,
|
||||
perPage: true,
|
||||
query: true,
|
||||
}).extend({
|
||||
senderIds: z.string().transform(parseToIntegerArray).optional().catch([]),
|
||||
});
|
||||
|
||||
export default function DocumentsPage() {
|
||||
const organisation = useCurrentOrganisation();
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { folderId } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const documentsPath = formatDocumentsPath(team.url);
|
||||
@@ -107,14 +88,18 @@ export default function DocumentsPage() {
|
||||
[ExtendedDocumentStatus.ALL]: 0,
|
||||
});
|
||||
|
||||
const findDocumentSearchParams = useMemo(
|
||||
() => ZSearchParamsSchema.safeParse(Object.fromEntries(searchParams.entries())).data || {},
|
||||
[searchParams],
|
||||
);
|
||||
const [findDocumentSearchParams, setFindDocumentSearchParams] = useQueryStates(documentsSearchParams, {
|
||||
history: 'push',
|
||||
});
|
||||
|
||||
const { data, isLoading, isLoadingError } = trpc.document.findDocumentsInternal.useQuery(
|
||||
{
|
||||
...findDocumentSearchParams,
|
||||
status: findDocumentSearchParams.status ?? undefined,
|
||||
period: findDocumentSearchParams.period ?? undefined,
|
||||
senderIds: findDocumentSearchParams.senderIds ?? undefined,
|
||||
page: findDocumentSearchParams.page ?? undefined,
|
||||
perPage: findDocumentSearchParams.perPage ?? undefined,
|
||||
query: findDocumentSearchParams.query ?? undefined,
|
||||
folderId,
|
||||
},
|
||||
{
|
||||
@@ -167,34 +152,21 @@ export default function DocumentsPage() {
|
||||
.filter((item): item is EnvelopeBulkDownloadItem => item !== null);
|
||||
}, [selectedEnvelopeIds, envelopeMetaCache]);
|
||||
|
||||
const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
const hasActiveFilters = useMemo(() => {
|
||||
return Boolean(
|
||||
(findDocumentSearchParams.status && findDocumentSearchParams.status !== ExtendedDocumentStatus.ALL) ||
|
||||
findDocumentSearchParams.senderIds?.length ||
|
||||
findDocumentSearchParams.period,
|
||||
);
|
||||
}, [findDocumentSearchParams]);
|
||||
|
||||
params.set('status', value);
|
||||
|
||||
if (value === ExtendedDocumentStatus.ALL) {
|
||||
params.delete('status');
|
||||
}
|
||||
|
||||
if (value === ExtendedDocumentStatus.INBOX && organisation.type === OrganisationType.PERSONAL) {
|
||||
params.delete('status');
|
||||
}
|
||||
|
||||
if (params.has('page')) {
|
||||
params.delete('page');
|
||||
}
|
||||
|
||||
let path = formatDocumentsPath(team.url);
|
||||
|
||||
if (folderId) {
|
||||
path += `/f/${folderId}`;
|
||||
}
|
||||
|
||||
if (params.toString()) {
|
||||
path += `?${params.toString()}`;
|
||||
}
|
||||
|
||||
return path;
|
||||
const onResetFilters = () => {
|
||||
void setFindDocumentSearchParams({
|
||||
status: null,
|
||||
senderIds: null,
|
||||
period: null,
|
||||
page: null,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -208,69 +180,40 @@ export default function DocumentsPage() {
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||
<FolderGrid type={FolderType.DOCUMENT} parentId={folderId ?? null} />
|
||||
|
||||
<div className="mt-8 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
|
||||
<div className="flex flex-row items-center">
|
||||
<Avatar className="mr-3 h-12 w-12 border-2 border-white border-solid dark:border-border">
|
||||
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
|
||||
<AvatarFallback className="text-muted-foreground text-xs">{team.name.slice(0, 1)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="mt-8 flex flex-row items-center">
|
||||
<Avatar className="mr-3 h-12 w-12 border-2 border-white border-solid dark:border-border">
|
||||
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
|
||||
<AvatarFallback className="text-muted-foreground text-xs">{team.name.slice(0, 1)}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<h2 className="font-semibold text-4xl">
|
||||
<Trans>Documents</Trans>
|
||||
</h2>
|
||||
<h2 className="font-semibold text-4xl">
|
||||
<Trans>Documents</Trans>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex flex-wrap items-center gap-x-2 gap-y-4">
|
||||
<div className="w-56">
|
||||
<DocumentSearch />
|
||||
</div>
|
||||
|
||||
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
|
||||
<Tabs value={findDocumentSearchParams.status || 'ALL'} className="overflow-x-auto">
|
||||
<TabsList>
|
||||
{[
|
||||
ExtendedDocumentStatus.INBOX,
|
||||
ExtendedDocumentStatus.PENDING,
|
||||
ExtendedDocumentStatus.COMPLETED,
|
||||
ExtendedDocumentStatus.CANCELLED,
|
||||
ExtendedDocumentStatus.DRAFT,
|
||||
ExtendedDocumentStatus.REJECTED,
|
||||
ExtendedDocumentStatus.EXPIRED,
|
||||
ExtendedDocumentStatus.ALL,
|
||||
]
|
||||
.filter((value) => {
|
||||
if (organisation.type === OrganisationType.PERSONAL) {
|
||||
return value !== ExtendedDocumentStatus.INBOX;
|
||||
}
|
||||
<DocumentsTableStatusFilter stats={stats} />
|
||||
|
||||
return true;
|
||||
})
|
||||
.map((value) => (
|
||||
<TabsTrigger key={value} className="min-w-[60px] hover:text-foreground" value={value} asChild>
|
||||
<Link to={getTabHref(value)} preventScrollReset>
|
||||
<DocumentStatus status={value} />
|
||||
{team && <DocumentsTableSenderFilter teamId={team.id} />}
|
||||
|
||||
{value !== ExtendedDocumentStatus.ALL && (
|
||||
<span className="ml-1 inline-block opacity-50">
|
||||
{stats[value] >= STATS_COUNT_CAP ? `${STATS_COUNT_CAP.toLocaleString()}+` : stats[value]}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<DocumentsTablePeriodFilter />
|
||||
|
||||
{team && <DocumentsTableSenderFilter teamId={team.id} />}
|
||||
|
||||
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
||||
<PeriodSelector />
|
||||
</div>
|
||||
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
||||
<DocumentSearch initialValue={findDocumentSearchParams.query} />
|
||||
</div>
|
||||
</div>
|
||||
{hasActiveFilters && (
|
||||
<Button variant="ghost" className="px-2 text-muted-foreground lg:px-3" onClick={onResetFilters}>
|
||||
<Trans>Reset</Trans>
|
||||
<XIcon className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<div>
|
||||
{data && data.count === 0 ? (
|
||||
<DocumentsTableEmptyState status={findDocumentSearchParams.status || ExtendedDocumentStatus.ALL} />
|
||||
<DocumentsTableEmptyState status={findDocumentSearchParams.status ?? ExtendedDocumentStatus.ALL} />
|
||||
) : (
|
||||
<DocumentsTable
|
||||
data={data}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
import { parseAsArrayOf, parseAsInteger, parseAsString, parseAsStringLiteral } from 'nuqs';
|
||||
|
||||
export const DOCUMENTS_PERIOD_VALUES = ['7d', '14d', '30d'] as const;
|
||||
|
||||
/**
|
||||
* Shared nuqs parsers for the documents page URL state.
|
||||
*
|
||||
* Used by the documents page and its filter components so every consumer
|
||||
* parses and serialises the params identically.
|
||||
*/
|
||||
export const documentsSearchParams = {
|
||||
status: parseAsStringLiteral(Object.values(ExtendedDocumentStatus)),
|
||||
period: parseAsStringLiteral(DOCUMENTS_PERIOD_VALUES),
|
||||
senderIds: parseAsArrayOf(parseAsInteger),
|
||||
page: parseAsInteger,
|
||||
perPage: parseAsInteger,
|
||||
query: parseAsString,
|
||||
};
|
||||
Reference in New Issue
Block a user