Merge branch 'main' into feat/add-pdf-image-renderer

This commit is contained in:
David Nguyen
2026-02-04 12:50:40 +11:00
committed by GitHub
125 changed files with 7880 additions and 938 deletions
@@ -3,6 +3,7 @@ import { useState } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import type { z } from 'zod';
import type { TLicenseClaim } from '@documenso/lib/types/license';
import { generateDefaultSubscriptionClaim } from '@documenso/lib/utils/organisations-claims';
import { trpc } from '@documenso/trpc/react';
import type { ZCreateSubscriptionClaimRequestSchema } from '@documenso/trpc/server/admin-router/create-subscription-claim.types';
@@ -22,7 +23,11 @@ import { SubscriptionClaimForm } from '../forms/subscription-claim-form';
export type CreateClaimFormValues = z.infer<typeof ZCreateSubscriptionClaimRequestSchema>;
export const ClaimCreateDialog = () => {
type ClaimCreateDialogProps = {
licenseFlags?: TLicenseClaim;
};
export const ClaimCreateDialog = ({ licenseFlags }: ClaimCreateDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
@@ -67,6 +72,7 @@ export const ClaimCreateDialog = () => {
...generateDefaultSubscriptionClaim(),
}}
onFormSubmit={createClaim}
licenseFlags={licenseFlags}
formSubmitTrigger={
<DialogFooter>
<Button
@@ -2,6 +2,7 @@ import { useState } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import type { TLicenseClaim } from '@documenso/lib/types/license';
import { trpc } from '@documenso/trpc/react';
import type { TFindSubscriptionClaimsResponse } from '@documenso/trpc/server/admin-router/find-subscription-claims.types';
import { Button } from '@documenso/ui/primitives/button';
@@ -21,9 +22,10 @@ import { SubscriptionClaimForm } from '../forms/subscription-claim-form';
export type ClaimUpdateDialogProps = {
claim: TFindSubscriptionClaimsResponse['data'][number];
trigger: React.ReactNode;
licenseFlags?: TLicenseClaim;
};
export const ClaimUpdateDialog = ({ claim, trigger }: ClaimUpdateDialogProps) => {
export const ClaimUpdateDialog = ({ claim, trigger, licenseFlags }: ClaimUpdateDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
@@ -69,6 +71,7 @@ export const ClaimUpdateDialog = ({ claim, trigger }: ClaimUpdateDialogProps) =>
data,
})
}
licenseFlags={licenseFlags}
formSubmitTrigger={
<DialogFooter>
<Button
@@ -0,0 +1,170 @@
import { plural } from '@lingui/core/macro';
import { Plural, useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type EnvelopesBulkDeleteDialogProps = {
envelopeIds: string[];
envelopeType: EnvelopeType;
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const EnvelopesBulkDeleteDialog = ({
envelopeIds,
envelopeType,
open,
onOpenChange,
onSuccess,
...props
}: EnvelopesBulkDeleteDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const trpcUtils = trpc.useUtils();
const isDocument = envelopeType === EnvelopeType.DOCUMENT;
const { mutateAsync: bulkDeleteEnvelopes, isPending } = trpc.envelope.bulk.delete.useMutation({
onSuccess: async (result) => {
// Invalidate the appropriate query based on envelope type.
if (isDocument) {
await trpcUtils.document.findDocumentsInternal.invalidate();
} else {
await trpcUtils.template.findTemplates.invalidate();
}
if (result.failedIds.length > 0) {
toast({
title: isDocument ? t`Documents partially deleted` : t`Templates partially deleted`,
description: t`${plural(result.deletedCount, {
one: '# item deleted.',
other: '# items deleted.',
})} ${plural(result.failedIds.length, {
one: '# item could not be deleted.',
other: '# items could not be deleted.',
})}`,
variant: 'destructive',
});
} else {
toast({
title: isDocument ? t`Documents deleted` : t`Templates deleted`,
description: plural(result.deletedCount, {
one: '# item has been deleted.',
other: '# items have been deleted.',
}),
variant: 'default',
});
}
onSuccess?.();
onOpenChange(false);
},
onError: () => {
toast({
title: t`Error`,
description: t`An error occurred while deleting the items.`,
variant: 'destructive',
});
},
});
return (
<Dialog {...props} open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{isDocument ? <Trans>Delete Documents</Trans> : <Trans>Delete Templates</Trans>}
</DialogTitle>
<DialogDescription>
{isDocument ? (
<Plural
value={envelopeIds.length}
one="You are about to delete the selected document."
other="You are about to delete # documents."
/>
) : (
<Plural
value={envelopeIds.length}
one="You are about to delete the selected template."
other="You are about to delete # templates."
/>
)}
</DialogDescription>
</DialogHeader>
<Alert variant="warning">
<AlertDescription>
<p>
<Trans>
Please note that this action is <strong>irreversible</strong>.
</Trans>
</p>
<p className="mt-1">
<Trans>Once confirmed, the following will occur:</Trans>
</p>
<ul className="mt-0.5 list-inside list-disc">
{isDocument ? (
<>
<li>
<Trans>Selected documents will be permanently deleted</Trans>
</li>
<li>
<Trans>Pending documents will have their signing process cancelled</Trans>
</li>
<li>
<Trans>All recipients will be notified</Trans>
</li>
</>
) : (
<>
<li>
<Trans>Selected templates will be permanently deleted</Trans>
</li>
<li>
<Trans>Direct links associated with templates will be removed</Trans>
</li>
</>
)}
</ul>
</AlertDescription>
</Alert>
<DialogFooter>
<Button type="button" variant="secondary" disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
<Button
onClick={(e) => {
e.preventDefault();
void bulkDeleteEnvelopes({ envelopeIds });
}}
loading={isPending}
variant="destructive"
>
<Trans>Delete</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,256 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Plural, useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderIcon, HomeIcon, Loader2, Search } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type EnvelopesBulkMoveDialogProps = {
envelopeIds: string[];
envelopeType: EnvelopeType;
open: boolean;
onOpenChange: (open: boolean) => void;
currentFolderId?: string;
onSuccess?: () => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZBulkMoveFormSchema = z.object({
folderId: z.string().nullable(),
});
type TBulkMoveFormSchema = z.infer<typeof ZBulkMoveFormSchema>;
export const EnvelopesBulkMoveDialog = ({
envelopeIds,
envelopeType,
open,
onOpenChange,
currentFolderId,
onSuccess,
...props
}: EnvelopesBulkMoveDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [searchTerm, setSearchTerm] = useState('');
const form = useForm<TBulkMoveFormSchema>({
resolver: zodResolver(ZBulkMoveFormSchema),
defaultValues: {
folderId: currentFolderId ?? null,
},
});
const isDocument = envelopeType === EnvelopeType.DOCUMENT;
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery(
{
parentId: currentFolderId,
type: envelopeType,
},
{
enabled: open,
},
);
const { mutateAsync: bulkMoveEnvelopes } = trpc.envelope.bulk.move.useMutation();
const trpcUtils = trpc.useUtils();
useEffect(() => {
if (open) {
setSearchTerm('');
form.reset({
folderId: currentFolderId,
});
}
}, [open, currentFolderId]);
const onSubmit = async (data: TBulkMoveFormSchema) => {
try {
await bulkMoveEnvelopes({
envelopeIds,
folderId: data.folderId,
envelopeType,
});
// Invalidate the appropriate query based on envelope type.
if (isDocument) {
await trpcUtils.document.findDocumentsInternal.invalidate();
} else {
await trpcUtils.template.findTemplates.invalidate();
}
toast({
description: t`Selected items have been moved.`,
});
onSuccess?.();
onOpenChange(false);
} catch (err) {
const error = AppError.parseError(err);
const errorMessage = match(error.code)
.with(
AppErrorCode.NOT_FOUND,
() => t`The folder you are trying to move the items to does not exist.`,
)
.with(AppErrorCode.UNAUTHORIZED, () => t`You are not allowed to move these items.`)
.with(AppErrorCode.INVALID_BODY, () => t`All items must be of the same type.`)
.otherwise(() => t`An error occurred while moving the items.`);
toast({
description: errorMessage,
variant: 'destructive',
});
}
};
const filteredFolders = folders?.data.filter((folder) =>
folder.name.toLowerCase().includes(searchTerm.toLowerCase()),
);
return (
<Dialog {...props} open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{isDocument ? (
<Trans>Move Documents to Folder</Trans>
) : (
<Trans>Move Templates to Folder</Trans>
)}
</DialogTitle>
<DialogDescription>
{isDocument ? (
<Plural
value={envelopeIds.length}
one="Select a folder to move the selected document to."
other="Select a folder to move the # selected documents to."
/>
) : (
<Plural
value={envelopeIds.length}
one="Select a folder to move the selected template to."
other="Select a folder to move the # selected templates to."
/>
)}
</DialogDescription>
</DialogHeader>
<div className="relative">
<Search className="absolute left-2 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t`Search folders...`}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8"
/>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
<FormField
control={form.control}
name="folderId"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Folder</Trans>
</FormLabel>
<FormControl>
<div className="max-h-96 space-y-2 overflow-y-auto">
{isFoldersLoading ? (
<div className="flex h-10 items-center justify-center">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : (
<>
<Button
type="button"
variant={field.value === null ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(null)}
disabled={currentFolderId === undefined}
>
<HomeIcon className="mr-2 h-4 w-4" />
<Trans>Home (No Folder)</Trans>
</Button>
{filteredFolders?.map((folder) => (
<Button
key={folder.id}
type="button"
variant={field.value === folder.id ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(folder.id)}
disabled={currentFolderId === folder.id}
>
<FolderIcon className="mr-2 h-4 w-4" />
{folder.name}
</Button>
))}
{searchTerm && filteredFolders?.length === 0 && (
<div className="px-2 py-2 text-center text-sm text-muted-foreground">
<Trans>No folders found</Trans>
</div>
)}
</>
)}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
disabled={isFoldersLoading || form.formState.isSubmitting}
loading={form.formState.isSubmitting}
>
<Trans>Move</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -1,10 +1,10 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { TeamMemberRole } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { InfoIcon } from 'lucide-react';
import { InfoIcon, UserPlusIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
@@ -13,6 +13,7 @@ import { z } from 'zod';
import { TEAM_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/teams';
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@@ -44,6 +45,7 @@ import {
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { OrganisationMemberInviteDialog } from '~/components/dialogs/organisation-member-invite-dialog';
import { useCurrentTeam } from '~/providers/team';
export type TeamMemberCreateDialogProps = {
@@ -64,11 +66,14 @@ type TAddTeamMembersFormSchema = z.infer<typeof ZAddTeamMembersFormSchema>;
export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDialogProps) => {
const [open, setOpen] = useState(false);
const [step, setStep] = useState<'SELECT' | 'MEMBERS'>('SELECT');
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
const prevInviteDialogOpenRef = useRef(false);
const { t } = useLingui();
const { toast } = useToast();
const team = useCurrentTeam();
const utils = trpc.useUtils();
const form = useForm<TAddTeamMembersFormSchema>({
resolver: zodResolver(ZAddTeamMembersFormSchema),
@@ -96,7 +101,29 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
);
}, [organisationMemberQuery, teamMemberQuery]);
const hasNoAvailableMembers =
!organisationMemberQuery.isLoading && avaliableOrganisationMembers.length === 0;
const onFormSubmit = async ({ members }: TAddTeamMembersFormSchema) => {
if (members.length === 0) {
if (hasNoAvailableMembers) {
setInviteDialogOpen(true);
return;
}
// Don't show error if on SELECT step - the disabled Next button already communicates this
if (step === 'SELECT') {
return;
}
toast({
title: t`No members selected`,
description: t`Please select at least one member to add to the team.`,
variant: 'destructive',
});
return;
}
try {
await createTeamMembers({
teamId: team.id,
@@ -123,9 +150,20 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
if (!open) {
form.reset();
setStep('SELECT');
setInviteDialogOpen(false);
}
}, [open, form]);
// Invalidate queries when invite dialog closes (transitions from true to false) to refresh available members
useEffect(() => {
if (prevInviteDialogOpenRef.current && !inviteDialogOpen) {
void utils.organisation.member.find.invalidate({
organisationId: team.organisationId,
});
}
prevInviteDialogOpenRef.current = inviteDialogOpen;
}, [inviteDialogOpen, utils, team.organisationId]);
return (
<Dialog
{...props}
@@ -134,9 +172,11 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
// Since it would be annoying to redo the whole process.
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
<Button variant="secondary" onClick={() => setOpen(true)}>
<Trans>Add members</Trans>
</Button>
{trigger ?? (
<Button variant="secondary" onClick={() => setOpen(true)}>
<Trans>Add members</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent hideClose={true} position="center">
@@ -149,7 +189,7 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
<TooltipTrigger asChild>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground z-[99999] max-w-xs">
<TooltipContent className="z-[99999] max-w-xs text-muted-foreground">
<Trans>
To be able to add members to a team, you must first add them to the
organisation. For more information, please see the{' '}
@@ -186,7 +226,18 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
.exhaustive()}
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<form
onSubmit={form.handleSubmit(onFormSubmit)}
onKeyDown={(e) => {
if (e.key === 'Enter' && form.getValues('members').length === 0) {
e.preventDefault();
if (hasNoAvailableMembers) {
setInviteDialogOpen(true);
}
// Don't show toast - the disabled Next button already communicates this
}
}}
>
<fieldset disabled={form.formState.isSubmitting}>
{step === 'SELECT' && (
<>
@@ -194,46 +245,102 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
control={form.control}
name="members"
render={({ field }) => (
<FormItem>
<FormItem className="space-y-2">
<FormLabel>
<Trans>Members</Trans>
</FormLabel>
<FormControl>
<MultiSelectCombobox
options={avaliableOrganisationMembers.map((member) => ({
label: member.name,
value: member.id,
}))}
loading={organisationMemberQuery.isLoading}
selectedValues={field.value.map(
(member) => member.organisationMemberId,
)}
onChange={(value) => {
field.onChange(
value.map((organisationMemberId) => ({
organisationMemberId,
teamRole:
field.value.find(
(member) =>
member.organisationMemberId === organisationMemberId,
)?.teamRole || TeamMemberRole.MEMBER,
})),
);
}}
className="bg-background w-full"
emptySelectionPlaceholder={t`Select members`}
/>
{hasNoAvailableMembers ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-muted-foreground/25 bg-muted/30 px-6 py-12 text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<UserPlusIcon className="h-6 w-6 text-muted-foreground" />
</div>
<h3 className="mb-2 text-sm font-semibold">
<Trans>No organisation members available</Trans>
</h3>
<p className="mb-6 max-w-sm text-sm text-muted-foreground">
<Trans>
To add members to this team, you must first add them to the
organisation.
</Trans>
</p>
<OrganisationMemberInviteDialog
open={inviteDialogOpen}
onOpenChange={setInviteDialogOpen}
trigger={
<Button type="button" variant="default">
<UserPlusIcon className="mr-2 h-4 w-4" />
<Trans>Invite organisation members</Trans>
</Button>
}
/>
</div>
) : (
<MultiSelectCombobox
options={avaliableOrganisationMembers.map((member) => ({
label: member.name,
value: member.id,
}))}
loading={organisationMemberQuery.isLoading}
selectedValues={field.value.map(
(member) => member.organisationMemberId,
)}
onChange={(value) => {
field.onChange(
value.map((organisationMemberId) => ({
organisationMemberId,
teamRole:
field.value.find(
(member) =>
member.organisationMemberId === organisationMemberId,
)?.teamRole || TeamMemberRole.MEMBER,
})),
);
}}
className="w-full bg-background"
emptySelectionPlaceholder={t`Select members`}
/>
)}
</FormControl>
<FormDescription>
<Trans>Select members to add to this team</Trans>
</FormDescription>
{!hasNoAvailableMembers && (
<>
<FormDescription>
<Trans>Select members to add to this team</Trans>
</FormDescription>
<Alert
variant="neutral"
className="mt-2 flex items-center gap-2 space-y-0"
>
<div>
<UserPlusIcon className="h-5 w-5 text-muted-foreground" />
</div>
<AlertDescription className="mt-0 flex-1">
<Trans>Can't find someone?</Trans>{' '}
<OrganisationMemberInviteDialog
open={inviteDialogOpen}
onOpenChange={setInviteDialogOpen}
trigger={
<Button
type="button"
variant="link"
className="h-auto p-0 text-sm font-medium text-documenso-700 hover:text-documenso-600"
>
<Trans>Invite them to the organisation first</Trans>
</Button>
}
/>
</AlertDescription>
</Alert>
</>
)}
</FormItem>
)}
/>
<DialogFooter>
<DialogFooter className="mt-4">
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
+1 -1
View File
@@ -402,7 +402,7 @@ export const SignUpForm = ({
size="lg"
className="mt-6 w-full"
>
<Trans>Complete</Trans>
<Trans>Create account</Trans>
</Button>
</form>
</Form>
@@ -2,10 +2,13 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import type { SubscriptionClaim } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { Link } from 'react-router';
import type { z } from 'zod';
import type { TLicenseClaim } from '@documenso/lib/types/license';
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
import { ZCreateSubscriptionClaimRequestSchema } from '@documenso/trpc/server/admin-router/create-subscription-claim.types';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Form,
@@ -24,15 +27,22 @@ type SubscriptionClaimFormProps = {
subscriptionClaim: Omit<SubscriptionClaim, 'id' | 'createdAt' | 'updatedAt'>;
onFormSubmit: (data: SubscriptionClaimFormValues) => Promise<void>;
formSubmitTrigger?: React.ReactNode;
licenseFlags?: TLicenseClaim;
};
export const SubscriptionClaimForm = ({
subscriptionClaim,
onFormSubmit,
formSubmitTrigger,
licenseFlags,
}: SubscriptionClaimFormProps) => {
const { t } = useLingui();
const hasRestrictedEnterpriseFeatures = Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).some(
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
(flag) => flag.isEnterprise && !licenseFlags?.[flag.key as keyof TLicenseClaim],
);
const form = useForm<SubscriptionClaimFormValues>({
resolver: zodResolver(ZCreateSubscriptionClaimRequestSchema),
defaultValues: {
@@ -142,34 +152,59 @@ export const SubscriptionClaimForm = ({
</FormLabel>
<div className="mt-2 space-y-2 rounded-md border p-4">
{Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(({ key, label }) => (
<FormField
key={key}
control={form.control}
name={`flags.${key}`}
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormControl>
<div className="flex items-center">
<Checkbox
id={`flag-${key}`}
checked={field.value}
onCheckedChange={field.onChange}
/>
{Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(
({ key, label, isEnterprise }) => {
const isRestrictedFeature =
isEnterprise && !licenseFlags?.[key as keyof TLicenseClaim]; // eslint-disable-line @typescript-eslint/consistent-type-assertions
<label
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
htmlFor={`flag-${key}`}
>
{label}
</label>
</div>
</FormControl>
</FormItem>
)}
/>
))}
return (
<FormField
key={key}
control={form.control}
name={`flags.${key}`}
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormControl>
<div className="flex items-center">
<Checkbox
id={`flag-${key}`}
checked={field.value}
onCheckedChange={field.onChange}
disabled={isRestrictedFeature && !field.value} // Allow disabling of restricted features.
/>
<label
className="ml-2 flex flex-row items-center text-sm text-muted-foreground"
htmlFor={`flag-${key}`}
>
{label}
{isRestrictedFeature && ' ¹'}
</label>
</div>
</FormControl>
</FormItem>
)}
/>
);
},
)}
</div>
{hasRestrictedEnterpriseFeatures && (
<Alert variant="neutral" className="mt-4">
<AlertDescription>
<span>¹&nbsp;</span>
<Trans>Your current license does not include these features.</Trans>{' '}
<Link
to="https://docs.documenso.com/users/licenses/enterprise-edition"
target="_blank"
className="text-foreground underline hover:opacity-80"
>
<Trans>Learn more</Trans>
</Link>
</AlertDescription>
</Alert>
)}
</div>
{formSubmitTrigger}
@@ -0,0 +1,212 @@
import { Trans, useLingui } from '@lingui/react/macro';
import {
ArrowRightIcon,
CheckCircle2Icon,
KeyRoundIcon,
Loader2Icon,
RefreshCwIcon,
XCircleIcon,
} from 'lucide-react';
import { DateTime } from 'luxon';
import { Link, useRevalidator } from 'react-router';
import { match } from 'ts-pattern';
import type { TCachedLicense } from '@documenso/lib/types/license';
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
import { trpc } from '@documenso/trpc/react';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { CardMetric } from './metric-card';
type AdminLicenseCardProps = {
licenseData: TCachedLicense | null;
};
export const AdminLicenseCard = ({ licenseData }: AdminLicenseCardProps) => {
const { t, i18n } = useLingui();
const { license } = licenseData || {};
if (!license) {
return (
<div className="relative">
<div className="absolute right-3 top-3 z-10">
<AdminLicenseResyncButton />
</div>
<CardMetric icon={KeyRoundIcon} title={t`License`} className="h-fit max-h-fit">
<div className="mt-1 flex items-center justify-center gap-2">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-dashed border-muted-foreground/30 bg-muted/50">
<KeyRoundIcon className="h-5 w-5 text-muted-foreground/50" />
</div>
<div className="flex flex-col gap-0.5">
{licenseData?.requestedLicenseKey ? (
<>
<p className="text-sm font-medium text-destructive">
<Trans>Invalid License Key</Trans>
</p>
<p className="text-xs text-muted-foreground">{licenseData.requestedLicenseKey}</p>
</>
) : (
<p className="text-sm font-medium text-muted-foreground">
<Trans>No License Configured</Trans>
</p>
)}
<Link
to="https://docs.documenso.com/users/licenses/enterprise-edition"
target="_blank"
className="flex flex-row items-center text-xs text-muted-foreground hover:text-muted-foreground/80"
>
<Trans>Learn more</Trans> <ArrowRightIcon className="h-3 w-3" />
</Link>
</div>
</div>
</CardMetric>
</div>
);
}
const enabledFlags = Object.entries(license.flags).filter(([, enabled]) => enabled);
return (
<div className="relative max-w-full overflow-hidden rounded-lg border border-border bg-background px-4 pb-6 pt-4 shadow shadow-transparent duration-200 hover:shadow-border/80">
<div className="absolute right-3 top-3">
<AdminLicenseResyncButton />
</div>
<div className="flex items-start gap-2">
<div className="h-4 w-4">
<KeyRoundIcon className="h-4 w-4 text-muted-foreground" />
</div>
<h3 className="text-primary-forground mb-2 flex items-end text-sm font-medium leading-tight">
<Trans>Documenso License</Trans>
</h3>
{match(license.status)
.with('ACTIVE', () => (
<Badge variant="default" size="small">
<CheckCircle2Icon className="mr-1 h-3 w-3" />
<Trans context="Subscription status">Active</Trans>
</Badge>
))
.with('PAST_DUE', () => (
<Badge variant="warning" size="small">
<XCircleIcon className="mr-1 h-3 w-3" />
<Trans context="Subscription status">Past Due</Trans>
</Badge>
))
.with('EXPIRED', () => (
<Badge variant="destructive" size="small">
<XCircleIcon className="mr-1 h-3 w-3" />
<Trans context="Subscription status">Expired</Trans>
</Badge>
))
.otherwise(() => null)}
</div>
<div className="mt-4 grid grid-cols-2 gap-4">
<div>
<p className="text-sm font-medium text-foreground">
<Trans>License</Trans>
</p>
<p className="mt-0.5 text-xs text-muted-foreground">{license.name}</p>
</div>
<div>
<p className="text-sm font-medium text-foreground">
<Trans>Expires</Trans>
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{i18n.date(license.periodEnd, DateTime.DATE_MED)}
</p>
</div>
<div>
<p className="text-sm font-medium text-foreground">
<Trans>License Key</Trans>
</p>
<p className="mt-0.5 text-xs text-muted-foreground">{license.licenseKey}</p>
</div>
<div>
<p className="text-sm font-medium text-foreground">
<Trans>Features</Trans>
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{enabledFlags.length > 0 ? (
enabledFlags
.map(
([flag]) =>
SUBSCRIPTION_CLAIM_FEATURE_FLAGS[
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
flag as keyof typeof SUBSCRIPTION_CLAIM_FEATURE_FLAGS
]?.label || flag,
)
.join(', ')
) : (
<Trans>No features enabled</Trans>
)}
</p>
</div>
</div>
</div>
);
};
const AdminLicenseResyncButton = () => {
const { t } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const { mutate: resyncLicense, isPending: isResyncingLicense } =
trpc.admin.license.resync.useMutation({
onSuccess: async () => {
toast({
title: t`License synced`,
});
await revalidate();
},
onError: () => {
toast({
title: t`Failed to sync license`,
variant: 'destructive',
});
},
});
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
disabled={isResyncingLicense}
onClick={() => resyncLicense()}
>
{isResyncingLicense ? (
<Loader2Icon className="h-4 w-4 animate-spin" />
) : (
<RefreshCwIcon className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<Trans>Sync license from server</Trans>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
@@ -0,0 +1,78 @@
import { Trans } from '@lingui/react/macro';
import { AlertTriangleIcon, KeyRoundIcon } from 'lucide-react';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
import type { TCachedLicense } from '@documenso/lib/types/license';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
export type AdminLicenseStatusBannerProps = {
license: TCachedLicense | null;
};
export const AdminLicenseStatusBanner = ({ license }: AdminLicenseStatusBannerProps) => {
const licenseStatus = license?.derivedStatus;
if (!license || licenseStatus === 'ACTIVE' || licenseStatus === 'NOT_FOUND') {
return null;
}
return (
<div
className={cn('mb-8 rounded-lg bg-yellow-200 text-yellow-900 dark:bg-yellow-400', {
'bg-destructive text-destructive-foreground':
licenseStatus === 'EXPIRED' || licenseStatus === 'UNAUTHORIZED',
})}
>
<div className="flex items-center justify-between gap-x-4 px-4 py-3 text-sm font-medium">
<div className="flex items-center">
<AlertTriangleIcon className="mr-2.5 h-5 w-5" />
{match(licenseStatus)
.with('PAST_DUE', () => (
<Trans>
License Payment Overdue - Please update your payment to avoid service disruptions.
</Trans>
))
.with('EXPIRED', () => (
<Trans>
License Expired - Please renew your license to continue using enterprise features.
</Trans>
))
.with('UNAUTHORIZED', () =>
license ? (
<Trans>
Invalid License Type - Your Documenso instance is using features that are not part
of your license.
</Trans>
) : (
<Trans>
Missing License - Your Documenso instance is using features that require a
license.
</Trans>
),
)
.otherwise(() => null)}
</div>
<Button
variant="outline"
size="sm"
className={cn({
'border-yellow-900/30 text-yellow-900 hover:bg-yellow-100 dark:hover:bg-yellow-500':
licenseStatus === 'PAST_DUE',
'border-destructive-foreground/30 text-destructive-foreground hover:bg-destructive/80':
licenseStatus === 'EXPIRED' || licenseStatus === 'UNAUTHORIZED',
})}
asChild
>
<Link to="https://docs.documenso.com/users/licenses/enterprise-edition" target="_blank">
<KeyRoundIcon className="mr-1.5 h-4 w-4" />
<Trans>See Documentation</Trans>
</Link>
</Button>
</div>
</div>
);
};
@@ -1,10 +1,13 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans, useLingui as useLinguiMacro } from '@lingui/react/macro';
import { RecipientRole } from '@prisma/client';
import type { TDefaultRecipient } from '@documenso/lib/types/default-recipients';
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
import { trpc } from '@documenso/trpc/react';
import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect';
import { useToast } from '@documenso/ui/primitives/use-toast';
type DefaultRecipientsMultiSelectComboboxProps = {
listValues: TDefaultRecipient[];
@@ -20,6 +23,8 @@ export const DefaultRecipientsMultiSelectCombobox = ({
organisationId,
}: DefaultRecipientsMultiSelectComboboxProps) => {
const { _ } = useLingui();
const { t } = useLinguiMacro();
const { toast } = useToast();
const { data: organisationData, isLoading: isLoadingOrganisation } =
trpc.organisation.member.find.useQuery(
@@ -60,31 +65,56 @@ export const DefaultRecipientsMultiSelectCombobox = ({
}));
const onSelectionChange = (selected: Option[]) => {
const updatedRecipients = selected.map((option) => {
const existingRecipient = listValues.find((r) => r.email === option.value);
const member = members?.find((m) => m.email === option.value);
const invalidEmails = selected.filter(
(option) => !isRecipientEmailValidForSending({ email: option.value }),
);
return {
email: option.value,
name: member?.name || option.value,
role: existingRecipient?.role ?? RecipientRole.CC,
};
});
if (invalidEmails.length > 0) {
toast({
title: t`Invalid email`,
description: t`"${invalidEmails[0].value}" is not a valid email address.`,
variant: 'destructive',
});
}
const updatedRecipients = selected
.filter((option) => !invalidEmails.includes(option))
.map((option) => {
const existingRecipient = listValues.find((r) => r.email === option.value);
const member = members?.find((m) => m.email === option.value);
return {
email: option.value,
name: member?.name || option.value,
role: existingRecipient?.role ?? RecipientRole.CC,
};
});
onChange(updatedRecipients);
};
return (
<MultiSelect
commandProps={{ label: _(msg`Select recipients`) }}
commandProps={{ label: _(msg`Select or add recipients`) }}
options={options}
value={value}
onChange={onSelectionChange}
placeholder={_(msg`Select recipients`)}
placeholder={_(msg`Select or enter email address`)}
hideClearAllButton
hidePlaceholderWhenSelected
loadingIndicator={isLoading ? <p className="text-center text-sm">Loading...</p> : undefined}
emptyIndicator={<p className="text-center text-sm">No members found</p>}
creatable
loadingIndicator={
isLoading ? (
<p className="text-center text-sm">
<Trans>Loading...</Trans>
</p>
) : undefined
}
emptyIndicator={
<p className="text-center text-sm">
<Trans>Type an email address to add a recipient</Trans>
</p>
}
/>
);
};
@@ -3,6 +3,8 @@ import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DownloadIcon } from 'lucide-react';
import { downloadFile } from '@documenso/lib/client-only/download-file';
import { base64 } from '@documenso/lib/universal/base64';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -25,36 +27,15 @@ export const DocumentAuditLogDownloadButton = ({
const onDownloadAuditLogsClick = async () => {
try {
const { url } = await downloadAuditLogs({ documentId });
const { data, envelopeTitle } = await downloadAuditLogs({ documentId });
const iframe = Object.assign(document.createElement('iframe'), {
src: url,
const buffer = new Uint8Array(base64.decode(data));
const blob = new Blob([buffer], { type: 'application/pdf' });
downloadFile({
data: blob,
filename: `${envelopeTitle} - Audit Logs.pdf`,
});
Object.assign(iframe.style, {
position: 'fixed',
top: '0',
left: '0',
width: '0',
height: '0',
});
const onLoaded = () => {
if (iframe.contentDocument?.readyState === 'complete') {
iframe.contentWindow?.print();
iframe.contentWindow?.addEventListener('afterprint', () => {
document.body.removeChild(iframe);
});
}
};
// When the iframe has loaded, print the iframe and remove it from the dom
iframe.addEventListener('load', onLoaded);
document.body.appendChild(iframe);
onLoaded();
} catch (error) {
console.error(error);
@@ -4,6 +4,8 @@ import { Trans } from '@lingui/react/macro';
import type { DocumentStatus } from '@prisma/client';
import { DownloadIcon } from 'lucide-react';
import { downloadFile } from '@documenso/lib/client-only/download-file';
import { base64 } from '@documenso/lib/universal/base64';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@@ -29,36 +31,15 @@ export const DocumentCertificateDownloadButton = ({
const onDownloadCertificatesClick = async () => {
try {
const { url } = await downloadCertificate({ documentId });
const { data, envelopeTitle } = await downloadCertificate({ documentId });
const iframe = Object.assign(document.createElement('iframe'), {
src: url,
const buffer = new Uint8Array(base64.decode(data));
const blob = new Blob([buffer], { type: 'application/pdf' });
downloadFile({
data: blob,
filename: `${envelopeTitle} - Certificate.pdf`,
});
Object.assign(iframe.style, {
position: 'fixed',
top: '0',
left: '0',
width: '0',
height: '0',
});
const onLoaded = () => {
if (iframe.contentDocument?.readyState === 'complete') {
iframe.contentWindow?.print();
iframe.contentWindow?.addEventListener('afterprint', () => {
document.body.removeChild(iframe);
});
}
};
// When the iframe has loaded, print the iframe and remove it from the dom
iframe.addEventListener('load', onLoaded);
document.body.appendChild(iframe);
onLoaded();
} catch (error) {
console.error(error);
@@ -594,8 +594,12 @@ export const EnvelopeEditorRecipientForm = () => {
<Card backdropBlur={false} className="border">
<CardHeader className="flex flex-row justify-between">
<div>
<CardTitle>Recipients</CardTitle>
<CardDescription className="mt-1.5">Add recipients to your document</CardDescription>
<CardTitle>
<Trans>Recipients</Trans>
</CardTitle>
<CardDescription className="mt-1.5">
<Trans>Add recipients to your document</Trans>
</CardDescription>
</div>
<div className="flex flex-row items-center space-x-2">
@@ -5,15 +5,16 @@ import { cn } from '@documenso/ui/lib/utils';
export type CardMetricProps = {
icon?: LucideIcon;
title: string;
value: string | number;
value?: string | number;
className?: string;
children?: React.ReactNode;
};
export const CardMetric = ({ icon: Icon, title, value, className }: CardMetricProps) => {
export const CardMetric = ({ icon: Icon, title, value, className, children }: CardMetricProps) => {
return (
<div
className={cn(
'border-border bg-background hover:shadow-border/80 h-32 max-h-32 max-w-full overflow-hidden rounded-lg border shadow shadow-transparent duration-200',
'h-32 max-h-32 max-w-full overflow-hidden rounded-lg border border-border bg-background shadow shadow-transparent duration-200 hover:shadow-border/80',
className,
)}
>
@@ -21,7 +22,7 @@ export const CardMetric = ({ icon: Icon, title, value, className }: CardMetricPr
<div className="flex items-start">
{Icon && (
<div className="mr-2 h-4 w-4">
<Icon className="text-muted-foreground h-4 w-4" />
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
)}
@@ -30,9 +31,11 @@ export const CardMetric = ({ icon: Icon, title, value, className }: CardMetricPr
</h3>
</div>
<p className="text-foreground mt-auto text-4xl font-semibold leading-8">
{typeof value === 'number' ? value.toLocaleString('en-US') : value}
</p>
{children || (
<p className="mt-auto text-4xl font-semibold leading-8 text-foreground">
{typeof value === 'number' ? value.toLocaleString('en-US') : value}
</p>
)}
</div>
</div>
);
@@ -6,6 +6,7 @@ import { EditIcon, MoreHorizontalIcon, Trash2Icon } from 'lucide-react';
import { Link, useSearchParams } from 'react-router';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import type { TLicenseClaim } from '@documenso/lib/types/license';
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
import { trpc } from '@documenso/trpc/react';
@@ -27,7 +28,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { ClaimDeleteDialog } from '../dialogs/claim-delete-dialog';
import { ClaimUpdateDialog } from '../dialogs/claim-update-dialog';
export const AdminClaimsTable = () => {
type AdminClaimsTableProps = {
licenseFlags?: TLicenseClaim;
};
export const AdminClaimsTable = ({ licenseFlags }: AdminClaimsTableProps) => {
const { t } = useLingui();
const { toast } = useToast();
@@ -97,11 +102,11 @@ export const AdminClaimsTable = () => {
);
if (flags.length === 0) {
return <p className="text-muted-foreground text-xs">{t`None`}</p>;
return <p className="text-xs text-muted-foreground">{t`None`}</p>;
}
return (
<ul className="text-muted-foreground list-disc space-y-1 text-xs">
<ul className="list-disc space-y-1 text-xs text-muted-foreground">
{flags.map(({ key, label }) => (
<li key={key}>{label}</li>
))}
@@ -114,7 +119,7 @@ export const AdminClaimsTable = () => {
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger>
<MoreHorizontalIcon className="text-muted-foreground h-5 w-5" />
<MoreHorizontalIcon className="h-5 w-5 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="start" forceMount>
@@ -124,6 +129,7 @@ export const AdminClaimsTable = () => {
<ClaimUpdateDialog
claim={row.original}
licenseFlags={licenseFlags}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
@@ -12,7 +12,8 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/find-documents.types';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import type { DataTableColumnDef, RowSelectionState } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
@@ -30,6 +31,9 @@ export type DocumentsTableProps = {
isLoading?: boolean;
isLoadingError?: boolean;
onMoveDocument?: (documentId: number) => void;
enableSelection?: boolean;
rowSelection?: RowSelectionState;
onRowSelectionChange?: (selection: RowSelectionState) => void;
};
type DocumentsTableRow = TFindDocumentsResponse['data'][number];
@@ -39,6 +43,9 @@ export const DocumentsTable = ({
isLoading,
isLoadingError,
onMoveDocument,
enableSelection,
rowSelection,
onRowSelectionChange,
}: DocumentsTableProps) => {
const { _, i18n } = useLingui();
@@ -48,7 +55,34 @@ export const DocumentsTable = ({
const updateSearchParams = useUpdateSearchParams();
const columns = useMemo(() => {
return [
const cols: DataTableColumnDef<DocumentsTableRow>[] = [];
if (enableSelection) {
cols.push({
id: 'select',
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label={_(msg`Select all`)}
onClick={(e) => e.stopPropagation()}
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label={_(msg`Select row`)}
onClick={(e) => e.stopPropagation()}
/>
),
enableSorting: false,
enableHiding: false,
size: 40,
});
}
cols.push(
{
header: _(msg`Created`),
accessorKey: 'createdAt',
@@ -93,8 +127,10 @@ export const DocumentsTable = ({
</div>
),
},
] satisfies DataTableColumnDef<DocumentsTableRow>[];
}, [team, onMoveDocument]);
);
return cols;
}, [team, onMoveDocument, enableSelection]);
const onPaginationChange = (page: number, perPage: number) => {
startTransition(() => {
@@ -132,6 +168,11 @@ export const DocumentsTable = ({
rows: 5,
component: (
<>
{enableSelection && (
<TableCell>
<Skeleton className="h-4 w-4 rounded" />
</TableCell>
)}
<TableCell>
<Skeleton className="h-4 w-40 rounded-full" />
</TableCell>
@@ -152,13 +193,17 @@ export const DocumentsTable = ({
</>
),
}}
enableRowSelection={enableSelection}
rowSelection={rowSelection}
onRowSelectionChange={onRowSelectionChange}
getRowId={(row) => row.envelopeId}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>
{isPending && (
<div className="bg-background/50 absolute inset-0 flex items-center justify-center">
<Loader className="text-muted-foreground h-8 w-8 animate-spin" />
<div className="absolute inset-0 flex items-center justify-center bg-background/50">
<Loader className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)}
</div>
@@ -0,0 +1,55 @@
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { FolderInputIcon, Trash2Icon, XIcon } from 'lucide-react';
import { Button } from '@documenso/ui/primitives/button';
export type EnvelopesTableBulkActionBarProps = {
selectedCount: number;
onMoveClick: () => void;
onDeleteClick: () => void;
onClearSelection: () => void;
};
export const EnvelopesTableBulkActionBar = ({
selectedCount,
onMoveClick,
onDeleteClick,
onClearSelection,
}: EnvelopesTableBulkActionBarProps) => {
const { t } = useLingui();
if (selectedCount === 0) {
return null;
}
return (
<div className="fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-x-4 rounded-lg border border-border bg-widget px-4 py-3 shadow-lg">
<span className="text-sm font-medium">
<Trans>{selectedCount} selected</Trans>
</span>
<div className="h-6 w-px bg-border" />
<Button type="button" variant="outline" size="sm" onClick={onMoveClick}>
<FolderInputIcon className="mr-2 h-4 w-4" />
<Trans>Move to Folder</Trans>
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={onDeleteClick}
className="text-destructive hover:text-destructive"
>
<Trash2Icon className="mr-2 h-4 w-4" />
<Trans>Delete</Trans>
</Button>
<Button variant="ghost" size="sm" onClick={onClearSelection} aria-label={t`Clear selection`}>
<XIcon className="h-4 w-4" />
</Button>
</div>
);
};
@@ -12,7 +12,8 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import type { TFindTemplatesResponse } from '@documenso/trpc/server/template-router/schema';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import type { DataTableColumnDef, RowSelectionState } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
@@ -32,6 +33,9 @@ type TemplatesTableProps = {
isLoadingError?: boolean;
documentRootPath: string;
templateRootPath: string;
enableSelection?: boolean;
rowSelection?: RowSelectionState;
onRowSelectionChange?: (selection: RowSelectionState) => void;
};
type TemplatesTableRow = TFindTemplatesResponse['data'][number];
@@ -42,6 +46,9 @@ export const TemplatesTable = ({
isLoadingError,
documentRootPath,
templateRootPath,
enableSelection,
rowSelection,
onRowSelectionChange,
}: TemplatesTableProps) => {
const { _, i18n } = useLingui();
const { remaining } = useLimits();
@@ -60,7 +67,34 @@ export const TemplatesTable = ({
};
const columns = useMemo(() => {
return [
const cols: DataTableColumnDef<TemplatesTableRow>[] = [];
if (enableSelection) {
cols.push({
id: 'select',
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label={_(msg`Select all`)}
onClick={(e) => e.stopPropagation()}
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label={_(msg`Select row`)}
onClick={(e) => e.stopPropagation()}
/>
),
enableSorting: false,
enableHiding: false,
size: 40,
});
}
cols.push(
{
header: _(msg`Created`),
accessorKey: 'createdAt',
@@ -86,8 +120,8 @@ export const TemplatesTable = ({
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 !p-0">
<ul className="text-muted-foreground space-y-0.5 divide-y [&>li]:p-4">
<TooltipContent className="max-w-md space-y-2 !p-0 text-foreground">
<ul className="space-y-0.5 divide-y text-muted-foreground [&>li]:p-4">
<li>
<h2 className="mb-2 flex flex-row items-center font-semibold">
<Globe2Icon className="mr-2 h-5 w-5 text-green-500 dark:text-green-300" />
@@ -176,8 +210,10 @@ export const TemplatesTable = ({
);
},
},
] satisfies DataTableColumnDef<TemplatesTableRow>[];
}, [documentRootPath, team?.id, templateRootPath]);
);
return cols;
}, [documentRootPath, team?.id, templateRootPath, enableSelection]);
const onPaginationChange = (page: number, perPage: number) => {
startTransition(() => {
@@ -224,6 +260,10 @@ export const TemplatesTable = ({
currentPage={results.currentPage}
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
enableRowSelection={enableSelection}
rowSelection={rowSelection}
onRowSelectionChange={onRowSelectionChange}
getRowId={(row) => row.envelopeId}
error={{
enable: isLoadingError || false,
}}
@@ -232,6 +272,11 @@ export const TemplatesTable = ({
rows: 5,
component: (
<>
{enableSelection && (
<TableCell className="w-10">
<Skeleton className="h-4 w-4 rounded" />
</TableCell>
)}
<TableCell>
<Skeleton className="h-4 w-40 rounded-full" />
</TableCell>