Compare commits

..

3 Commits

Author SHA1 Message Date
3b5c50ed88 v1.13.2 2025-10-30 15:12:40 +11:00
e4e9e749e5 fix: handle custom org limits with member invite 2025-10-30 14:31:58 +11:00
37ae6a86fd fix: embedded direct template recipient auth 2025-10-29 15:22:07 +11:00
399 changed files with 10990 additions and 28778 deletions

View File

@ -29,10 +29,6 @@ NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000"
# URL used by the web app to request itself (e.g. local background jobs) # URL used by the web app to request itself (e.g. local background jobs)
NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000" NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000"
# [[SERVER]]
# OPTIONAL: The port the server will listen on. Defaults to 3000.
PORT=3000
# [[DATABASE]] # [[DATABASE]]
NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso" NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
# Defines the URL to use for the database when running migrations and other commands that won't work with a connection pool. # Defines the URL to use for the database when running migrations and other commands that won't work with a connection pool.

View File

@ -1,692 +0,0 @@
# Documenso Code Style Guide
This document captures the code style, patterns, and conventions used in the Documenso codebase. It covers both enforceable rules and subjective "taste" elements that make our code consistent and maintainable.
## Table of Contents
1. [General Principles](#general-principles)
2. [TypeScript Conventions](#typescript-conventions)
3. [Imports & Dependencies](#imports--dependencies)
4. [Functions & Methods](#functions--methods)
5. [React & Components](#react--components)
6. [Error Handling](#error-handling)
7. [Async/Await Patterns](#asyncawait-patterns)
8. [Whitespace & Formatting](#whitespace--formatting)
9. [Naming Conventions](#naming-conventions)
10. [Pattern Matching](#pattern-matching)
11. [Database & Prisma](#database--prisma)
12. [TRPC Patterns](#trpc-patterns)
---
## General Principles
- **Functional over Object-Oriented**: Prefer functional programming patterns over classes
- **Explicit over Implicit**: Be explicit about types, return values, and error cases
- **Early Returns**: Use guard clauses and early returns to reduce nesting
- **Immutability**: Favor `const` over `let`; avoid mutation where possible
---
## TypeScript Conventions
### Type Definitions
```typescript
// ✅ Prefer `type` over `interface`
type CreateDocumentOptions = {
templateId: number;
userId: number;
recipients: Recipient[];
};
// ❌ Avoid interfaces unless absolutely necessary
interface CreateDocumentOptions {
templateId: number;
}
```
### Type Imports
```typescript
// ✅ Use `type` keyword for type-only imports
import type { Document, Recipient } from '@prisma/client';
import { DocumentStatus } from '@prisma/client';
// Types in function signatures
export const findDocuments = async ({ userId, teamId }: FindDocumentsOptions) => {
// ...
};
```
### Inline Types for Function Parameters
```typescript
// ✅ Extract inline types to named types
type FinalRecipient = Pick<Recipient, 'name' | 'email' | 'role' | 'authOptions'> & {
templateRecipientId: number;
fields: Field[];
};
const finalRecipients: FinalRecipient[] = [];
```
---
## Imports & Dependencies
### Import Organization
Imports should be organized in the following order with blank lines between groups:
```typescript
// 1. React imports
import { useCallback, useEffect, useMemo } from 'react';
// 2. Third-party library imports (alphabetically)
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import type { Document, Recipient } from '@prisma/client';
import { DocumentStatus, RecipientRole } from '@prisma/client';
import { match } from 'ts-pattern';
// 3. Internal package imports (from @documenso/*)
import { AppError } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { Button } from '@documenso/ui/primitives/button';
// 4. Relative imports
import { getTeamById } from '../team/get-team';
import type { FindResultResponse } from './types';
```
### Destructuring Imports
```typescript
// ✅ Destructure specific exports
// ✅ Use type imports for types
import type { Document } from '@prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import { Input } from '@documenso/ui/primitives/input';
```
---
## Functions & Methods
### Arrow Functions
```typescript
// ✅ Always use arrow functions for functions
export const createDocument = async ({
userId,
title,
}: CreateDocumentOptions) => {
// ...
};
// ✅ Callbacks and handlers
const onSubmit = useCallback(async () => {
// ...
}, [dependencies]);
// ❌ Avoid regular function declarations
function createDocument() {
// ...
}
```
### Function Parameters
```typescript
// ✅ Use destructured object parameters for multiple params
export const findDocuments = async ({
userId,
teamId,
status = ExtendedDocumentStatus.ALL,
page = 1,
perPage = 10,
}: FindDocumentsOptions) => {
// ...
};
// ✅ Destructure on separate line when needed
const onFormSubmit = form.handleSubmit(onSubmit);
// ✅ Deconstruct nested properties explicitly
const { user } = ctx;
const { templateId } = input;
```
---
## React & Components
### Component Definition
```typescript
// ✅ Use const with arrow function
export const AddSignersFormPartial = ({
documentFlow,
recipients,
fields,
onSubmit,
}: AddSignersFormProps) => {
// ...
};
// ❌ Never use classes
class MyComponent extends React.Component {
// ...
}
```
### Hooks
```typescript
// ✅ Group related hooks together with blank line separation
const { _ } = useLingui();
const { toast } = useToast();
const { currentStep, totalSteps, previousStep } = useStep();
const form = useForm<TFormSchema>({
resolver: zodResolver(ZFormSchema),
defaultValues: {
// ...
},
});
```
### Event Handlers
```typescript
// ✅ Use arrow functions with descriptive names
const onFormSubmit = async () => {
await form.trigger();
// ...
};
const onFieldCopy = useCallback(
(event?: KeyboardEvent | null) => {
event?.preventDefault();
// ...
},
[dependencies],
);
// ✅ Inline handlers for simple operations
<Button onClick={() => setOpen(false)}>Close</Button>
```
### State Management
```typescript
// ✅ Descriptive state names with auxiliary verbs
const [isLoading, setIsLoading] = useState(false);
const [hasError, setHasError] = useState(false);
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
// ✅ Complex state in single useState when related
const [coords, setCoords] = useState({
x: 0,
y: 0,
});
```
---
## Error Handling
### Try-Catch Blocks
```typescript
// ✅ Use try-catch for operations that might fail
try {
const document = await getDocumentById({
documentId: Number(documentId),
userId: user.id,
});
return {
status: 200,
body: document,
};
} catch (err) {
return {
status: 404,
body: {
message: 'Document not found',
},
};
}
```
### Throwing Errors
```typescript
// ✅ Use AppError for application errors
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Template not found',
});
// ✅ Use descriptive error messages
if (!template) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Template with ID ${templateId} not found`,
});
}
```
### Error Parsing on Frontend
```typescript
// ✅ Parse errors on the frontend
try {
await updateOrganisation({ organisationId, data });
} catch (err) {
const error = AppError.parseError(err);
console.error(error);
toast({
title: t`An error occurred`,
description: error.message,
variant: 'destructive',
});
}
```
---
## Async/Await Patterns
### Async Function Definitions
```typescript
// ✅ Mark async functions clearly
export const createDocument = async ({
userId,
title,
}: Options): Promise<Document> => {
// ...
};
// ✅ Use await for promises
const document = await prisma.document.create({ data });
// ✅ Use Promise.all for parallel operations
const [document, recipients] = await Promise.all([
getDocumentById({ documentId }),
getRecipientsForDocument({ documentId }),
]);
```
### Void for Fire-and-Forget
```typescript
// ✅ Use void for intentionally unwaited promises
void handleAutoSave();
// ✅ Or in event handlers
onClick={() => void onFormSubmit()}
```
---
## Whitespace & Formatting
### Blank Lines Between Concepts
```typescript
// ✅ Blank line after imports
import { prisma } from '@documenso/prisma';
export const findDocuments = async () => {
// ...
};
// ✅ Blank line between logical sections
const user = await prisma.user.findFirst({ where: { id: userId } });
let team = null;
if (teamId !== undefined) {
team = await getTeamById({ userId, teamId });
}
// ✅ Blank line before return statements
const result = await someOperation();
return result;
```
### Function/Method Spacing
```typescript
// ✅ No blank lines between chained methods in same operation
const documents = await prisma.document
.findMany({ where: { userId } })
.then((docs) => docs.map(maskTokens));
// ✅ Blank line between different operations
const document = await createDocument({ userId });
await sendDocument({ documentId: document.id });
return document;
```
### Object and Array Formatting
```typescript
// ✅ Multi-line when complex
const options = {
userId,
teamId,
status: ExtendedDocumentStatus.ALL,
page: 1,
};
// ✅ Single line when simple
const coords = { x: 0, y: 0 };
// ✅ Array items on separate lines when objects
const recipients = [
{
name: 'John',
email: 'john@example.com',
},
{
name: 'Jane',
email: 'jane@example.com',
},
];
```
---
## Naming Conventions
### Variables
```typescript
// ✅ camelCase for variables and functions
const documentId = 123;
const onSubmit = () => {};
// ✅ Descriptive names with auxiliary verbs for booleans
const isLoading = false;
const hasError = false;
const canEdit = true;
const shouldRender = true;
// ✅ Prefix with $ for DOM elements
const $page = document.querySelector('.page');
const $inputRef = useRef<HTMLInputElement>(null);
```
### Types and Schemas
```typescript
// ✅ PascalCase for types
type CreateDocumentOptions = {
userId: number;
};
// ✅ Prefix Zod schemas with Z
const ZCreateDocumentSchema = z.object({
title: z.string(),
});
// ✅ Prefix type from Zod schema with T
type TCreateDocumentSchema = z.infer<typeof ZCreateDocumentSchema>;
```
### Constants
```typescript
// ✅ UPPER_SNAKE_CASE for true constants
const DEFAULT_DOCUMENT_DATE_FORMAT = 'dd/MM/yyyy';
const MAX_FILE_SIZE = 1024 * 1024 * 5;
// ✅ camelCase for const variables that aren't "constants"
const userId = await getUserId();
```
### Functions
```typescript
// ✅ Verb-based names for functions
const createDocument = async () => {};
const findDocuments = async () => {};
const updateDocument = async () => {};
const deleteDocument = async () => {};
// ✅ On prefix for event handlers
const onSubmit = () => {};
const onClick = () => {};
const onFieldCopy = () => {}; // 'on' is also acceptable
```
### Clarity Over Brevity
```typescript
// ✅ Prefer descriptive names over abbreviations
const superLongMethodThatIsCorrect = () => {};
const recipientAuthenticationOptions = {};
const documentMetadata = {};
// ❌ Avoid abbreviations that sacrifice clarity
const supLongMethThatIsCorrect = () => {};
const recipAuthOpts = {};
const docMeta = {};
// ✅ Common abbreviations that are widely understood are acceptable
const userId = 123;
const htmlElement = document.querySelector('div');
const apiResponse = await fetch('/api');
```
---
## Pattern Matching
### Using ts-pattern
```typescript
import { match } from 'ts-pattern';
// ✅ Use match for complex conditionals
const result = match(status)
.with(ExtendedDocumentStatus.DRAFT, () => ({
status: 'draft',
}))
.with(ExtendedDocumentStatus.PENDING, () => ({
status: 'pending',
}))
.with(ExtendedDocumentStatus.COMPLETED, () => ({
status: 'completed',
}))
.exhaustive();
// ✅ Use .otherwise() for default case when not exhaustive
const value = match(type)
.with('text', () => 'Text field')
.with('number', () => 'Number field')
.otherwise(() => 'Unknown field');
```
---
## Database & Prisma
### Query Structure
```typescript
// ✅ Destructure commonly used fields
const { id, email, name } = user;
// ✅ Use select to limit returned fields
const user = await prisma.user.findFirst({
where: { id: userId },
select: {
id: true,
email: true,
name: true,
},
});
// ✅ Use include for relations
const document = await prisma.document.findFirst({
where: { id: documentId },
include: {
recipients: true,
fields: true,
},
});
```
### Transactions
```typescript
// ✅ Use transactions for related operations
return await prisma.$transaction(async (tx) => {
const document = await tx.document.create({ data });
await tx.field.createMany({ data: fieldsData });
await tx.documentAuditLog.create({ data: auditData });
return document;
});
```
### Where Clauses
```typescript
// ✅ Build complex where clauses separately
const whereClause: Prisma.DocumentWhereInput = {
AND: [
{ userId: user.id },
{ deletedAt: null },
{ status: { in: [DocumentStatus.DRAFT, DocumentStatus.PENDING] } },
],
};
const documents = await prisma.document.findMany({
where: whereClause,
});
```
---
## TRPC Patterns
### Router Structure
```typescript
// ✅ Destructure context and input at start
.query(async ({ input, ctx }) => {
const { teamId } = ctx;
const { templateId } = input;
ctx.logger.info({
input: { templateId },
});
return await getTemplateById({
id: templateId,
userId: ctx.user.id,
teamId,
});
});
```
### Request/Response Schemas
```typescript
// ✅ Name schemas clearly
const ZCreateDocumentRequestSchema = z.object({
title: z.string(),
recipients: z.array(ZRecipientSchema),
});
const ZCreateDocumentResponseSchema = z.object({
documentId: z.number(),
status: z.string(),
});
```
### Error Handling in TRPC
```typescript
// ✅ Catch and transform errors appropriately
try {
const result = await createDocument({ userId, data });
return result;
} catch (err) {
return AppError.toRestAPIError(err);
}
// ✅ Or throw AppError directly
if (!template) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Template not found',
});
}
```
---
## Additional Patterns
### Optional Chaining
```typescript
// ✅ Use optional chaining for potentially undefined values
const email = user?.email;
const recipientToken = recipient?.token ?? '';
// ✅ Use nullish coalescing for defaults
const pageSize = perPage ?? 10;
const status = documentStatus ?? DocumentStatus.DRAFT;
```
### Array Operations
```typescript
// ✅ Use functional array methods
const activeRecipients = recipients.filter((r) => r.signingStatus === 'SIGNED');
const recipientEmails = recipients.map((r) => r.email);
const hasSignedRecipients = recipients.some((r) => r.signingStatus === 'SIGNED');
// ✅ Use find instead of filter + [0]
const recipient = recipients.find((r) => r.id === recipientId);
```
### Conditional Rendering
```typescript
// ✅ Use && for conditional rendering
{isLoading && <Loader />}
// ✅ Use ternary for either/or
{isLoading ? <Loader /> : <Content />}
// ✅ Extract complex conditions to variables
const shouldShowAdvanced = isAdmin && hasPermission && !isDisabled;
{shouldShowAdvanced && <AdvancedSettings />}
```
---
## When in Doubt
- **Consistency**: Follow the patterns you see in similar files
- **Readability**: Favor code that's easy to read over clever one-liners
- **Explicitness**: Be explicit rather than implicit
- **Whitespace**: Use blank lines to separate logical sections
- **Early Returns**: Use guard clauses to reduce nesting
- **Functional**: Prefer functional patterns over imperative ones

View File

@ -5,7 +5,7 @@
"scripts": { "scripts": {
"dev": "next dev -p 3003", "dev": "next dev -p 3003",
"build": "next build", "build": "next build",
"start": "next start -p 3003", "start": "next start",
"lint:fix": "next lint --fix", "lint:fix": "next lint --fix",
"clean": "rimraf .next && rimraf node_modules" "clean": "rimraf .next && rimraf node_modules"
}, },

View File

@ -27,45 +27,9 @@
font-display: swap; font-display: swap;
} }
@font-face {
font-family: 'Noto Sans';
src: url('/fonts/noto-sans.ttf') format('truetype-variations');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
/* Korean noto sans */
@font-face {
font-family: 'Noto Sans Korean';
src: url('/fonts/noto-sans-korean.ttf') format('truetype-variations');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
/* Japanese noto sans */
@font-face {
font-family: 'Noto Sans Japanese';
src: url('/fonts/noto-sans-japanese.ttf') format('truetype-variations');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
/* Chinese noto sans */
@font-face {
font-family: 'Noto Sans Chinese';
src: url('/fonts/noto-sans-chinese.ttf') format('truetype-variations');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@layer base { @layer base {
:root { :root {
--font-sans: 'Inter'; --font-sans: 'Inter';
--font-signature: 'Caveat'; --font-signature: 'Caveat';
--font-noto: 'Noto Sans', 'Noto Sans Korean', 'Noto Sans Japanese', 'Noto Sans Chinese';
} }
} }

View File

@ -1,218 +0,0 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { OrganisationMemberRole } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisations';
import { trpc } from '@documenso/trpc/react';
import type { TGetAdminOrganisationResponse } from '@documenso/trpc/server/admin-router/get-admin-organisation.types';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type AdminOrganisationMemberUpdateDialogProps = {
trigger?: React.ReactNode;
organisationId: string;
organisationMember: TGetAdminOrganisationResponse['members'][number];
isOwner: boolean;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZUpdateOrganisationMemberFormSchema = z.object({
role: z.enum(['OWNER', 'ADMIN', 'MANAGER', 'MEMBER']),
});
type ZUpdateOrganisationMemberSchema = z.infer<typeof ZUpdateOrganisationMemberFormSchema>;
export const AdminOrganisationMemberUpdateDialog = ({
trigger,
organisationId,
organisationMember,
isOwner,
...props
}: AdminOrganisationMemberUpdateDialogProps) => {
const [open, setOpen] = useState(false);
const { t } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
// Determine the current role value for the form
const currentRoleValue = isOwner
? 'OWNER'
: getHighestOrganisationRoleInGroup(
organisationMember.organisationGroupMembers.map((ogm) => ogm.group),
);
const organisationMemberName = organisationMember.user.name ?? organisationMember.user.email;
const form = useForm<ZUpdateOrganisationMemberSchema>({
resolver: zodResolver(ZUpdateOrganisationMemberFormSchema),
defaultValues: {
role: currentRoleValue,
},
});
const { mutateAsync: updateOrganisationMemberRole } =
trpc.admin.organisationMember.updateRole.useMutation();
const onFormSubmit = async ({ role }: ZUpdateOrganisationMemberSchema) => {
try {
await updateOrganisationMemberRole({
organisationId,
userId: organisationMember.userId,
role,
});
const roleLabel = match(role)
.with('OWNER', () => t`Owner`)
.with(OrganisationMemberRole.ADMIN, () => t`Admin`)
.with(OrganisationMemberRole.MANAGER, () => t`Manager`)
.with(OrganisationMemberRole.MEMBER, () => t`Member`)
.exhaustive();
toast({
title: t`Success`,
description:
role === 'OWNER'
? t`Ownership transferred to ${organisationMemberName}.`
: t`Updated ${organisationMemberName} to ${roleLabel}.`,
duration: 5000,
});
setOpen(false);
// Refresh the page to show updated data
await navigate(0);
} catch (err) {
console.error(err);
toast({
title: t`An unknown error occurred`,
description: t`We encountered an unknown error while attempting to update this organisation member. Please try again later.`,
variant: 'destructive',
});
}
};
useEffect(() => {
if (!open) {
return;
}
form.reset({
role: currentRoleValue,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, currentRoleValue, form]);
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger ?? (
<Button variant="secondary">
<Trans>Update role</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Update organisation member</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>
You are currently updating{' '}
<span className="font-bold">{organisationMemberName}.</span>
</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel required>
<Trans>Role</Trans>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="text-muted-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent className="w-full" position="popper">
<SelectItem value="OWNER">
<Trans>Owner</Trans>
</SelectItem>
<SelectItem value={OrganisationMemberRole.ADMIN}>
<Trans>Admin</Trans>
</SelectItem>
<SelectItem value={OrganisationMemberRole.MANAGER}>
<Trans>Manager</Trans>
</SelectItem>
<SelectItem value={OrganisationMemberRole.MEMBER}>
<Trans>Member</Trans>
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="mt-4">
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -19,15 +19,13 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team'; import { useCurrentTeam } from '~/providers/team';
type DocumentDuplicateDialogProps = { type DocumentDuplicateDialogProps = {
id: string; id: number;
token?: string;
open: boolean; open: boolean;
onOpenChange: (_open: boolean) => void; onOpenChange: (_open: boolean) => void;
}; };
export const DocumentDuplicateDialog = ({ export const DocumentDuplicateDialog = ({
id, id,
token,
open, open,
onOpenChange, onOpenChange,
}: DocumentDuplicateDialogProps) => { }: DocumentDuplicateDialogProps) => {
@ -38,23 +36,27 @@ export const DocumentDuplicateDialog = ({
const team = useCurrentTeam(); const team = useCurrentTeam();
const { data: envelopeItemsPayload, isLoading: isLoadingEnvelopeItems } = const { data: document, isLoading } = trpcReact.document.get.useQuery(
trpcReact.envelope.item.getManyByToken.useQuery( {
{ documentId: id,
envelopeId: id, },
access: token ? { type: 'recipient', token } : { type: 'user' }, {
}, queryHash: `document-duplicate-dialog-${id}`,
{ enabled: open === true,
enabled: open, },
}, );
);
const envelopeItems = envelopeItemsPayload?.data || []; const documentData = document?.documentData
? {
...document.documentData,
data: document.documentData.initialData,
}
: undefined;
const documentsPath = formatDocumentsPath(team.url); const documentsPath = formatDocumentsPath(team.url);
const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } = const { mutateAsync: duplicateDocument, isPending: isDuplicateLoading } =
trpcReact.envelope.duplicate.useMutation({ trpcReact.document.duplicate.useMutation({
onSuccess: async ({ id }) => { onSuccess: async ({ id }) => {
toast({ toast({
title: _(msg`Document Duplicated`), title: _(msg`Document Duplicated`),
@ -69,7 +71,7 @@ export const DocumentDuplicateDialog = ({
const onDuplicate = async () => { const onDuplicate = async () => {
try { try {
await duplicateEnvelope({ envelopeId: id }); await duplicateDocument({ documentId: id });
} catch { } catch {
toast({ toast({
title: _(msg`Something went wrong`), title: _(msg`Something went wrong`),
@ -81,14 +83,14 @@ export const DocumentDuplicateDialog = ({
}; };
return ( return (
<Dialog open={open} onOpenChange={(value) => !isDuplicating && onOpenChange(value)}> <Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
<Trans>Duplicate</Trans> <Trans>Duplicate</Trans>
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
{isLoadingEnvelopeItems || !envelopeItems || envelopeItems.length === 0 ? ( {!documentData || isLoading ? (
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8"> <div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl"> <h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
<Trans>Loading Document...</Trans> <Trans>Loading Document...</Trans>
@ -96,12 +98,7 @@ export const DocumentDuplicateDialog = ({
</div> </div>
) : ( ) : (
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll"> <div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll">
<PDFViewer <PDFViewer key={document?.id} documentData={documentData} />
key={envelopeItems[0].id}
envelopeItem={envelopeItems[0]}
token={undefined}
version="original"
/>
</div> </div>
)} )}
@ -118,8 +115,8 @@ export const DocumentDuplicateDialog = ({
<Button <Button
type="button" type="button"
disabled={isDuplicating} disabled={isDuplicateLoading || isLoading}
loading={isDuplicating} loading={isDuplicateLoading}
onClick={onDuplicate} onClick={onDuplicate}
className="flex-1" className="flex-1"
> >

View File

@ -15,16 +15,17 @@ import {
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { InfoIcon } from 'lucide-react'; import { InfoIcon } from 'lucide-react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import * as z from 'zod'; import * as z from 'zod';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TEnvelope } from '@documenso/lib/types/envelope'; import type { TEnvelope } from '@documenso/lib/types/envelope';
import { formatSigningLink } from '@documenso/lib/utils/recipients';
import { trpc, trpc as trpcReact } from '@documenso/trpc/react'; import { trpc, trpc as trpcReact } from '@documenso/trpc/react';
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper'; import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
Dialog, Dialog,
@ -60,10 +61,8 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export type EnvelopeDistributeDialogProps = { export type EnvelopeDistributeDialogProps = {
envelope: Pick<TEnvelope, 'id' | 'userId' | 'teamId' | 'status' | 'type' | 'documentMeta'> & { envelope: Pick<TEnvelope, 'id' | 'userId' | 'teamId' | 'status' | 'type' | 'documentMeta'> & {
recipients: Recipient[]; recipients: Recipient[];
fields: Pick<Field, 'type' | 'recipientId'>[]; fields: Field[];
}; };
onDistribute?: () => Promise<void>;
documentRootPath: string;
trigger?: React.ReactNode; trigger?: React.ReactNode;
}; };
@ -85,19 +84,13 @@ export const ZEnvelopeDistributeFormSchema = z.object({
export type TEnvelopeDistributeFormSchema = z.infer<typeof ZEnvelopeDistributeFormSchema>; export type TEnvelopeDistributeFormSchema = z.infer<typeof ZEnvelopeDistributeFormSchema>;
export const EnvelopeDistributeDialog = ({ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistributeDialogProps) => {
envelope,
trigger,
documentRootPath,
onDistribute,
}: EnvelopeDistributeDialogProps) => {
const organisation = useCurrentOrganisation(); const organisation = useCurrentOrganisation();
const recipients = envelope.recipients; const recipients = envelope.recipients;
const { toast } = useToast(); const { toast } = useToast();
const { t } = useLingui(); const { t } = useLingui();
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@ -134,44 +127,22 @@ export const EnvelopeDistributeDialog = ({
const distributionMethod = watch('meta.distributionMethod'); const distributionMethod = watch('meta.distributionMethod');
const recipientsMissingSignatureFields = useMemo( const everySignerHasSignature = useMemo(
() => () =>
envelope.recipients.filter( envelope.recipients
(recipient) => .filter((recipient) => recipient.role === RecipientRole.SIGNER)
recipient.role === RecipientRole.SIGNER && .every((recipient) =>
!envelope.fields.some( envelope.fields.some(
(field) => field.type === FieldType.SIGNATURE && field.recipientId === recipient.id, (field) => field.type === FieldType.SIGNATURE && field.recipientId === recipient.id,
), ),
), ),
[envelope.recipients, envelope.fields], [envelope.recipients, envelope.fields],
); );
const invalidEnvelopeCode = useMemo(() => {
if (recipientsMissingSignatureFields.length > 0) {
return 'MISSING_SIGNATURES';
}
if (envelope.recipients.length === 0) {
return 'MISSING_RECIPIENTS';
}
return null;
}, [envelope.recipients, envelope.fields, recipientsMissingSignatureFields]);
const onFormSubmit = async ({ meta }: TEnvelopeDistributeFormSchema) => { const onFormSubmit = async ({ meta }: TEnvelopeDistributeFormSchema) => {
try { try {
await distributeEnvelope({ envelopeId: envelope.id, meta }); await distributeEnvelope({ envelopeId: envelope.id, meta });
await onDistribute?.();
let redirectPath = `${documentRootPath}/${envelope.id}`;
if (meta.distributionMethod === DocumentDistributionMethod.NONE) {
redirectPath += '?action=copy-links';
}
await navigate(redirectPath);
toast({ toast({
title: t`Envelope distributed`, title: t`Envelope distributed`,
description: t`Your envelope has been distributed successfully.`, description: t`Your envelope has been distributed successfully.`,
@ -207,8 +178,7 @@ export const EnvelopeDistributeDialog = ({
<Trans>Recipients will be able to sign the document once sent</Trans> <Trans>Recipients will be able to sign the document once sent</Trans>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{everySignerHasSignature ? (
{!invalidEnvelopeCode ? (
<Form {...form}> <Form {...form}>
<form onSubmit={handleSubmit(onFormSubmit)}> <form onSubmit={handleSubmit(onFormSubmit)}>
<fieldset disabled={isSubmitting}> <fieldset disabled={isSubmitting}>
@ -230,11 +200,7 @@ export const EnvelopeDistributeDialog = ({
</TabsList> </TabsList>
</Tabs> </Tabs>
<div <div className="min-h-72">
className={cn('min-h-72', {
'min-h-[23rem]': organisation.organisationClaim.flags.emailDomains,
})}
>
<AnimatePresence initial={false} mode="wait"> <AnimatePresence initial={false} mode="wait">
{distributionMethod === DocumentDistributionMethod.EMAIL && ( {distributionMethod === DocumentDistributionMethod.EMAIL && (
<motion.div <motion.div
@ -336,7 +302,7 @@ export const EnvelopeDistributeDialog = ({
<Trans>Message</Trans>{' '} <Trans>Message</Trans>{' '}
<span className="text-muted-foreground">(Optional)</span> <span className="text-muted-foreground">(Optional)</span>
<Tooltip> <Tooltip>
<TooltipTrigger type="button"> <TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" /> <InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className="text-muted-foreground p-4"> <TooltipContent className="text-muted-foreground p-4">
@ -369,18 +335,71 @@ export const EnvelopeDistributeDialog = ({
exit={{ opacity: 0, transition: { duration: 0.15 } }} exit={{ opacity: 0, transition: { duration: 0.15 } }}
className="min-h-60 rounded-lg border" className="min-h-60 rounded-lg border"
> >
<div className="text-muted-foreground py-24 text-center text-sm"> {envelope.status === DocumentStatus.DRAFT ? (
<p> <div className="text-muted-foreground py-24 text-center text-sm">
<Trans>We won't send anything to notify recipients.</Trans> <p>
</p> <Trans>We won't send anything to notify recipients.</Trans>
</p>
<p className="mt-2"> <p className="mt-2">
<Trans> <Trans>
We will generate signing links for you, which you can send to the We will generate signing links for you, which you can send to the
recipients through your method of choice. recipients through your method of choice.
</Trans> </Trans>
</p> </p>
</div> </div>
) : (
<ul className="text-muted-foreground divide-y">
{recipients.length === 0 && (
<li className="flex flex-col items-center justify-center py-6 text-sm">
<Trans>No recipients</Trans>
</li>
)}
{recipients.map((recipient) => (
<li
key={recipient.id}
className="flex items-center justify-between px-4 py-3 text-sm"
>
<AvatarWithText
avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
primaryText={
<p className="text-muted-foreground text-sm">
{recipient.email}
</p>
}
secondaryText={
<p className="text-muted-foreground/70 text-xs">
{t(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
}
/>
{recipient.role !== RecipientRole.CC && (
<CopyTextButton
value={formatSigningLink(recipient.token)}
onCopySuccess={() => {
toast({
title: t`Copied to clipboard`,
description: t`The signing link has been copied to your clipboard.`,
});
}}
badgeContentUncopied={
<p className="ml-1 text-xs">
<Trans>Copy</Trans>
</p>
}
badgeContentCopied={
<p className="ml-1 text-xs">
<Trans>Copied</Trans>
</p>
}
/>
)}
</li>
))}
</ul>
)}
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
@ -407,24 +426,12 @@ export const EnvelopeDistributeDialog = ({
) : ( ) : (
<> <>
<Alert variant="warning"> <Alert variant="warning">
{match(invalidEnvelopeCode) <AlertDescription>
.with('MISSING_RECIPIENTS', () => ( <Trans>
<AlertDescription> Some signers have not been assigned a signature field. Please assign at least 1
<Trans>You need at least one recipient to send a document</Trans> signature field to each signer before proceeding.
</AlertDescription> </Trans>
)) </AlertDescription>
.with('MISSING_SIGNATURES', () => (
<AlertDescription>
<Trans>The following signers are missing signature fields:</Trans>
<ul className="ml-2 mt-1 list-inside list-disc">
{recipientsMissingSignatureFields.map((recipient) => (
<li key={recipient.id}>{recipient.email}</li>
))}
</ul>
</AlertDescription>
))
.exhaustive()}
</Alert> </Alert>
<DialogFooter> <DialogFooter>

View File

@ -1,204 +0,0 @@
import { useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, type EnvelopeItem } from '@prisma/client';
import { DownloadIcon, FileTextIcon } from 'lucide-react';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { useToast } from '@documenso/ui/primitives/use-toast';
type EnvelopeItemToDownload = Pick<EnvelopeItem, 'id' | 'envelopeId' | 'title' | 'order'>;
type EnvelopeDownloadDialogProps = {
envelopeId: string;
envelopeStatus: DocumentStatus;
envelopeItems?: EnvelopeItemToDownload[];
/**
* The recipient token to download the document.
*
* If not provided, it will be assumed that the current user can access the document.
*/
token?: string;
trigger: React.ReactNode;
};
export const EnvelopeDownloadDialog = ({
envelopeId,
envelopeStatus,
envelopeItems: initialEnvelopeItems,
token,
trigger,
}: EnvelopeDownloadDialogProps) => {
const { toast } = useToast();
const { t } = useLingui();
const [open, setOpen] = useState(false);
const [isDownloadingState, setIsDownloadingState] = useState<{
[envelopeItemIdAndVersion: string]: boolean;
}>({});
const generateDownloadKey = (envelopeItemId: string, version: 'original' | 'signed') =>
`${envelopeItemId}-${version}`;
const { data: envelopeItemsPayload, isLoading: isLoadingEnvelopeItems } =
trpc.envelope.item.getManyByToken.useQuery(
{
envelopeId,
access: token ? { type: 'recipient', token } : { type: 'user' },
},
{
initialData: initialEnvelopeItems ? { data: initialEnvelopeItems } : undefined,
enabled: open,
},
);
const envelopeItems = envelopeItemsPayload?.data || [];
const onDownload = async (
envelopeItem: EnvelopeItemToDownload,
version: 'original' | 'signed',
) => {
const { id: envelopeItemId } = envelopeItem;
if (isDownloadingState[generateDownloadKey(envelopeItemId, version)]) {
return;
}
setIsDownloadingState((prev) => ({
...prev,
[generateDownloadKey(envelopeItemId, version)]: true,
}));
try {
await downloadPDF({
envelopeItem,
token,
fileName: envelopeItem.title,
version,
});
setIsDownloadingState((prev) => ({
...prev,
[generateDownloadKey(envelopeItemId, version)]: false,
}));
} catch (error) {
setIsDownloadingState((prev) => ({
...prev,
[generateDownloadKey(envelopeItemId, version)]: false,
}));
console.error(error);
toast({
title: t`Something went wrong`,
description: t`This document could not be downloaded at this time. Please try again.`,
variant: 'destructive',
duration: 7500,
});
}
};
return (
<Dialog open={open} onOpenChange={(value) => setOpen(value)}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Download Files</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Select the files you would like to download.</Trans>
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
{isLoadingEnvelopeItems ? (
<>
{Array.from({ length: 1 }).map((_, index) => (
<div
key={index}
className="border-border bg-card flex items-center gap-2 rounded-lg border p-4"
>
<Skeleton className="h-10 w-10 flex-shrink-0 rounded-lg" />
<div className="flex w-full flex-col gap-2">
<Skeleton className="h-4 w-28 rounded-lg" />
<Skeleton className="h-4 w-20 rounded-lg" />
</div>
<Skeleton className="h-10 w-20 flex-shrink-0 rounded-lg" />
</div>
))}
</>
) : (
envelopeItems.map((item) => (
<div
key={item.id}
className="border-border bg-card hover:bg-accent/50 flex items-center gap-4 rounded-lg border p-4 transition-colors"
>
<div className="flex-shrink-0">
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg">
<FileTextIcon className="text-primary h-5 w-5" />
</div>
</div>
<div className="min-w-0 flex-1">
{/* Todo: Envelopes - Fix overflow */}
<h4 className="text-foreground truncate text-sm font-medium">{item.title}</h4>
<p className="text-muted-foreground mt-0.5 text-xs">
<Trans>PDF Document</Trans>
</p>
</div>
<div className="flex flex-shrink-0 items-center gap-2">
<Button
variant="outline"
size="sm"
className="text-xs"
onClick={async () => onDownload(item, 'original')}
loading={isDownloadingState[generateDownloadKey(item.id, 'original')]}
>
{!isDownloadingState[generateDownloadKey(item.id, 'original')] && (
<DownloadIcon className="mr-2 h-4 w-4" />
)}
<Trans context="Original document (adjective)">Original</Trans>
</Button>
{envelopeStatus === DocumentStatus.COMPLETED && (
<Button
variant="default"
size="sm"
className="text-xs"
onClick={async () => onDownload(item, 'signed')}
loading={isDownloadingState[generateDownloadKey(item.id, 'signed')]}
>
{!isDownloadingState[generateDownloadKey(item.id, 'signed')] && (
<DownloadIcon className="mr-2 h-4 w-4" />
)}
<Trans context="Signed document (adjective)">Signed</Trans>
</Button>
)}
</div>
</div>
))
)}
</div>
</DialogContent>
</Dialog>
);
};

View File

@ -43,7 +43,7 @@ export const EnvelopeDuplicateDialog = ({
const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } = const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } =
trpc.envelope.duplicate.useMutation({ trpc.envelope.duplicate.useMutation({
onSuccess: async ({ id }) => { onSuccess: async ({ duplicatedEnvelopeId }) => {
toast({ toast({
title: t`Envelope Duplicated`, title: t`Envelope Duplicated`,
description: t`Your envelope has been successfully duplicated.`, description: t`Your envelope has been successfully duplicated.`,
@ -55,7 +55,7 @@ export const EnvelopeDuplicateDialog = ({
? formatDocumentsPath(team.url) ? formatDocumentsPath(team.url)
: formatTemplatesPath(team.url); : formatTemplatesPath(team.url);
await navigate(`${path}/${id}/edit`); await navigate(`${path}/${duplicatedEnvelopeId}/edit`);
setOpen(false); setOpen(false);
}, },
}); });

View File

@ -3,7 +3,7 @@ import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog'; import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { startRegistration } from '@simplewebauthn/browser'; import { startRegistration } from '@simplewebauthn/browser';
import { KeyRoundIcon } from 'lucide-react'; import { KeyRoundIcon } from 'lucide-react';
@ -209,11 +209,7 @@ export const PasskeyCreateDialog = ({ trigger, onSuccess, ...props }: PasskeyCre
)) ))
.with('TOO_MANY_PASSKEYS', () => ( .with('TOO_MANY_PASSKEYS', () => (
<AlertDescription> <AlertDescription>
<Plural <Trans>You cannot have more than {MAXIMUM_PASSKEYS} passkeys.</Trans>
value={MAXIMUM_PASSKEYS}
one="You cannot have more than # passkey."
other="You cannot have more than # passkeys."
/>
</AlertDescription> </AlertDescription>
)) ))
.with('InvalidStateError', () => ( .with('InvalidStateError', () => (

View File

@ -1,186 +0,0 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { Plural, Trans } from '@lingui/react/macro';
import { createCallable } from 'react-call';
import { useForm, useWatch } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { validateCheckboxLength } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
import { type TCheckboxFieldMeta } from '@documenso/lib/types/field-meta';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { Form, FormControl, FormField, FormItem } from '@documenso/ui/primitives/form/form';
export type SignFieldCheckboxDialogProps = {
fieldMeta: TCheckboxFieldMeta;
validationRule: '>=' | '=' | '<=';
validationLength: number;
preselectedIndices: number[];
};
export const SignFieldCheckboxDialog = createCallable<
SignFieldCheckboxDialogProps,
number[] | null
>(({ call, fieldMeta, validationRule, validationLength, preselectedIndices }) => {
const ZSignFieldCheckboxFormSchema = z
.object({
values: z.array(
z.object({
checked: z.boolean(),
value: z.string(),
}),
),
})
.superRefine((data, ctx) => {
// Allow unselecting all options if the field is not required even if
// validation is not met.
if (!fieldMeta.required && data.values.every((value) => !value.checked)) {
return;
}
const numberOfSelectedValues = data.values.filter((value) => value.checked).length;
const isValid = validateCheckboxLength(
numberOfSelectedValues,
validationRule,
validationLength,
);
if (!isValid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: msg`Validation failed`.id,
});
}
});
const form = useForm<z.infer<typeof ZSignFieldCheckboxFormSchema>>({
resolver: zodResolver(ZSignFieldCheckboxFormSchema),
defaultValues: {
values: (fieldMeta.values || []).map((value, index) => ({
checked: preselectedIndices.includes(index) || false,
value: value.value,
})),
},
});
const formValues = useWatch({
control: form.control,
});
return (
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Sign Checkbox Field</Trans>
</DialogTitle>
<DialogDescription
className={cn('mt-4', {
'text-destructive': Object.keys(form.formState.errors).length > 0,
})}
>
{match(validationRule)
.with('>=', () => (
<Plural
value={validationLength}
one="Select at least # option"
other="Select at least # options"
/>
))
.with('=', () => (
<Plural
value={validationLength}
one="Select exactly # option"
other="Select exactly # options"
/>
))
.with('<=', () => (
<Plural
value={validationLength}
one="Select at most # option"
other="Select at most # options"
/>
))
.exhaustive()}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit((data) =>
call.end(
data.values
.map((value, i) => (value.checked ? i : null))
.filter((value) => value !== null),
),
)}
>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<ul className="space-y-3">
{(formValues.values || []).map((value, index) => (
<li key={`checkbox-${index}`}>
<FormField
control={form.control}
name={`values.${index}`}
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex items-center">
<Checkbox
id={`checkbox-value-${index}`}
className="data-[state=checked]:bg-primary border-foreground/30 h-5 w-5"
checked={field.value.checked}
onCheckedChange={(checked) => {
field.onChange({
...field.value,
checked,
});
}}
/>
<label
className="text-muted-foreground ml-2 w-full text-sm"
htmlFor={`checkbox-value-${index}`}
>
{value.value}
</label>
</div>
</FormControl>
</FormItem>
)}
/>
</li>
))}
</ul>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => call.end(null)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit">
<Trans>Sign</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
});

View File

@ -1,15 +1,40 @@
import { useLingui } from '@lingui/react/macro'; import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { createCallable } from 'react-call'; import { createCallable } from 'react-call';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import type { TDropdownFieldMeta } from '@documenso/lib/types/field-meta'; import type { TDropdownFieldMeta } from '@documenso/lib/types/field-meta';
import { Button } from '@documenso/ui/primitives/button';
import { import {
CommandDialog, Dialog,
CommandEmpty, DialogContent,
CommandGroup, DialogDescription,
CommandInput, DialogFooter,
CommandItem, DialogHeader,
CommandList, DialogTitle,
} from '@documenso/ui/primitives/command'; } from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
const ZSignFieldDropdownFormSchema = z.object({
dropdown: z.string().min(1, { message: msg`Option is required`.id }),
});
type TSignFieldDropdownFormSchema = z.infer<typeof ZSignFieldDropdownFormSchema>;
export type SignFieldDropdownDialogProps = { export type SignFieldDropdownDialogProps = {
fieldMeta: TDropdownFieldMeta; fieldMeta: TDropdownFieldMeta;
@ -21,25 +46,72 @@ export const SignFieldDropdownDialog = createCallable<SignFieldDropdownDialogPro
const values = fieldMeta.values?.map((value) => value.value) ?? []; const values = fieldMeta.values?.map((value) => value.value) ?? [];
const form = useForm<TSignFieldDropdownFormSchema>({
resolver: zodResolver(ZSignFieldDropdownFormSchema),
defaultValues: {
dropdown: fieldMeta.defaultValue,
},
});
return ( return (
<CommandDialog <Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
position="start" <DialogContent position="center">
dialogContentClassName="mt-4" <DialogHeader>
open={true} <DialogTitle>
onOpenChange={(value) => (!value ? call.end(null) : null)} <Trans>Sign Dropdown Field</Trans>
> </DialogTitle>
<CommandInput placeholder={t`Select an option`} />
<CommandList> <DialogDescription className="mt-4">
<CommandEmpty>No results found.</CommandEmpty> <Trans>Select a value to sign into the field</Trans>
<CommandGroup heading={t`Options`}> </DialogDescription>
{values.map((value, i) => ( </DialogHeader>
<CommandItem onSelect={() => call.end(value)} key={i} value={value}>
{value} <Form {...form}>
</CommandItem> <form onSubmit={form.handleSubmit((data) => call.end(data.dropdown))}>
))} <fieldset
</CommandGroup> className="flex h-full flex-col space-y-4"
</CommandList> disabled={form.formState.isSubmitting}
</CommandDialog> >
<FormField
control={form.control}
name="dropdown"
render={({ field }) => (
<FormItem>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="bg-background">
<SelectValue placeholder={t`Select an option`} />
</SelectTrigger>
<SelectContent>
{values.map((value, i) => (
<SelectItem key={i} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => call.end(null)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit">
<Trans>Sign</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
); );
}, },
); );

View File

@ -29,22 +29,20 @@ const ZSignFieldEmailFormSchema = z.object({
type TSignFieldEmailFormSchema = z.infer<typeof ZSignFieldEmailFormSchema>; type TSignFieldEmailFormSchema = z.infer<typeof ZSignFieldEmailFormSchema>;
export type SignFieldEmailDialogProps = { export type SignFieldEmailDialogProps = Record<string, never>;
placeholderEmail: string | null;
};
export const SignFieldEmailDialog = createCallable<SignFieldEmailDialogProps, string | null>( export const SignFieldEmailDialog = createCallable<SignFieldEmailDialogProps, string | null>(
({ call, placeholderEmail }) => { ({ call }) => {
const form = useForm<TSignFieldEmailFormSchema>({ const form = useForm<TSignFieldEmailFormSchema>({
resolver: zodResolver(ZSignFieldEmailFormSchema), resolver: zodResolver(ZSignFieldEmailFormSchema),
defaultValues: { defaultValues: {
email: placeholderEmail || '', email: '',
}, },
}); });
return ( return (
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}> <Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
<DialogContent> <DialogContent position="center">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
<Trans>Sign Email</Trans> <Trans>Sign Email</Trans>

View File

@ -45,7 +45,7 @@ export const SignFieldInitialsDialog = createCallable<SignFieldInitialsDialogPro
return ( return (
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}> <Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
<DialogContent> <DialogContent position="center">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
<Trans>Sign Initials</Trans> <Trans>Sign Initials</Trans>

View File

@ -44,7 +44,7 @@ export const SignFieldNameDialog = createCallable<SignFieldNameDialogProps, stri
return ( return (
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}> <Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
<DialogContent> <DialogContent position="center">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
<Trans>Sign Name</Trans> <Trans>Sign Name</Trans>

View File

@ -1,4 +1,5 @@
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { createCallable } from 'react-call'; import { createCallable } from 'react-call';
@ -27,71 +28,51 @@ import {
} from '@documenso/ui/primitives/form/form'; } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
const createNumberFieldSchema = (fieldMeta: TNumberFieldMeta) => {
let schema = z.coerce.number({
invalid_type_error: msg`Please enter a valid number`.id, // Todo: Envelopes - Check that this works
});
const { numberFormat, minValue, maxValue } = fieldMeta;
if (typeof minValue === 'number') {
schema = schema.min(minValue);
}
if (typeof maxValue === 'number') {
schema = schema.max(maxValue);
}
if (numberFormat) {
const foundRegex = numberFormatValues.find((item) => item.value === numberFormat)?.regex;
if (!foundRegex) {
return schema;
}
return schema.refine(
(value) => {
return foundRegex.test(value.toString());
},
{
message: `Number needs to be formatted as ${numberFormat}`,
// Todo: Envelopes
// message: msg`Number needs to be formatted as ${numberFormat}`.id,
},
);
}
return schema;
};
export type SignFieldNumberDialogProps = { export type SignFieldNumberDialogProps = {
fieldMeta: TNumberFieldMeta; fieldMeta: TNumberFieldMeta;
}; };
export const SignFieldNumberDialog = createCallable<SignFieldNumberDialogProps, string | null>( export const SignFieldNumberDialog = createCallable<SignFieldNumberDialogProps, number | null>(
({ call, fieldMeta }) => { ({ call, fieldMeta }) => {
const { t } = useLingui(); const { t } = useLingui();
// Needs to be inside dialog for translation purposes.
const createNumberFieldSchema = (fieldMeta: TNumberFieldMeta) => {
const { numberFormat, minValue, maxValue } = fieldMeta;
if (numberFormat) {
const foundRegex = numberFormatValues.find((item) => item.value === numberFormat)?.regex;
if (foundRegex) {
return z.string().refine(
(value) => {
return foundRegex.test(value.toString());
},
{
message: t`Number needs to be formatted as ${numberFormat}`,
},
);
}
}
// Not gong to work with min/max numbers + number format
// Since currently doesn't work in V1 going to ignore for now.
return z.string().superRefine((value, ctx) => {
const isValidNumber = /^[0-9,.]+$/.test(value.toString());
if (!isValidNumber) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t`Please enter a valid number`,
});
return;
}
if (typeof minValue === 'number' && parseFloat(value) < minValue) {
ctx.addIssue({
code: z.ZodIssueCode.too_small,
minimum: minValue,
inclusive: true,
type: 'number',
});
return;
}
if (typeof maxValue === 'number' && parseFloat(value) > maxValue) {
ctx.addIssue({
code: z.ZodIssueCode.too_big,
maximum: maxValue,
inclusive: true,
type: 'number',
});
return;
}
});
};
const ZSignFieldNumberFormSchema = z.object({ const ZSignFieldNumberFormSchema = z.object({
number: createNumberFieldSchema(fieldMeta), number: createNumberFieldSchema(fieldMeta),
}); });
@ -105,7 +86,7 @@ export const SignFieldNumberDialog = createCallable<SignFieldNumberDialogProps,
return ( return (
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}> <Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
<DialogContent> <DialogContent position="center">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
<Trans>Sign Number Field</Trans> <Trans>Sign Number Field</Trans>

View File

@ -50,7 +50,7 @@ export const SignFieldTextDialog = createCallable<SignFieldTextDialogProps, stri
return ( return (
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}> <Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
<DialogContent> <DialogContent position="center">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
<Trans>Sign Text Field</Trans> <Trans>Sign Text Field</Trans>

View File

@ -7,9 +7,9 @@ import { FilePlus, Loader } from 'lucide-react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatTemplatesPath } from '@documenso/lib/utils/teams'; import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { TCreateTemplatePayloadSchema } from '@documenso/trpc/server/template-router/schema';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
Dialog, Dialog,
@ -54,17 +54,13 @@ export const TemplateCreateDialog = ({ folderId }: TemplateCreateDialogProps) =>
setIsUploadingFile(true); setIsUploadingFile(true);
try { try {
const payload = { const response = await putPdfFile(file);
const { legacyTemplateId: id } = await createTemplate({
title: file.name, title: file.name,
templateDocumentDataId: response.id,
folderId: folderId, folderId: folderId,
} satisfies TCreateTemplatePayloadSchema; });
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('file', file);
const { envelopeId: id } = await createTemplate(formData);
toast({ toast({
title: _(msg`Template document uploaded`), title: _(msg`Template document uploaded`),
@ -96,7 +92,7 @@ export const TemplateCreateDialog = ({ folderId }: TemplateCreateDialogProps) =>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button className="cursor-pointer" disabled={!user.emailVerified}> <Button className="cursor-pointer" disabled={!user.emailVerified}>
<FilePlus className="-ml-1 mr-2 h-4 w-4" /> <FilePlus className="-ml-1 mr-2 h-4 w-4" />
<Trans>Template (Legacy)</Trans> <Trans>New Template</Trans>
</Button> </Button>
</DialogTrigger> </DialogTrigger>

View File

@ -265,7 +265,7 @@ export const TemplateDirectLinkDialog = ({
{remaining.directTemplates !== 0 && ( {remaining.directTemplates !== 0 && (
<DialogFooter className="mx-auto mt-4"> <DialogFooter className="mx-auto mt-4">
<Button type="button" onClick={() => setCurrentStep('SELECT_RECIPIENT')}> <Button type="button" onClick={() => setCurrentStep('SELECT_RECIPIENT')}>
<Trans>Enable direct link signing</Trans> <Trans> Enable direct link signing</Trans>
</Button> </Button>
</DialogFooter> </DialogFooter>
)} )}

View File

@ -6,7 +6,7 @@ import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { Recipient } from '@prisma/client'; import type { Recipient } from '@prisma/client';
import { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/client'; import { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/client';
import { FileTextIcon, InfoIcon, Plus, UploadCloudIcon, X } from 'lucide-react'; import { InfoIcon, Plus, Upload, X } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import * as z from 'zod'; import * as z from 'zod';
@ -16,10 +16,6 @@ import {
TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX, TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX, TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
} from '@documenso/lib/constants/template'; } from '@documenso/lib/constants/template';
import {
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
SKIP_QUERY_BATCH_META,
} from '@documenso/lib/constants/trpc';
import { AppError } from '@documenso/lib/errors/app-error'; import { AppError } from '@documenso/lib/errors/app-error';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
@ -45,7 +41,6 @@ import {
FormMessage, FormMessage,
} from '@documenso/ui/primitives/form/form'; } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import type { Toast } from '@documenso/ui/primitives/use-toast'; import type { Toast } from '@documenso/ui/primitives/use-toast';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
@ -54,13 +49,8 @@ const ZAddRecipientsForNewDocumentSchema = z.object({
distributeDocument: z.boolean(), distributeDocument: z.boolean(),
useCustomDocument: z.boolean().default(false), useCustomDocument: z.boolean().default(false),
customDocumentData: z customDocumentData: z
.array( .any()
z.object({ .refine((data) => data instanceof File || data === undefined)
title: z.string(),
data: z.instanceof(File).optional(),
envelopeItemId: z.string(),
}),
)
.optional(), .optional(),
recipients: z.array( recipients: z.array(
z.object({ z.object({
@ -75,7 +65,6 @@ const ZAddRecipientsForNewDocumentSchema = z.object({
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>; type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
export type TemplateUseDialogProps = { export type TemplateUseDialogProps = {
envelopeId: string;
templateId: number; templateId: number;
templateSigningOrder?: DocumentSigningOrder | null; templateSigningOrder?: DocumentSigningOrder | null;
recipients: Recipient[]; recipients: Recipient[];
@ -88,7 +77,6 @@ export function TemplateUseDialog({
recipients, recipients,
documentDistributionMethod = DocumentDistributionMethod.EMAIL, documentDistributionMethod = DocumentDistributionMethod.EMAIL,
documentRootPath, documentRootPath,
envelopeId,
templateId, templateId,
templateSigningOrder, templateSigningOrder,
trigger, trigger,
@ -105,7 +93,7 @@ export function TemplateUseDialog({
defaultValues: { defaultValues: {
distributeDocument: false, distributeDocument: false,
useCustomDocument: false, useCustomDocument: false,
customDocumentData: [], customDocumentData: undefined,
recipients: recipients recipients: recipients
.sort((a, b) => (a.signingOrder || 0) - (b.signingOrder || 0)) .sort((a, b) => (a.signingOrder || 0) - (b.signingOrder || 0))
.map((recipient) => { .map((recipient) => {
@ -127,50 +115,23 @@ export function TemplateUseDialog({
}, },
}); });
const { replace, fields: localCustomDocumentData } = useFieldArray({
control: form.control,
name: 'customDocumentData',
});
const { data: response, isLoading: isLoadingEnvelopeItems } = trpc.envelope.item.getMany.useQuery(
{
envelopeId,
},
{
placeholderData: (previousData) => previousData,
...SKIP_QUERY_BATCH_META,
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
const envelopeItems = response?.data ?? [];
const { mutateAsync: createDocumentFromTemplate } = const { mutateAsync: createDocumentFromTemplate } =
trpc.template.createDocumentFromTemplate.useMutation(); trpc.template.createDocumentFromTemplate.useMutation();
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => { const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
try { try {
const customFilesToUpload = (data.customDocumentData || []).filter( let customDocumentDataId: string | undefined = undefined;
(item): item is { data: File; envelopeItemId: string; title: string } =>
item.data !== undefined && item.envelopeItemId !== undefined && item.title !== undefined,
);
const customDocumentData = await Promise.all( if (data.useCustomDocument && data.customDocumentData) {
customFilesToUpload.map(async (item) => { const customDocumentData = await putPdfFile(data.customDocumentData);
const customDocumentData = await putPdfFile(item.data); customDocumentDataId = customDocumentData.id;
}
return { const { id } = await createDocumentFromTemplate({
documentDataId: customDocumentData.id,
envelopeItemId: item.envelopeItemId,
};
}),
);
const { envelopeId } = await createDocumentFromTemplate({
templateId, templateId,
recipients: data.recipients, recipients: data.recipients,
distributeDocument: data.distributeDocument, distributeDocument: data.distributeDocument,
customDocumentData, customDocumentDataId,
}); });
toast({ toast({
@ -179,7 +140,7 @@ export function TemplateUseDialog({
duration: 5000, duration: 5000,
}); });
let documentPath = `${documentRootPath}/${envelopeId}`; let documentPath = `${documentRootPath}/${id}`;
if ( if (
data.distributeDocument && data.distributeDocument &&
@ -219,18 +180,6 @@ export function TemplateUseDialog({
} }
}, [open, form]); }, [open, form]);
useEffect(() => {
if (envelopeItems.length > 0 && localCustomDocumentData.length === 0) {
replace(
envelopeItems.map((item) => ({
title: item.title,
data: undefined,
envelopeItemId: item.id,
})),
);
}
}, [envelopeItems, form, open]);
return ( return (
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}> <Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger asChild> <DialogTrigger asChild>
@ -435,6 +384,7 @@ export function TemplateUseDialog({
className="text-muted-foreground ml-2 flex items-center text-sm" className="text-muted-foreground ml-2 flex items-center text-sm"
htmlFor="useCustomDocument" htmlFor="useCustomDocument"
> >
{/* Todo: Envelopes - How will this work? */}
<Trans>Upload custom document</Trans> <Trans>Upload custom document</Trans>
<Tooltip> <Tooltip>
<TooltipTrigger type="button"> <TooltipTrigger type="button">
@ -456,133 +406,116 @@ export function TemplateUseDialog({
/> />
{form.watch('useCustomDocument') && ( {form.watch('useCustomDocument') && (
<div className="my-4 space-y-2"> <div className="my-4">
{isLoadingEnvelopeItems ? ( <FormField
<SpinnerBox className="py-16" /> control={form.control}
) : ( name="customDocumentData"
localCustomDocumentData.map((item, i) => ( render={({ field }) => (
<FormField <FormItem>
key={item.id} <FormControl>
control={form.control} <div className="w-full space-y-4">
name={`customDocumentData.${i}.data`} <label
render={({ field }) => ( className={cn(
<FormItem> 'text-muted-foreground hover:border-muted-foreground/50 group relative flex min-h-[150px] cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-gray-300 px-6 py-10 transition-colors',
<FormControl> {
<div 'border-destructive hover:border-destructive':
key={item.id} form.formState.errors.customDocumentData,
className="border-border bg-card hover:bg-accent/10 flex items-center gap-4 rounded-lg border p-4 transition-colors" },
> )}
<div className="flex-shrink-0"> >
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg"> <div className="text-center">
<FileTextIcon className="text-primary h-5 w-5" /> {!field.value && (
</div> <>
</div> <Upload className="text-muted-foreground/50 mx-auto h-10 w-10" />
<div className="mt-4 flex text-sm leading-6">
<div className="min-w-0 flex-1"> <span className="text-muted-foreground relative">
<h4 className="text-foreground truncate text-sm font-medium">
{item.title}
</h4>
<p className="text-muted-foreground mt-0.5 text-xs">
{field.value ? (
<div>
<Trans> <Trans>
Custom {(field.value.size / (1024 * 1024)).toFixed(2)}{' '} <span className="text-primary font-semibold">
MB file Click to upload
</span>{' '}
or drag and drop
</Trans> </Trans>
</div> </span>
) : (
<Trans>Default file</Trans>
)}
</p>
</div>
<div className="flex flex-shrink-0 items-center gap-2">
{field.value ? (
<div className="">
<Button
type="button"
variant="destructive"
size="sm"
className="text-xs"
onClick={(e) => {
e.preventDefault();
field.onChange(undefined);
}}
>
<X className="mr-2 h-4 w-4" />
<Trans>Remove</Trans>
</Button>
</div> </div>
) : ( <p className="text-muted-foreground/80 text-xs">
<Button PDF files only
type="button" </p>
variant="outline" </>
size="sm" )}
className="text-xs"
onClick={() => {
const fileInput = document.getElementById(
`template-use-dialog-file-input-${item.envelopeItemId}`,
);
if (fileInput instanceof HTMLInputElement) { {field.value && (
fileInput.click(); <div className="text-muted-foreground space-y-1">
} <p className="text-sm font-medium">{field.value.name}</p>
}} <p className="text-muted-foreground/60 text-xs">
> {(field.value.size / (1024 * 1024)).toFixed(2)} MB
<UploadCloudIcon className="mr-2 h-4 w-4" /> </p>
<Trans>Upload</Trans> </div>
</Button> )}
)}
<input
type="file"
id={`template-use-dialog-file-input-${item.envelopeItemId}`}
className="hidden"
accept=".pdf,application/pdf"
onChange={(e) => {
const file = e.target.files?.[0];
if (!file) {
field.onChange(undefined);
return;
}
if (file.type !== 'application/pdf') {
form.setError('customDocumentData', {
type: 'manual',
message: _(msg`Please select a PDF file`),
});
return;
}
if (
file.size >
APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024
) {
form.setError('customDocumentData', {
type: 'manual',
message: _(
msg`File size exceeds the limit of ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT} MB`,
),
});
return;
}
field.onChange(file);
}}
/>
</div>
</div> </div>
</FormControl>
<FormMessage /> <input
</FormItem> type="file"
)} data-testid="template-use-dialog-file-input"
/> className="absolute h-full w-full opacity-0"
)) accept=".pdf,application/pdf"
)} onChange={(e) => {
const file = e.target.files?.[0];
if (!file) {
field.onChange(undefined);
return;
}
if (file.type !== 'application/pdf') {
form.setError('customDocumentData', {
type: 'manual',
message: _(msg`Please select a PDF file`),
});
return;
}
if (file.size > APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024) {
form.setError('customDocumentData', {
type: 'manual',
message: _(
msg`File size exceeds the limit of ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT} MB`,
),
});
return;
}
field.onChange(file);
}}
/>
{field.value && (
<div className="absolute right-2 top-2">
<Button
type="button"
variant="destructive"
className="h-6 w-6 p-0"
onClick={(e) => {
e.preventDefault();
field.onChange(undefined);
}}
>
<X className="h-4 w-4" />
<div className="sr-only">
<Trans>Clear file</Trans>
</div>
</Button>
</div>
)}
</label>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div> </div>
)} )}
</div> </div>

View File

@ -4,7 +4,6 @@ import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { DocumentData, FieldType } from '@prisma/client'; import type { DocumentData, FieldType } from '@prisma/client';
import { ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client'; import { ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client';
import { base64 } from '@scure/base';
import { ChevronsUpDown } from 'lucide-react'; import { ChevronsUpDown } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
@ -13,6 +12,7 @@ import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-c
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element'; import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { type TFieldMetaSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta'; import { type TFieldMetaSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { base64 } from '@documenso/lib/universal/base64';
import { nanoid } from '@documenso/lib/universal/id'; import { nanoid } from '@documenso/lib/universal/id';
import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers'; import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers';
import { useRecipientColors } from '@documenso/ui/lib/recipient-colors'; import { useRecipientColors } from '@documenso/ui/lib/recipient-colors';
@ -83,14 +83,21 @@ export const ConfigureFieldsView = ({
const normalizedDocumentData = useMemo(() => { const normalizedDocumentData = useMemo(() => {
if (documentData) { if (documentData) {
return documentData.data; return documentData;
} }
if (!configData.documentData) { if (!configData.documentData) {
return null; return null;
} }
return base64.encode(configData.documentData.data); const data = base64.encode(configData.documentData?.data);
return {
id: 'preview',
type: 'BYTES_64',
data,
initialData: data,
} satisfies DocumentData;
}, [configData.documentData]); }, [configData.documentData]);
const recipients = useMemo(() => { const recipients = useMemo(() => {
@ -534,15 +541,7 @@ export const ConfigureFieldsView = ({
<Form {...form}> <Form {...form}>
{normalizedDocumentData && ( {normalizedDocumentData && (
<div> <div>
<PDFViewer <PDFViewer documentData={normalizedDocumentData} />
overrideData={normalizedDocumentData}
envelopeItem={{
id: '',
envelopeId: '',
}}
token={undefined}
version="signed"
/>
<ElementVisible <ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`} target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}

View File

@ -3,8 +3,8 @@ import { useEffect, useLayoutEffect, useState } from 'react';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { DocumentMeta, EnvelopeItem, Recipient, Signature } from '@prisma/client'; import type { DocumentMeta, Recipient, Signature } from '@prisma/client';
import { type Field, FieldType } from '@prisma/client'; import { type DocumentData, type Field, FieldType } from '@prisma/client';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react'; import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
@ -47,7 +47,7 @@ export type EmbedDirectTemplateClientPageProps = {
token: string; token: string;
envelopeId: string; envelopeId: string;
updatedAt: Date; updatedAt: Date;
envelopeItems: Pick<EnvelopeItem, 'id' | 'envelopeId'>[]; documentData: DocumentData;
recipient: Recipient; recipient: Recipient;
fields: Field[]; fields: Field[];
metadata?: DocumentMeta | null; metadata?: DocumentMeta | null;
@ -59,7 +59,7 @@ export const EmbedDirectTemplateClientPage = ({
token, token,
envelopeId, envelopeId,
updatedAt, updatedAt,
envelopeItems, documentData,
recipient, recipient,
fields, fields,
metadata, metadata,
@ -335,9 +335,7 @@ export const EmbedDirectTemplateClientPage = ({
{/* Viewer */} {/* Viewer */}
<div className="flex-1"> <div className="flex-1">
<PDFViewer <PDFViewer
envelopeItem={envelopeItems[0]} documentData={documentData}
token={recipient.token}
version="signed"
onDocumentLoad={() => setHasDocumentLoaded(true)} onDocumentLoad={() => setHasDocumentLoaded(true)}
/> />
</div> </div>

View File

@ -1,232 +0,0 @@
import { useEffect, useLayoutEffect, useState } from 'react';
import { useLingui } from '@lingui/react';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { ZSignDocumentEmbedDataSchema } from '~/types/embed-document-sign-schema';
import { injectCss } from '~/utils/css-vars';
import { DocumentSigningPageViewV2 } from '../general/document-signing/document-signing-page-view-v2';
import { useRequiredEnvelopeSigningContext } from '../general/document-signing/envelope-signing-provider';
import { EmbedClientLoading } from './embed-client-loading';
import { EmbedDocumentCompleted } from './embed-document-completed';
import { EmbedDocumentRejected } from './embed-document-rejected';
import { EmbedSigningProvider } from './embed-signing-context';
export type EmbedSignDocumentV2ClientPageProps = {
hidePoweredBy?: boolean;
allowWhitelabelling?: boolean;
};
export const EmbedSignDocumentV2ClientPage = ({
hidePoweredBy = false,
allowWhitelabelling = false,
}: EmbedSignDocumentV2ClientPageProps) => {
const { _ } = useLingui();
const { envelope, recipient, envelopeData, setFullName, fullName } =
useRequiredEnvelopeSigningContext();
const { isCompleted, isRejected, recipientSignature } = envelopeData;
// !: Not used at the moment, may be removed in the future.
// const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
const [hasFinishedInit, setHasFinishedInit] = useState(false);
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
const [isNameLocked, setIsNameLocked] = useState(false);
const onDocumentCompleted = (data: {
token: string;
documentId: number;
envelopeId: string;
recipientId: number;
}) => {
if (window.parent) {
window.parent.postMessage(
{
action: 'document-completed',
data,
},
'*',
);
}
};
const onDocumentError = () => {
if (window.parent) {
window.parent.postMessage(
{
action: 'document-error',
data: null,
},
'*',
);
}
};
const onDocumentReady = () => {
if (window.parent) {
window.parent.postMessage(
{
action: 'document-ready',
data: null,
},
'*',
);
}
};
const onFieldSigned = (data: { fieldId?: number; value?: string; isBase64?: boolean }) => {
if (window.parent) {
window.parent.postMessage(
{
action: 'field-signed',
data,
},
'*',
);
}
};
const onFieldUnsigned = (data: { fieldId?: number }) => {
if (window.parent) {
window.parent.postMessage(
{
action: 'field-unsigned',
data,
},
'*',
);
}
};
const onDocumentRejected = (data: {
token: string;
documentId: number;
envelopeId: string;
recipientId: number;
reason?: string;
}) => {
if (window.parent) {
window.parent.postMessage(
{
action: 'document-rejected',
data,
},
'*',
);
}
};
useLayoutEffect(() => {
const hash = window.location.hash.slice(1);
try {
const data = ZSignDocumentEmbedDataSchema.parse(JSON.parse(decodeURIComponent(atob(hash))));
if (!isCompleted && data.name) {
setFullName(data.name);
}
// Since a recipient can be provided a name we can lock it without requiring
// a to be provided by the parent application, unlike direct templates.
setIsNameLocked(!!data.lockName);
setAllowDocumentRejection(!!data.allowDocumentRejection);
if (data.darkModeDisabled) {
document.documentElement.classList.add('dark-mode-disabled');
}
if (allowWhitelabelling) {
injectCss({
css: data.css,
cssVars: data.cssVars,
});
}
} catch (err) {
console.error(err);
}
setHasFinishedInit(true);
// !: While the setters are stable we still want to ensure we're avoiding
// !: re-renders.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allowWhitelabelling]);
useEffect(() => {
if (hasFinishedInit) {
onDocumentReady();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasFinishedInit]);
// Listen for document completion events from the envelope signing context
useEffect(() => {
if (isCompleted) {
onDocumentCompleted({
token: recipient.token,
envelopeId: envelope.id,
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
recipientId: recipient.id,
});
}
}, [isCompleted, envelope.id, recipient.id, recipient.token]);
// Listen for document rejection events
useEffect(() => {
if (isRejected) {
onDocumentRejected({
token: recipient.token,
envelopeId: envelope.id,
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
recipientId: recipient.id,
});
}
}, [isRejected, envelope.id, recipient.id, recipient.token]);
if (isRejected) {
return <EmbedDocumentRejected />;
}
if (isCompleted) {
return (
<EmbedDocumentCompleted
name={fullName}
signature={
recipientSignature
? {
id: 1,
fieldId: 1,
recipientId: recipient.id,
created: new Date(),
signatureImageAsBase64: recipientSignature.signatureImageAsBase64,
typedSignature: recipientSignature.typedSignature,
}
: undefined
}
/>
);
}
return (
<EmbedSigningProvider
isNameLocked={isNameLocked}
hidePoweredBy={hidePoweredBy}
allowDocumentRejection={allowDocumentRejection}
onDocumentCompleted={onDocumentCompleted}
onDocumentError={onDocumentError}
onDocumentRejected={onDocumentRejected}
onDocumentReady={onDocumentReady}
onFieldSigned={onFieldSigned}
onFieldUnsigned={onFieldUnsigned}
>
<div className="embed--Root relative">
{!hasFinishedInit && <EmbedClientLoading />}
<DocumentSigningPageViewV2 />
</div>
</EmbedSigningProvider>
);
};

View File

@ -3,8 +3,14 @@ import { useEffect, useId, useLayoutEffect, useMemo, useState } from 'react';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { DocumentMeta, EnvelopeItem } from '@prisma/client'; import type { DocumentMeta } from '@prisma/client';
import { type Field, FieldType, RecipientRole, SigningStatus } from '@prisma/client'; import {
type DocumentData,
type Field,
FieldType,
RecipientRole,
SigningStatus,
} from '@prisma/client';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react'; import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn'; import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
@ -40,11 +46,11 @@ import { EmbedDocumentCompleted } from './embed-document-completed';
import { EmbedDocumentFields } from './embed-document-fields'; import { EmbedDocumentFields } from './embed-document-fields';
import { EmbedDocumentRejected } from './embed-document-rejected'; import { EmbedDocumentRejected } from './embed-document-rejected';
export type EmbedSignDocumentV1ClientPageProps = { export type EmbedSignDocumentClientPageProps = {
token: string; token: string;
documentId: number; documentId: number;
envelopeId: string; envelopeId: string;
envelopeItems: Pick<EnvelopeItem, 'id' | 'envelopeId'>[]; documentData: DocumentData;
recipient: RecipientWithFields; recipient: RecipientWithFields;
fields: Field[]; fields: Field[];
completedFields: DocumentField[]; completedFields: DocumentField[];
@ -55,11 +61,11 @@ export type EmbedSignDocumentV1ClientPageProps = {
allRecipients?: RecipientWithFields[]; allRecipients?: RecipientWithFields[];
}; };
export const EmbedSignDocumentV1ClientPage = ({ export const EmbedSignDocumentClientPage = ({
token, token,
documentId, documentId,
envelopeId, envelopeId,
envelopeItems, documentData,
recipient, recipient,
fields, fields,
completedFields, completedFields,
@ -68,7 +74,7 @@ export const EmbedSignDocumentV1ClientPage = ({
hidePoweredBy = false, hidePoweredBy = false,
allowWhitelabelling = false, allowWhitelabelling = false,
allRecipients = [], allRecipients = [],
}: EmbedSignDocumentV1ClientPageProps) => { }: EmbedSignDocumentClientPageProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
@ -287,9 +293,7 @@ export const EmbedSignDocumentV1ClientPage = ({
{/* Viewer */} {/* Viewer */}
<div className="embed--DocumentViewer flex-1"> <div className="embed--DocumentViewer flex-1">
<PDFViewer <PDFViewer
envelopeItem={envelopeItems[0]} documentData={documentData}
token={token}
version="signed"
onDocumentLoad={() => setHasDocumentLoaded(true)} onDocumentLoad={() => setHasDocumentLoaded(true)}
/> />
</div> </div>

View File

@ -1,101 +0,0 @@
import { createContext, useContext } from 'react';
export type EmbedSigningContextValue = {
isEmbed: true;
allowDocumentRejection: boolean;
isNameLocked: boolean;
isEmailLocked: boolean;
hidePoweredBy: boolean;
onDocumentCompleted: (data: {
token: string;
documentId: number;
envelopeId: string;
recipientId: number;
}) => void;
onDocumentError: () => void;
onDocumentRejected: (data: {
token: string;
documentId: number;
envelopeId: string;
recipientId: number;
reason?: string;
}) => void;
onDocumentReady: () => void;
onFieldSigned: (data: { fieldId?: number; value?: string; isBase64?: boolean }) => void;
onFieldUnsigned: (data: { fieldId?: number }) => void;
};
const EmbedSigningContext = createContext<EmbedSigningContextValue | null>(null);
export const useEmbedSigningContext = () => {
return useContext(EmbedSigningContext);
};
export const useRequiredEmbedSigningContext = () => {
const context = useEmbedSigningContext();
if (!context) {
throw new Error('useRequiredEmbedSigningContext must be used within EmbedSigningProvider');
}
return context;
};
export type EmbedSigningProviderProps = {
allowDocumentRejection?: boolean;
isNameLocked?: boolean;
isEmailLocked?: boolean;
hidePoweredBy?: boolean;
onDocumentCompleted: (data: {
token: string;
documentId: number;
envelopeId: string;
recipientId: number;
}) => void;
onDocumentError: () => void;
onDocumentRejected: (data: {
token: string;
documentId: number;
envelopeId: string;
recipientId: number;
reason?: string;
}) => void;
onDocumentReady: () => void;
onFieldSigned: (data: { fieldId?: number; value?: string; isBase64?: boolean }) => void;
onFieldUnsigned: (data: { fieldId?: number }) => void;
children: React.ReactNode;
};
export const EmbedSigningProvider = ({
allowDocumentRejection = false,
isNameLocked = false,
isEmailLocked = true,
hidePoweredBy = false,
onDocumentCompleted,
onDocumentError,
onDocumentRejected,
onDocumentReady,
onFieldSigned,
onFieldUnsigned,
children,
}: EmbedSigningProviderProps) => {
return (
<EmbedSigningContext.Provider
value={{
isEmbed: true,
allowDocumentRejection,
isNameLocked,
isEmailLocked,
hidePoweredBy,
onDocumentCompleted,
onDocumentError,
onDocumentRejected,
onDocumentReady,
onFieldSigned,
onFieldUnsigned,
}}
>
{children}
</EmbedSigningContext.Provider>
);
};

View File

@ -226,9 +226,7 @@ export const MultiSignDocumentSigningView = ({
})} })}
> >
<PDFViewer <PDFViewer
envelopeItem={document.envelopeItems[0]} documentData={document.documentData}
token={token}
version="signed"
onDocumentLoad={() => { onDocumentLoad={() => {
setHasDocumentLoaded(true); setHasDocumentLoaded(true);
onDocumentReady?.(); onDocumentReady?.();

View File

@ -1,49 +0,0 @@
import { useTransition } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import type { DateRange } from '@documenso/lib/types/search-params';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
type DateRangeFilterProps = {
currentRange: DateRange;
};
export const DateRangeFilter = ({ currentRange }: DateRangeFilterProps) => {
const { _ } = useLingui();
const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams();
const handleRangeChange = (value: string) => {
startTransition(() => {
updateSearchParams({
dateRange: value as DateRange,
page: 1,
});
});
};
return (
<div className="flex items-center gap-2">
<Select value={currentRange} onValueChange={handleRangeChange} disabled={isPending}>
<SelectTrigger className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="last30days">{_(msg`Last 30 Days`)}</SelectItem>
<SelectItem value="last90days">{_(msg`Last 90 Days`)}</SelectItem>
<SelectItem value="lastYear">{_(msg`Last Year`)}</SelectItem>
<SelectItem value="allTime">{_(msg`All Time`)}</SelectItem>
</SelectContent>
</Select>
</div>
);
};

View File

@ -1,14 +1,14 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import type { TeamGlobalSettings } from '@prisma/client'; import type { TeamGlobalSettings } from '@prisma/client';
import { Loader } from 'lucide-react'; import { Loader } from 'lucide-react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { getFile } from '@documenso/lib/universal/upload/get-file';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
@ -29,8 +29,6 @@ import {
} from '@documenso/ui/primitives/select'; } from '@documenso/ui/primitives/select';
import { Textarea } from '@documenso/ui/primitives/textarea'; import { Textarea } from '@documenso/ui/primitives/textarea';
import { useOptionalCurrentTeam } from '~/providers/team';
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp']; const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
@ -70,9 +68,6 @@ export function BrandingPreferencesForm({
}: BrandingPreferencesFormProps) { }: BrandingPreferencesFormProps) {
const { t } = useLingui(); const { t } = useLingui();
const team = useOptionalCurrentTeam();
const organisation = useCurrentOrganisation();
const [previewUrl, setPreviewUrl] = useState<string>(''); const [previewUrl, setPreviewUrl] = useState<string>('');
const [hasLoadedPreview, setHasLoadedPreview] = useState(false); const [hasLoadedPreview, setHasLoadedPreview] = useState(false);
@ -93,13 +88,14 @@ export function BrandingPreferencesForm({
const file = JSON.parse(settings.brandingLogo); const file = JSON.parse(settings.brandingLogo);
if ('type' in file && 'data' in file) { if ('type' in file && 'data' in file) {
const logoUrl = void getFile(file).then((binaryData) => {
context === 'Team' const objectUrl = URL.createObjectURL(new Blob([binaryData]));
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/team/${team?.id}`
: `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/organisation/${organisation?.id}`;
setPreviewUrl(logoUrl); setPreviewUrl(objectUrl);
setHasLoadedPreview(true); setHasLoadedPreview(true);
});
return;
} }
} }

View File

@ -0,0 +1,31 @@
// export const numberFormatValues = [
// {
// label: '123,456,789.00',
// value: '123,456,789.00',
// },
// {
// label: '123.456.789,00',
// value: '123.456.789,00',
// },
// {
// label: '123456,789.00',
// value: '123456,789.00',
// },
// ];
export const checkboxValidationRules = ['Select at least', 'Select exactly', 'Select at most'];
export const checkboxValidationLength = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
export const checkboxValidationSigns = [
{
label: 'Select at least',
value: '>=',
},
{
label: 'Select exactly',
value: '=',
},
{
label: 'Select at most',
value: '<=',
},
];

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo } from 'react'; import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
@ -7,19 +7,11 @@ import { PlusIcon, Trash } from 'lucide-react';
import { useForm, useWatch } from 'react-hook-form'; import { useForm, useWatch } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import { validateCheckboxLength } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
import { import {
type TCheckboxFieldMeta as CheckboxFieldMeta, type TCheckboxFieldMeta as CheckboxFieldMeta,
DEFAULT_FIELD_FONT_SIZE,
ZCheckboxFieldMeta, ZCheckboxFieldMeta,
} from '@documenso/lib/types/field-meta'; } from '@documenso/lib/types/field-meta';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Checkbox } from '@documenso/ui/primitives/checkbox'; import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
checkboxValidationLength,
checkboxValidationRules,
checkboxValidationSigns,
} from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
import { import {
Form, Form,
FormControl, FormControl,
@ -38,8 +30,8 @@ import {
} from '@documenso/ui/primitives/select'; } from '@documenso/ui/primitives/select';
import { Separator } from '@documenso/ui/primitives/separator'; import { Separator } from '@documenso/ui/primitives/separator';
import { checkboxValidationLength, checkboxValidationRules } from './constants';
import { import {
EditorGenericFontSizeField,
EditorGenericReadOnlyField, EditorGenericReadOnlyField,
EditorGenericRequiredField, EditorGenericRequiredField,
} from './editor-field-generic-field-forms'; } from './editor-field-generic-field-forms';
@ -52,7 +44,6 @@ const ZCheckboxFieldFormSchema = ZCheckboxFieldMeta.pick({
required: true, required: true,
values: true, values: true,
readOnly: true, readOnly: true,
fontSize: true,
}) })
.extend({ .extend({
validationLength: z.coerce.number().optional(), validationLength: z.coerce.number().optional(),
@ -99,7 +90,6 @@ export const EditorFieldCheckboxForm = ({
values: value.values || [{ id: 1, checked: false, value: '' }], values: value.values || [{ id: 1, checked: false, value: '' }],
required: value.required || false, required: value.required || false,
readOnly: value.readOnly || false, readOnly: value.readOnly || false,
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
}, },
}); });
@ -109,17 +99,13 @@ export const EditorFieldCheckboxForm = ({
control, control,
}); });
const addValue = (numberOfValues: number = 1) => { const addValue = () => {
const currentValues = form.getValues('values') || []; const currentValues = form.getValues('values') || [];
const currentMaxId = Math.max(...currentValues.map((val) => val.id)); const newId =
currentValues.length > 0 ? Math.max(...currentValues.map((val) => val.id)) + 1 : 1;
const newValues = Array.from({ length: numberOfValues }, (_, index) => ({ const newValues = [...currentValues, { id: newId, checked: false, value: '' }];
id: currentMaxId + index + 1, form.setValue('values', newValues);
checked: false,
value: '',
}));
form.setValue('values', [...currentValues, ...newValues]);
}; };
const removeValue = (index: number) => { const removeValue = (index: number) => {
@ -146,34 +132,10 @@ export const EditorFieldCheckboxForm = ({
} }
}, [formValues]); }, [formValues]);
const isValidationRuleMetForPreselectedValues = useMemo(() => {
const preselectedValues = (formValues.values || [])?.filter((value) => value.checked);
if (formValues.validationLength && formValues.validationRule && preselectedValues.length > 0) {
const validationRule = checkboxValidationSigns.find(
(sign) => sign.label === formValues.validationRule,
);
if (!validationRule) {
return false;
}
return validateCheckboxLength(
preselectedValues.length,
validationRule.value,
formValues.validationLength,
);
}
return true;
}, [formValues]);
return ( return (
<Form {...form}> <Form {...form}>
<form> <form>
<fieldset className="flex flex-col gap-2"> <fieldset className="flex flex-col gap-2">
<EditorGenericFontSizeField formControl={form.control} />
<FormField <FormField
control={form.control} control={form.control}
name="direction" name="direction"
@ -240,25 +202,7 @@ export const EditorFieldCheckboxForm = ({
<FormControl> <FormControl>
<Select <Select
value={field.value ? String(field.value) : ''} value={field.value ? String(field.value) : ''}
onValueChange={(value) => { onValueChange={field.onChange}
const validationNumber = Number(value);
const currentValues = formValues.values || [];
const minimumNumberOfValuesRequired =
validationNumber - currentValues.length;
if (!formValues.validationRule) {
form.setValue('validationRule', checkboxValidationRules[0]);
}
if (minimumNumberOfValuesRequired > 0) {
addValue(minimumNumberOfValuesRequired);
}
field.onChange(validationNumber);
void form.trigger();
}}
> >
<SelectTrigger className="text-muted-foreground bg-background mt-5 w-full"> <SelectTrigger className="text-muted-foreground bg-background mt-5 w-full">
<SelectValue placeholder={t`Pick a number`} /> <SelectValue placeholder={t`Pick a number`} />
@ -295,7 +239,7 @@ export const EditorFieldCheckboxForm = ({
<Trans>Checkbox values</Trans> <Trans>Checkbox values</Trans>
</p> </p>
<button type="button" onClick={() => addValue()}> <button type="button" onClick={addValue}>
<PlusIcon className="h-4 w-4" /> <PlusIcon className="h-4 w-4" />
</button> </button>
</div> </div>
@ -341,16 +285,6 @@ export const EditorFieldCheckboxForm = ({
</li> </li>
))} ))}
</ul> </ul>
{!isValidationRuleMetForPreselectedValues && (
<Alert variant="warning">
<AlertDescription>
<Trans>
The preselected values will be ignored unless they meet the validation criteria.
</Trans>
</AlertDescription>
</Alert>
)}
</section> </section>
</fieldset> </fieldset>
</form> </form>

View File

@ -7,7 +7,6 @@ import type { z } from 'zod';
import { import {
DEFAULT_FIELD_FONT_SIZE, DEFAULT_FIELD_FONT_SIZE,
type TDateFieldMeta as DateFieldMeta, type TDateFieldMeta as DateFieldMeta,
FIELD_DEFAULT_GENERIC_ALIGN,
ZDateFieldMeta, ZDateFieldMeta,
} from '@documenso/lib/types/field-meta'; } from '@documenso/lib/types/field-meta';
import { Form } from '@documenso/ui/primitives/form/form'; import { Form } from '@documenso/ui/primitives/form/form';
@ -40,7 +39,7 @@ export const EditorFieldDateForm = ({
mode: 'onChange', mode: 'onChange',
defaultValues: { defaultValues: {
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE, fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN, textAlign: value.textAlign || 'left',
}, },
}); });

View File

@ -8,10 +8,7 @@ import { PlusIcon, Trash } from 'lucide-react';
import { useForm, useWatch } from 'react-hook-form'; import { useForm, useWatch } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import { import { type TDropdownFieldMeta as DropdownFieldMeta } from '@documenso/lib/types/field-meta';
DEFAULT_FIELD_FONT_SIZE,
type TDropdownFieldMeta as DropdownFieldMeta,
} from '@documenso/lib/types/field-meta';
import { import {
Form, Form,
FormControl, FormControl,
@ -31,50 +28,56 @@ import {
import { Separator } from '@documenso/ui/primitives/separator'; import { Separator } from '@documenso/ui/primitives/separator';
import { import {
EditorGenericFontSizeField,
EditorGenericReadOnlyField, EditorGenericReadOnlyField,
EditorGenericRequiredField, EditorGenericRequiredField,
} from './editor-field-generic-field-forms'; } from './editor-field-generic-field-forms';
const ZDropdownFieldFormSchema = z.object({ const ZDropdownFieldFormSchema = z
defaultValue: z.string().optional(), .object({
values: z defaultValue: z.string().optional(),
.object({ values: z
value: z.string().min(1, { .object({
message: msg`Option value cannot be empty`.id, value: z.string().min(1, {
}), message: msg`Option value cannot be empty`.id,
}) }),
.array() })
.min(1, { .array()
message: msg`Dropdown must have at least one option`.id, .min(1, {
}) message: msg`Dropdown must have at least one option`.id,
.superRefine((values, ctx) => { })
const seen = new Map<string, number[]>(); // value → indices .refine(
(data) => {
// Todo: Envelopes - This doesn't work.
console.log({
data,
});
values.forEach((item, index) => { if (data) {
const key = item.value; const values = data.map((item) => item.value);
if (!seen.has(key)) { return new Set(values).size === values.length;
seen.set(key, []);
}
seen.get(key)!.push(index);
});
for (const [key, indices] of seen) {
if (indices.length > 1 && key.trim() !== '') {
for (const i of indices) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: msg`Duplicate values are not allowed`.id,
path: [i, 'value'],
});
} }
} return true;
},
{
message: 'Duplicate values are not allowed',
},
),
required: z.boolean().optional(),
readOnly: z.boolean().optional(),
})
.refine(
(data) => {
// Default value must be one of the available options
if (data.defaultValue && data.values) {
return data.values.some((item) => item.value === data.defaultValue);
} }
}), return true;
required: z.boolean().optional(), },
readOnly: z.boolean().optional(), {
fontSize: z.number().optional(), message: 'Default value must be one of the available options',
}); path: ['defaultValue'],
},
);
type TDropdownFieldFormSchema = z.infer<typeof ZDropdownFieldFormSchema>; type TDropdownFieldFormSchema = z.infer<typeof ZDropdownFieldFormSchema>;
@ -99,7 +102,6 @@ export const EditorFieldDropdownForm = ({
values: value.values || [{ value: 'Option 1' }], values: value.values || [{ value: 'Option 1' }],
required: value.required || false, required: value.required || false,
readOnly: value.readOnly || false, readOnly: value.readOnly || false,
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
}, },
}); });
@ -109,20 +111,7 @@ export const EditorFieldDropdownForm = ({
const addValue = () => { const addValue = () => {
const currentValues = form.getValues('values') || []; const currentValues = form.getValues('values') || [];
const newValues = [...currentValues, { value: 'New option' }];
let newValue = 'New option';
// Iterate to create a unique value
for (let i = 0; i < currentValues.length; i++) {
newValue = `New option ${i + 1}`;
if (currentValues.some((item) => item.value === `New option ${i + 1}`)) {
newValue = `New option ${i + 1}`;
} else {
break;
}
}
const newValues = [...currentValues, { value: newValue }];
form.setValue('values', newValues); form.setValue('values', newValues);
}; };
@ -138,10 +127,6 @@ export const EditorFieldDropdownForm = ({
newValues.splice(index, 1); newValues.splice(index, 1);
form.setValue('values', newValues); form.setValue('values', newValues);
if (form.getValues('defaultValue') === newValues[index].value) {
form.setValue('defaultValue', undefined);
}
}; };
useEffect(() => { useEffect(() => {
@ -155,13 +140,19 @@ export const EditorFieldDropdownForm = ({
} }
}, [formValues]); }, [formValues]);
const { formState } = form;
useEffect(() => {
console.log({
errors: formState.errors,
formValues,
});
}, [formState, formState.errors, formValues]);
return ( return (
<Form {...form}> <Form {...form}>
<form> <form>
<fieldset className="flex flex-col gap-2"> <fieldset className="flex flex-col gap-2">
<EditorGenericFontSizeField formControl={form.control} />
{/* Todo: Envelopes This is buggy. */}
<FormField <FormField
control={form.control} control={form.control}
name="defaultValue" name="defaultValue"
@ -172,25 +163,20 @@ export const EditorFieldDropdownForm = ({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Select <Select
// Todo: Envelopes - This is buggy, removing/adding should update the default value.
{...field} {...field}
value={field.value ?? '-1'} value={field.value}
onValueChange={(value) => field.onChange(value === '-1' ? undefined : value)} onValueChange={(val) => field.onChange(val)}
> >
<SelectTrigger className="text-muted-foreground bg-background w-full"> <SelectTrigger className="text-muted-foreground bg-background w-full">
<SelectValue placeholder={t`Default Value`} /> <SelectValue placeholder={t`Default Value`} />
</SelectTrigger> </SelectTrigger>
<SelectContent position="popper"> <SelectContent position="popper">
{(formValues.values || []) {(formValues.values || []).map((item, index) => (
.filter((item) => item.value) <SelectItem key={index} value={item.value || ''}>
.map((item, index) => ( {item.value}
<SelectItem key={index} value={item.value || ''}> </SelectItem>
{item.value} ))}
</SelectItem>
))}
<SelectItem value={'-1'}>
<Trans>Default Value</Trans>
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</FormControl> </FormControl>

View File

@ -7,7 +7,6 @@ import type { z } from 'zod';
import { import {
DEFAULT_FIELD_FONT_SIZE, DEFAULT_FIELD_FONT_SIZE,
type TEmailFieldMeta as EmailFieldMeta, type TEmailFieldMeta as EmailFieldMeta,
FIELD_DEFAULT_GENERIC_ALIGN,
ZEmailFieldMeta, ZEmailFieldMeta,
} from '@documenso/lib/types/field-meta'; } from '@documenso/lib/types/field-meta';
import { Form } from '@documenso/ui/primitives/form/form'; import { Form } from '@documenso/ui/primitives/form/form';
@ -40,7 +39,7 @@ export const EditorFieldEmailForm = ({
mode: 'onChange', mode: 'onChange',
defaultValues: { defaultValues: {
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE, fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN, textAlign: value.textAlign || 'left',
}, },
}); });

View File

@ -3,10 +3,6 @@ import { useEffect } from 'react';
import { Trans, useLingui } from '@lingui/react/macro'; import { Trans, useLingui } from '@lingui/react/macro';
import { type Control, useFormContext } from 'react-hook-form'; import { type Control, useFormContext } from 'react-hook-form';
import { FIELD_MIN_LINE_HEIGHT } from '@documenso/lib/types/field-meta';
import { FIELD_MAX_LINE_HEIGHT } from '@documenso/lib/types/field-meta';
import { FIELD_MIN_LETTER_SPACING } from '@documenso/lib/types/field-meta';
import { FIELD_MAX_LETTER_SPACING } from '@documenso/lib/types/field-meta';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Checkbox } from '@documenso/ui/primitives/checkbox'; import { Checkbox } from '@documenso/ui/primitives/checkbox';
import { import {
@ -111,119 +107,6 @@ export const EditorGenericTextAlignField = ({
); );
}; };
export const EditorGenericVerticalAlignField = ({
formControl,
className,
}: {
formControl: FormControlType;
className?: string;
}) => {
const { t } = useLingui();
return (
<FormField
control={formControl}
name="verticalAlign"
render={({ field }) => (
<FormItem className={className}>
<FormLabel>
<Trans>Vertical Align</Trans>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t`Select vertical align`} />
</SelectTrigger>
<SelectContent>
<SelectItem value="top">
<Trans>Top</Trans>
</SelectItem>
<SelectItem value="middle">
<Trans>Middle</Trans>
</SelectItem>
<SelectItem value="bottom">
<Trans>Bottom</Trans>
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
};
export const EditorGenericLineHeightField = ({
formControl,
className,
}: {
formControl: FormControlType;
className?: string;
}) => {
const { t } = useLingui();
return (
<FormField
control={formControl}
name="lineHeight"
render={({ field }) => (
<FormItem className={className}>
<FormLabel>
<Trans>Line Height</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={FIELD_MIN_LINE_HEIGHT}
max={FIELD_MAX_LINE_HEIGHT}
className="bg-background"
placeholder={t`Line height`}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
};
export const EditorGenericLetterSpacingField = ({
formControl,
className,
}: {
formControl: FormControlType;
className?: string;
}) => {
const { t } = useLingui();
return (
<FormField
control={formControl}
name="letterSpacing"
render={({ field }) => (
<FormItem className={className}>
<FormLabel>
<Trans>Letter Spacing</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={FIELD_MIN_LETTER_SPACING}
max={FIELD_MAX_LETTER_SPACING}
className="bg-background"
placeholder={t`Letter spacing`}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
};
export const EditorGenericRequiredField = ({ export const EditorGenericRequiredField = ({
formControl, formControl,
className, className,

View File

@ -6,7 +6,6 @@ import type { z } from 'zod';
import { import {
DEFAULT_FIELD_FONT_SIZE, DEFAULT_FIELD_FONT_SIZE,
FIELD_DEFAULT_GENERIC_ALIGN,
type TInitialsFieldMeta as InitialsFieldMeta, type TInitialsFieldMeta as InitialsFieldMeta,
ZInitialsFieldMeta, ZInitialsFieldMeta,
} from '@documenso/lib/types/field-meta'; } from '@documenso/lib/types/field-meta';
@ -40,7 +39,7 @@ export const EditorFieldInitialsForm = ({
mode: 'onChange', mode: 'onChange',
defaultValues: { defaultValues: {
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE, fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN, textAlign: value.textAlign || 'left',
}, },
}); });

View File

@ -6,7 +6,6 @@ import type { z } from 'zod';
import { import {
DEFAULT_FIELD_FONT_SIZE, DEFAULT_FIELD_FONT_SIZE,
FIELD_DEFAULT_GENERIC_ALIGN,
type TNameFieldMeta as NameFieldMeta, type TNameFieldMeta as NameFieldMeta,
ZNameFieldMeta, ZNameFieldMeta,
} from '@documenso/lib/types/field-meta'; } from '@documenso/lib/types/field-meta';
@ -40,7 +39,7 @@ export const EditorFieldNameForm = ({
mode: 'onChange', mode: 'onChange',
defaultValues: { defaultValues: {
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE, fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN, textAlign: value.textAlign || 'left',
}, },
}); });

View File

@ -6,11 +6,6 @@ import { useForm, useWatch } from 'react-hook-form';
import type { z } from 'zod'; import type { z } from 'zod';
import { import {
DEFAULT_FIELD_FONT_SIZE,
FIELD_DEFAULT_GENERIC_ALIGN,
FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN,
FIELD_DEFAULT_LETTER_SPACING,
FIELD_DEFAULT_LINE_HEIGHT,
type TNumberFieldMeta as NumberFieldMeta, type TNumberFieldMeta as NumberFieldMeta,
ZNumberFieldMeta, ZNumberFieldMeta,
} from '@documenso/lib/types/field-meta'; } from '@documenso/lib/types/field-meta';
@ -36,12 +31,9 @@ import { Separator } from '@documenso/ui/primitives/separator';
import { import {
EditorGenericFontSizeField, EditorGenericFontSizeField,
EditorGenericLabelField, EditorGenericLabelField,
EditorGenericLetterSpacingField,
EditorGenericLineHeightField,
EditorGenericReadOnlyField, EditorGenericReadOnlyField,
EditorGenericRequiredField, EditorGenericRequiredField,
EditorGenericTextAlignField, EditorGenericTextAlignField,
EditorGenericVerticalAlignField,
} from './editor-field-generic-field-forms'; } from './editor-field-generic-field-forms';
const ZNumberFieldFormSchema = ZNumberFieldMeta.pick({ const ZNumberFieldFormSchema = ZNumberFieldMeta.pick({
@ -51,9 +43,6 @@ const ZNumberFieldFormSchema = ZNumberFieldMeta.pick({
numberFormat: true, numberFormat: true,
fontSize: true, fontSize: true,
textAlign: true, textAlign: true,
lineHeight: true,
letterSpacing: true,
verticalAlign: true,
required: true, required: true,
readOnly: true, readOnly: true,
minValue: true, minValue: true,
@ -110,11 +99,8 @@ export const EditorFieldNumberForm = ({
placeholder: value.placeholder || '', placeholder: value.placeholder || '',
value: value.value || '', value: value.value || '',
numberFormat: value.numberFormat || null, numberFormat: value.numberFormat || null,
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE, fontSize: value.fontSize || 14,
textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN, textAlign: value.textAlign || 'left',
lineHeight: value.lineHeight ?? FIELD_DEFAULT_LINE_HEIGHT,
letterSpacing: value.letterSpacing ?? FIELD_DEFAULT_LETTER_SPACING,
verticalAlign: value.verticalAlign ?? FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN,
required: value.required || false, required: value.required || false,
readOnly: value.readOnly || false, readOnly: value.readOnly || false,
minValue: value.minValue, minValue: value.minValue,
@ -132,10 +118,6 @@ export const EditorFieldNumberForm = ({
useEffect(() => { useEffect(() => {
const validatedFormValues = ZNumberFieldFormSchema.safeParse(formValues); const validatedFormValues = ZNumberFieldFormSchema.safeParse(formValues);
if (formValues.readOnly && !formValues.value) {
void form.trigger('value');
}
if (validatedFormValues.success) { if (validatedFormValues.success) {
onValueChange({ onValueChange({
type: 'number', type: 'number',
@ -148,14 +130,6 @@ export const EditorFieldNumberForm = ({
<Form {...form}> <Form {...form}>
<form> <form>
<fieldset className="flex flex-col gap-2"> <fieldset className="flex flex-col gap-2">
<EditorGenericFontSizeField className="w-full" formControl={form.control} />
<div className="flex w-full flex-row gap-x-4">
<EditorGenericTextAlignField className="w-full" formControl={form.control} />
<EditorGenericVerticalAlignField className="w-full" formControl={form.control} />
</div>
<EditorGenericLabelField formControl={form.control} /> <EditorGenericLabelField formControl={form.control} />
<FormField <FormField
@ -225,9 +199,9 @@ export const EditorFieldNumberForm = ({
/> />
<div className="flex w-full flex-row gap-x-4"> <div className="flex w-full flex-row gap-x-4">
<EditorGenericLineHeightField className="w-full" formControl={form.control} /> <EditorGenericFontSizeField className="w-full" formControl={form.control} />
<EditorGenericLetterSpacingField className="w-full" formControl={form.control} /> <EditorGenericTextAlignField className="w-full" formControl={form.control} />
</div> </div>
<div className="mt-1"> <div className="mt-1">

View File

@ -1,62 +1,47 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { PlusIcon, Trash } from 'lucide-react'; import { PlusIcon, Trash } from 'lucide-react';
import { useForm, useWatch } from 'react-hook-form'; import { useForm, useWatch } from 'react-hook-form';
import type { z } from 'zod'; import { z } from 'zod';
import { import { type TRadioFieldMeta as RadioFieldMeta } from '@documenso/lib/types/field-meta';
DEFAULT_FIELD_FONT_SIZE,
type TRadioFieldMeta as RadioFieldMeta,
ZRadioFieldMeta,
} from '@documenso/lib/types/field-meta';
import { Checkbox } from '@documenso/ui/primitives/checkbox'; import { Checkbox } from '@documenso/ui/primitives/checkbox';
import { import { Form, FormControl, FormField, FormItem } from '@documenso/ui/primitives/form/form';
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Separator } from '@documenso/ui/primitives/separator'; import { Separator } from '@documenso/ui/primitives/separator';
import { import {
EditorGenericFontSizeField,
EditorGenericReadOnlyField, EditorGenericReadOnlyField,
EditorGenericRequiredField, EditorGenericRequiredField,
} from './editor-field-generic-field-forms'; } from './editor-field-generic-field-forms';
const ZRadioFieldFormSchema = ZRadioFieldMeta.pick({ const ZRadioFieldFormSchema = z
label: true, .object({
direction: true, label: z.string().optional(),
values: true, values: z
required: true, .object({ id: z.number(), checked: z.boolean(), value: z.string() })
readOnly: true, .array()
fontSize: true, .min(1)
}).refine( .optional(),
(data) => { required: z.boolean().optional(),
// There cannot be more than one checked option readOnly: z.boolean().optional(),
if (data.values) { })
const checkedValues = data.values.filter((option) => option.checked); .refine(
return checkedValues.length <= 1; (data) => {
} // There cannot be more than one checked option
return true; if (data.values) {
}, const checkedValues = data.values.filter((option) => option.checked);
{ return checkedValues.length <= 1;
message: 'There cannot be more than one checked option', }
path: ['values'], return true;
}, },
); {
message: 'There cannot be more than one checked option',
path: ['values'],
},
);
type TRadioFieldFormSchema = z.infer<typeof ZRadioFieldFormSchema>; type TRadioFieldFormSchema = z.infer<typeof ZRadioFieldFormSchema>;
@ -68,12 +53,9 @@ export type EditorFieldRadioFormProps = {
export const EditorFieldRadioForm = ({ export const EditorFieldRadioForm = ({
value = { value = {
type: 'radio', type: 'radio',
direction: 'vertical',
}, },
onValueChange, onValueChange,
}: EditorFieldRadioFormProps) => { }: EditorFieldRadioFormProps) => {
const { t } = useLingui();
const form = useForm<TRadioFieldFormSchema>({ const form = useForm<TRadioFieldFormSchema>({
resolver: zodResolver(ZRadioFieldFormSchema), resolver: zodResolver(ZRadioFieldFormSchema),
mode: 'onChange', mode: 'onChange',
@ -82,8 +64,6 @@ export const EditorFieldRadioForm = ({
values: value.values || [{ id: 1, checked: false, value: 'Default value' }], values: value.values || [{ id: 1, checked: false, value: 'Default value' }],
required: value.required || false, required: value.required || false,
readOnly: value.readOnly || false, readOnly: value.readOnly || false,
direction: value.direction || 'vertical',
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
}, },
}); });
@ -127,37 +107,7 @@ export const EditorFieldRadioForm = ({
return ( return (
<Form {...form}> <Form {...form}>
<form> <form>
<fieldset className="flex flex-col gap-2"> <fieldset className="flex flex-col gap-2 pb-2">
<EditorGenericFontSizeField formControl={form.control} />
<FormField
control={form.control}
name="direction"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Direction</Trans>
</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="text-muted-foreground bg-background w-full">
<SelectValue placeholder={t`Select direction`} />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="vertical">
<Trans>Vertical</Trans>
</SelectItem>
<SelectItem value="horizontal">
<Trans>Horizontal</Trans>
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<EditorGenericRequiredField formControl={form.control} /> <EditorGenericRequiredField formControl={form.control} />
<EditorGenericReadOnlyField formControl={form.control} /> <EditorGenericReadOnlyField formControl={form.control} />

View File

@ -1,71 +0,0 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import { useForm, useWatch } from 'react-hook-form';
import type { z } from 'zod';
import { DEFAULT_SIGNATURE_TEXT_FONT_SIZE } from '@documenso/lib/constants/pdf';
import { type TSignatureFieldMeta, ZSignatureFieldMeta } from '@documenso/lib/types/field-meta';
import { Form } from '@documenso/ui/primitives/form/form';
import { EditorGenericFontSizeField } from './editor-field-generic-field-forms';
const ZSignatureFieldFormSchema = ZSignatureFieldMeta.pick({
fontSize: true,
});
type TSignatureFieldFormSchema = z.infer<typeof ZSignatureFieldFormSchema>;
type EditorFieldSignatureFormProps = {
value: TSignatureFieldMeta | undefined;
onValueChange: (value: TSignatureFieldMeta) => void;
};
export const EditorFieldSignatureForm = ({
value = {
type: 'signature',
},
onValueChange,
}: EditorFieldSignatureFormProps) => {
const form = useForm<TSignatureFieldFormSchema>({
resolver: zodResolver(ZSignatureFieldFormSchema),
mode: 'onChange',
defaultValues: {
fontSize: value.fontSize || DEFAULT_SIGNATURE_TEXT_FONT_SIZE,
},
});
const { control } = form;
const formValues = useWatch({
control,
});
// Dupecode/Inefficient: Done because native isValid won't work for our usecase.
useEffect(() => {
const validatedFormValues = ZSignatureFieldFormSchema.safeParse(formValues);
if (validatedFormValues.success) {
onValueChange({
type: 'signature',
...validatedFormValues.data,
});
}
}, [formValues]);
return (
<Form {...form}>
<form>
<fieldset className="flex flex-col gap-2">
<div>
<EditorGenericFontSizeField formControl={form.control} />
<p className="text-muted-foreground mt-0.5 text-xs">
<Trans>The typed signature font size</Trans>
</p>
</div>
</fieldset>
</form>
</Form>
);
};

View File

@ -3,17 +3,9 @@ import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro'; import { Trans, useLingui } from '@lingui/react/macro';
import { useForm, useWatch } from 'react-hook-form'; import { useForm, useWatch } from 'react-hook-form';
import type { z } from 'zod'; import { z } from 'zod';
import { import { type TTextFieldMeta as TextFieldMeta } from '@documenso/lib/types/field-meta';
DEFAULT_FIELD_FONT_SIZE,
FIELD_DEFAULT_GENERIC_ALIGN,
FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN,
FIELD_DEFAULT_LETTER_SPACING,
FIELD_DEFAULT_LINE_HEIGHT,
type TTextFieldMeta as TextFieldMeta,
ZTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import { import {
Form, Form,
FormControl, FormControl,
@ -27,36 +19,32 @@ import { Textarea } from '@documenso/ui/primitives/textarea';
import { import {
EditorGenericFontSizeField, EditorGenericFontSizeField,
EditorGenericLetterSpacingField,
EditorGenericLineHeightField,
EditorGenericReadOnlyField, EditorGenericReadOnlyField,
EditorGenericRequiredField, EditorGenericRequiredField,
EditorGenericTextAlignField, EditorGenericTextAlignField,
EditorGenericVerticalAlignField,
} from './editor-field-generic-field-forms'; } from './editor-field-generic-field-forms';
const ZTextFieldFormSchema = ZTextFieldMeta.pick({ const ZTextFieldFormSchema = z
label: true, .object({
placeholder: true, label: z.string().optional(),
text: true, placeholder: z.string().optional(),
characterLimit: true, text: z.string().optional(),
fontSize: true, characterLimit: z.coerce.number().min(0).optional(),
textAlign: true, fontSize: z.coerce.number().min(8).max(96).optional(),
lineHeight: true, textAlign: z.enum(['left', 'center', 'right']).optional(),
letterSpacing: true, required: z.boolean().optional(),
verticalAlign: true, readOnly: z.boolean().optional(),
required: true, })
readOnly: true, .refine(
}).refine( (data) => {
(data) => { // A read-only field must have text
// A read-only field must have text return !data.readOnly || (data.text && data.text.length > 0);
return !data.readOnly || (data.text && data.text.length > 0); },
}, {
{ message: 'A read-only field must have text',
message: 'A read-only field must have text', path: ['text'],
path: ['text'], },
}, );
);
type TTextFieldFormSchema = z.infer<typeof ZTextFieldFormSchema>; type TTextFieldFormSchema = z.infer<typeof ZTextFieldFormSchema>;
@ -81,11 +69,8 @@ export const EditorFieldTextForm = ({
placeholder: value.placeholder || '', placeholder: value.placeholder || '',
text: value.text || '', text: value.text || '',
characterLimit: value.characterLimit || 0, characterLimit: value.characterLimit || 0,
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE, fontSize: value.fontSize || 14,
textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN, textAlign: value.textAlign || 'left',
lineHeight: value.lineHeight ?? FIELD_DEFAULT_LINE_HEIGHT,
letterSpacing: value.letterSpacing ?? FIELD_DEFAULT_LETTER_SPACING,
verticalAlign: value.verticalAlign ?? FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN,
required: value.required || false, required: value.required || false,
readOnly: value.readOnly || false, readOnly: value.readOnly || false,
}, },
@ -101,10 +86,6 @@ export const EditorFieldTextForm = ({
useEffect(() => { useEffect(() => {
const validatedFormValues = ZTextFieldFormSchema.safeParse(formValues); const validatedFormValues = ZTextFieldFormSchema.safeParse(formValues);
if (formValues.readOnly && !formValues.text) {
void form.trigger('text');
}
if (validatedFormValues.success) { if (validatedFormValues.success) {
onValueChange({ onValueChange({
type: 'text', type: 'text',
@ -117,14 +98,6 @@ export const EditorFieldTextForm = ({
<Form {...form}> <Form {...form}>
<form> <form>
<fieldset className="flex flex-col gap-2"> <fieldset className="flex flex-col gap-2">
<EditorGenericFontSizeField className="w-full" formControl={form.control} />
<div className="flex w-full flex-row gap-x-4">
<EditorGenericTextAlignField className="w-full" formControl={form.control} />
<EditorGenericVerticalAlignField className="w-full" formControl={form.control} />
</div>
<FormField <FormField
control={form.control} control={form.control}
name="label" name="label"
@ -170,18 +143,6 @@ export const EditorFieldTextForm = ({
className="h-auto" className="h-auto"
placeholder={t`Add text to the field`} placeholder={t`Add text to the field`}
{...field} {...field}
onChange={(e) => {
const values = form.getValues();
const characterLimit = values.characterLimit || 0;
let textValue = e.target.value;
if (characterLimit > 0 && textValue.length > characterLimit) {
textValue = textValue.slice(0, characterLimit);
}
e.target.value = textValue;
field.onChange(e);
}}
rows={1} rows={1}
/> />
</FormControl> </FormControl>
@ -200,22 +161,11 @@ export const EditorFieldTextForm = ({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input
type="number"
min={0}
className="bg-background" className="bg-background"
placeholder={t`Character limit`} placeholder={t`Field character limit`}
{...field} {...field}
value={field.value || ''}
onChange={(e) => {
const values = form.getValues();
const characterLimit = parseInt(e.target.value, 10) || 0;
field.onChange(characterLimit || '');
const textValue = values.text || '';
if (characterLimit > 0 && textValue.length > characterLimit) {
form.setValue('text', textValue.slice(0, characterLimit));
}
}}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@ -224,9 +174,9 @@ export const EditorFieldTextForm = ({
/> />
<div className="flex w-full flex-row gap-x-4"> <div className="flex w-full flex-row gap-x-4">
<EditorGenericLineHeightField className="w-full" formControl={form.control} /> <EditorGenericFontSizeField className="w-full" formControl={form.control} />
<EditorGenericLetterSpacingField className="w-full" formControl={form.control} /> <EditorGenericTextAlignField className="w-full" formControl={form.control} />
</div> </div>
<div className="mt-1"> <div className="mt-1">

View File

@ -39,7 +39,6 @@ export const SubscriptionClaimForm = ({
name: subscriptionClaim.name, name: subscriptionClaim.name,
teamCount: subscriptionClaim.teamCount, teamCount: subscriptionClaim.teamCount,
memberCount: subscriptionClaim.memberCount, memberCount: subscriptionClaim.memberCount,
envelopeItemCount: subscriptionClaim.envelopeItemCount,
flags: subscriptionClaim.flags, flags: subscriptionClaim.flags,
}, },
}); });
@ -112,30 +111,6 @@ export const SubscriptionClaimForm = ({
)} )}
/> />
<FormField
control={form.control}
name="envelopeItemCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Envelope Item Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={1}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Maximum number of uploaded files per envelope allowed</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div> <div>
<FormLabel> <FormLabel>
<Trans>Feature Flags</Trans> <Trans>Feature Flags</Trans>

View File

@ -9,7 +9,6 @@ import { useHotkeys } from 'react-hotkeys-hook';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { Theme, useTheme } from 'remix-themes'; import { Theme, useTheme } from 'remix-themes';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n'; import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
import { import {
@ -64,12 +63,10 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [pages, setPages] = useState<string[]>([]); const [pages, setPages] = useState<string[]>([]);
const debouncedSearch = useDebouncedValue(search, 200);
const { data: searchDocumentsData, isPending: isSearchingDocuments } = const { data: searchDocumentsData, isPending: isSearchingDocuments } =
trpcReact.document.search.useQuery( trpcReact.document.search.useQuery(
{ {
query: debouncedSearch, query: search,
}, },
{ {
placeholderData: (previousData) => previousData, placeholderData: (previousData) => previousData,
@ -235,7 +232,6 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
<Trans>No results found.</Trans> <Trans>No results found.</Trans>
</CommandEmpty> </CommandEmpty>
)} )}
{!currentPage && ( {!currentPage && (
<> <>
{documentPageLinks.length > 0 && ( {documentPageLinks.length > 0 && (
@ -243,17 +239,14 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
<Commands push={push} pages={documentPageLinks} /> <Commands push={push} pages={documentPageLinks} />
</CommandGroup> </CommandGroup>
)} )}
{templatePageLinks.length > 0 && ( {templatePageLinks.length > 0 && (
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Templates`)}> <CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Templates`)}>
<Commands push={push} pages={templatePageLinks} /> <Commands push={push} pages={templatePageLinks} />
</CommandGroup> </CommandGroup>
)} )}
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Settings`)}> <CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Settings`)}>
<Commands push={push} pages={SETTINGS_PAGES} /> <Commands push={push} pages={SETTINGS_PAGES} />
</CommandGroup> </CommandGroup>
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Preferences`)}> <CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Preferences`)}>
<CommandItem className="-mx-2 -my-1 rounded-lg" onSelect={() => addPage('language')}> <CommandItem className="-mx-2 -my-1 rounded-lg" onSelect={() => addPage('language')}>
Change language Change language
@ -262,7 +255,6 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
Change theme Change theme
</CommandItem> </CommandItem>
</CommandGroup> </CommandGroup>
{searchResults.length > 0 && ( {searchResults.length > 0 && (
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Your documents`)}> <CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Your documents`)}>
<Commands push={push} pages={searchResults} /> <Commands push={push} pages={searchResults} />

View File

@ -1,17 +0,0 @@
import type { SVGAttributes } from 'react';
export type LogoProps = SVGAttributes<SVGSVGElement>;
export const BrandingLogoIcon = ({ ...props }: LogoProps) => {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 84 84" {...props}>
<g fill="currentColor">
<path d="M35.53 12.152c-.968.879-2.038 1.91-3.261 3.118a4.55 4.55 0 0 1-2.722.97l-4.098.079 1.194-1.194C33.883 7.885 37.502 4.265 42 4.265s8.118 3.62 15.357 10.86l1.192 1.192-3.957-.075a4.55 4.55 0 0 1-3.004-1.209l-2.373-2.194a69 69 0 0 0-.66-.61l-.128-.119h-.002a35 35 0 0 0-2.244-1.892C44.17 8.684 43 8.338 42 8.338s-2.17.346-4.18 1.88a35 35 0 0 0-2.275 1.92zM71.77 35.444a69 69 0 0 0-.608-.658l-2.196-2.374a4.55 4.55 0 0 1-1.208-3.002l-.077-3.961 1.194 1.194c7.24 7.24 10.86 10.859 10.86 15.357s-3.62 8.118-10.86 15.357l-1.194 1.194.077-3.961a4.55 4.55 0 0 1 1.209-3.002l2.195-2.373q.315-.338.609-.66l.119-.128v-.002a35 35 0 0 0 1.892-2.244c1.534-2.01 1.88-3.18 1.88-4.181s-.346-2.17-1.88-4.18a35 35 0 0 0-1.892-2.245v-.002zM48.51 71.813q.362-.33.747-.69l2.331-2.157a4.55 4.55 0 0 1 3.003-1.208l3.959-.076-1.193 1.193c-7.24 7.24-10.859 10.86-15.357 10.86s-8.118-3.62-15.357-10.86l-1.194-1.194 3.97.076a4.55 4.55 0 0 1 2.991 1.2l1.601 1.47c1.461 1.4 2.69 2.502 3.808 3.355 2.01 1.534 3.18 1.88 4.181 1.88s2.17-.346 4.18-1.88a35 35 0 0 0 2.275-1.92zM12.156 48.476q.364.4.763.825l2.115 2.287a4.55 4.55 0 0 1 1.209 3.002l.076 3.961-1.194-1.194C7.885 50.117 4.265 46.498 4.265 42s3.62-8.118 10.86-15.357l1.193-1.193-.075 3.959a4.55 4.55 0 0 1-1.21 3.004l-2.18 2.357q-.325.346-.626.676l-.117.127v.002a35 35 0 0 0-1.892 2.244C8.684 39.83 8.338 41 8.338 42s.346 2.17 1.88 4.18a35 35 0 0 0 1.92 2.275z" />
<path d="m12.138 35.543 2.896-3.13a4.55 4.55 0 0 0 1.186-2.626c.012-1.61.038-3.013.096-4.254l.003-.17.006-.005c.053-1.072.131-2.021.246-2.875.337-2.506.92-3.578 1.627-4.286s1.78-1.29 4.285-1.626c.87-.117 1.838-.196 2.935-.25l.002-.002h.06c1.285-.062 2.746-.089 4.43-.1a4.55 4.55 0 0 0 2.711-1.257l2.923-2.825h-1.688c-10.238 0-15.357 0-18.538 3.18-3.18 3.181-3.18 8.3-3.18 18.539zM12.138 48.456v1.688c0 10.239 0 15.358 3.18 18.538s8.3 3.18 18.538 3.18h16.289c10.238 0 15.357 0 18.538-3.18 3.18-3.18 3.18-8.3 3.18-18.537v-1.69l-2.897 3.133a4.55 4.55 0 0 0-1.185 2.618c-.012 1.645-.039 3.075-.1 4.335v.04h-.001a35 35 0 0 1-.25 2.936c-.337 2.506-.92 3.578-1.627 4.286s-1.78 1.29-4.285 1.626c-.855.115-1.804.194-2.876.247l-.005.005-.149.003c-1.246.058-2.658.085-4.277.097-.976.1-1.897.515-2.623 1.185l-3.132 2.897H35.573l-3.163-2.906a4.55 4.55 0 0 0-2.61-1.176 110 110 0 0 1-4.324-.1h-.056l-.002-.002a35 35 0 0 1-2.935-.25c-2.505-.336-3.578-.919-4.285-1.626-.708-.708-1.29-1.78-1.627-4.286a35 35 0 0 1-.25-2.935l-.002-.002-.001-.075c-.06-1.251-.086-2.668-.098-4.296a4.55 4.55 0 0 0-1.186-2.621zM67.781 29.794a4.55 4.55 0 0 0 1.185 2.618l2.897 3.132v-1.688c0-10.239 0-15.358-3.18-18.538s-8.3-3.18-18.538-3.18h-1.689l3.132 2.895a4.55 4.55 0 0 0 2.627 1.186c1.6.012 2.997.038 4.232.096l.247.004.008.008a34 34 0 0 1 2.816.244c2.505.337 3.578.919 4.285 1.626.708.708 1.29 1.78 1.627 4.286.117.87.196 1.839.25 2.936l.001.04c.061 1.26.088 2.69.1 4.335M38.91 23.96l-2.747 2.33a2.9 2.9 0 0 1-1.747.689l-4.597.214 2.397-2.397c4.627-4.627 6.94-6.94 9.815-6.94s5.188 2.313 9.815 6.94l2.383 2.382-4.662-.202a2.9 2.9 0 0 1-1.773-.703l-2.074-1.789c-.728-.685-1.345-1.226-1.908-1.656-1.154-.88-1.592-.9-1.78-.9-.19 0-.627.02-1.781.9l-.055.042h-.003l-.027.023c-.387.3-.8.652-1.257 1.067" />
<path d="M61.023 39.995c-.785-.992-1.911-2.163-3.542-3.803a2.9 2.9 0 0 1-.44-1.426l-.202-4.977 2.369 2.368c4.627 4.627 6.94 6.94 6.94 9.815s-2.313 5.188-6.94 9.815l-2.382 2.381.23-4.757a2.9 2.9 0 0 1 .727-1.787l1.742-1.968a28 28 0 0 0 1.387-1.569l.215-.242v-.03l.049-.062c.88-1.154.9-1.592.9-1.781 0-.19-.02-.627-.9-1.78l-.049-.064v-.024zM22.946 40.124l3.175-3.454c.45-.489.719-1.117.762-1.78l.175-2.71c.027-.86.071-1.584.144-2.216l.012-.192.013-.013.009-.065c.193-1.438.488-1.762.622-1.896s.457-.429 1.896-.622c.461-.062.974-.106 1.555-.138l3.9-.385a2.9 2.9 0 0 0 1.678-.75l3.296-3.017h-3.357c-6.543 0-9.815 0-11.847 2.033-1.732 1.732-1.988 4.363-2.026 9.15q-.009 1.246-.007 2.698v3.356" />
<path d="M22.946 43.82v3.357c0 .97 0 1.866.006 2.698.038 4.787.295 7.418 2.027 9.15 1.731 1.732 4.362 1.988 9.15 2.026q1.246.009 2.697.007h10.411q1.45.002 2.697-.007c4.788-.038 7.419-.294 9.15-2.026 2.033-2.033 2.033-5.304 2.033-11.848V43.81l-3.384 3.67a2.9 2.9 0 0 0-.69 1.29c-.006 2.38-.038 4.033-.193 5.306l-.002.068-.008.008-.012.098c-.194 1.438-.489 1.762-.623 1.896-.133.133-.457.429-1.895.622l-.099.013-.008.008-.114.007c-.724.086-1.57.133-2.602.159l-2.32.141c-.661.04-1.288.305-1.778.75l-3.538 3.212h-3.697l-3.536-3.306a2.9 2.9 0 0 0-1.69-.769q-.41 0-.79-.004c-1.906-.016-3.288-.063-4.384-.21-1.439-.194-1.762-.49-1.896-.623-.134-.134-.429-.458-.622-1.896l-.009-.065-.012-.013-.002-.027-.004-.108c-.13-1.084-.171-2.442-.185-4.283l-.02-.472a2.9 2.9 0 0 0-.755-1.833zM57.01 32.35l.19 2.586c.049.652.315 1.27.757 1.751l3.16 3.447v-3.367c0-6.544 0-9.815-2.032-11.848s-5.305-2.033-11.848-2.033H43.85l3.391 3.09c.475.432 1.08.696 1.721.748l3.933.322q.562.033 1.045.085l.29.024.013.012.066.01c1.438.192 1.762.488 1.895.621.134.134.43.458.623 1.896.098.733.152 1.595.182 2.655" />
<path d="m27.226 54.158-.013-.013.002.027.012.013zM29.849 56.78l4.289.199c-1.852-.015-3.208-.06-4.29-.198M27.044 49.476a3 3 0 0 0-.08-.57 3 3 0 0 1 .04.376l.02.472c.014 1.84.056 3.2.185 4.283l.004.108.013.013zM17.915 41.972c0 2.45 1.679 4.491 5.038 7.903q-.009-1.246-.007-2.698v-3.344l-.007-.008v-.005l-.052-.068c-.88-1.153-.9-1.59-.9-1.78s.02-.627.9-1.78l.059-.077v-3.348q-.001-1.452.006-2.698c-3.358 3.412-5.037 5.454-5.037 7.903M40.25 61.116l-.048-.037h-.01l-.022-.021h-3.344q-1.45.002-2.697-.007c3.412 3.358 5.453 5.038 7.902 5.038 2.45 0 4.491-1.68 7.903-5.038q-1.246.009-2.697.007h-3.35l-.075.058c-1.154.88-1.592.9-1.78.9-.19 0-.627-.02-1.781-.9" />
</g>
</svg>
);
};

View File

@ -89,10 +89,7 @@ export const DirectTemplatePageView = ({
setStep('sign'); setStep('sign');
}; };
const onSignDirectTemplateSubmit = async ( const onSignDirectTemplateSubmit = async (fields: DirectTemplateLocalField[]) => {
fields: DirectTemplateLocalField[],
nextSigner?: { name: string; email: string },
) => {
try { try {
let directTemplateExternalId = searchParams?.get('externalId') || undefined; let directTemplateExternalId = searchParams?.get('externalId') || undefined;
@ -101,7 +98,6 @@ export const DirectTemplatePageView = ({
} }
const { token } = await createDocumentFromDirectTemplate({ const { token } = await createDocumentFromDirectTemplate({
nextSigner,
directTemplateToken, directTemplateToken,
directTemplateExternalId, directTemplateExternalId,
directRecipientName: fullName, directRecipientName: fullName,
@ -153,9 +149,7 @@ export const DirectTemplatePageView = ({
<CardContent className="p-2"> <CardContent className="p-2">
<PDFViewer <PDFViewer
key={template.id} key={template.id}
envelopeItem={template.envelopeItems[0]} documentData={template.templateDocumentData}
token={directTemplateRecipient.token}
version="signed"
onDocumentLoad={() => setIsDocumentPdfLoaded(true)} onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
/> />
</CardContent> </CardContent>

View File

@ -55,13 +55,10 @@ import { DocumentSigningRecipientProvider } from '../document-signing/document-s
export type DirectTemplateSigningFormProps = { export type DirectTemplateSigningFormProps = {
flowStep: DocumentFlowStep; flowStep: DocumentFlowStep;
directRecipient: Pick<Recipient, 'authOptions' | 'email' | 'role' | 'name' | 'token' | 'id'>; directRecipient: Pick<Recipient, 'authOptions' | 'email' | 'role' | 'name' | 'token'>;
directRecipientFields: Field[]; directRecipientFields: Field[];
template: Omit<TTemplate, 'user'>; template: Omit<TTemplate, 'user'>;
onSubmit: ( onSubmit: (_data: DirectTemplateLocalField[]) => Promise<void>;
_data: DirectTemplateLocalField[],
_nextSigner?: { name: string; email: string },
) => Promise<void>;
}; };
export type DirectTemplateLocalField = Field & { export type DirectTemplateLocalField = Field & {
@ -152,7 +149,7 @@ export const DirectTemplateSigningForm = ({
validateFieldsInserted(fieldsRequiringValidation); validateFieldsInserted(fieldsRequiringValidation);
}; };
const handleSubmit = async (nextSigner?: { name: string; email: string }) => { const handleSubmit = async () => {
setValidateUninsertedFields(true); setValidateUninsertedFields(true);
const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation); const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation);
@ -164,7 +161,7 @@ export const DirectTemplateSigningForm = ({
setIsSubmitting(true); setIsSubmitting(true);
try { try {
await onSubmit(localFields, nextSigner); await onSubmit(localFields);
} catch { } catch {
setIsSubmitting(false); setIsSubmitting(false);
} }
@ -221,30 +218,6 @@ export const DirectTemplateSigningForm = ({
setLocalFields(updatedFields); setLocalFields(updatedFields);
}, []); }, []);
const nextRecipient = useMemo(() => {
if (
!template.templateMeta?.signingOrder ||
template.templateMeta.signingOrder !== 'SEQUENTIAL' ||
!template.templateMeta.allowDictateNextSigner
) {
return undefined;
}
const sortedRecipients = template.recipients.sort((a, b) => {
// Sort by signingOrder first (nulls last), then by id
if (a.signingOrder === null && b.signingOrder === null) return a.id - b.id;
if (a.signingOrder === null) return 1;
if (b.signingOrder === null) return -1;
if (a.signingOrder === b.signingOrder) return a.id - b.id;
return a.signingOrder - b.signingOrder;
});
const currentIndex = sortedRecipients.findIndex((r) => r.id === directRecipient.id);
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
? sortedRecipients[currentIndex + 1]
: undefined;
}, [template.templateMeta?.signingOrder, template.recipients, directRecipient.id]);
return ( return (
<DocumentSigningRecipientProvider recipient={directRecipient}> <DocumentSigningRecipientProvider recipient={directRecipient}>
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} /> <DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
@ -444,15 +417,11 @@ export const DirectTemplateSigningForm = ({
<DocumentSigningCompleteDialog <DocumentSigningCompleteDialog
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
onSignatureComplete={async (nextSigner) => handleSubmit(nextSigner)} onSignatureComplete={async () => handleSubmit()}
documentTitle={template.title} documentTitle={template.title}
fields={localFields} fields={localFields}
fieldsValidated={fieldsValidated} fieldsValidated={fieldsValidated}
recipient={directRecipient} recipient={directRecipient}
allowDictateNextSigner={nextRecipient && template.templateMeta?.allowDictateNextSigner}
defaultNextSigner={
nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined
}
/> />
</div> </div>
</DocumentFlowFormContainerFooter> </DocumentFlowFormContainerFooter>

View File

@ -8,13 +8,11 @@ import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitive
export type DocumentSigningAttachmentsPopoverProps = { export type DocumentSigningAttachmentsPopoverProps = {
envelopeId: string; envelopeId: string;
token: string; token: string;
trigger?: React.ReactNode;
}; };
export const DocumentSigningAttachmentsPopover = ({ export const DocumentSigningAttachmentsPopover = ({
envelopeId, envelopeId,
token, token,
trigger,
}: DocumentSigningAttachmentsPopoverProps) => { }: DocumentSigningAttachmentsPopoverProps) => {
const { data: attachments } = trpc.envelope.attachment.find.useQuery({ const { data: attachments } = trpc.envelope.attachment.find.useQuery({
envelopeId, envelopeId,
@ -28,17 +26,15 @@ export const DocumentSigningAttachmentsPopover = ({
return ( return (
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
{trigger ?? ( <Button variant="outline" className="gap-2">
<Button variant="outline" className="gap-2"> <PaperclipIcon className="h-4 w-4" />
<PaperclipIcon className="h-4 w-4" /> <span>
<span> <Trans>Attachments</Trans>{' '}
<Trans>Attachments</Trans>{' '} {attachments && attachments.data.length > 0 && (
{attachments && attachments.data.length > 0 && ( <span className="ml-1">({attachments.data.length})</span>
<span className="ml-1">({attachments.data.length})</span> )}
)} </span>
</span> </Button>
</Button>
)}
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-96" align="start"> <PopoverContent className="w-96" align="start">

View File

@ -9,7 +9,7 @@ import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
export type DocumentSigningAuthPageViewProps = { export type DocumentSigningAuthPageViewProps = {
email?: string; email: string;
emailHasAccount?: boolean; emailHasAccount?: boolean;
}; };
@ -22,18 +22,12 @@ export const DocumentSigningAuthPageView = ({
const [isSigningOut, setIsSigningOut] = useState(false); const [isSigningOut, setIsSigningOut] = useState(false);
const handleChangeAccount = async (email?: string) => { const handleChangeAccount = async (email: string) => {
try { try {
setIsSigningOut(true); setIsSigningOut(true);
let redirectPath = '/signin';
if (email) {
redirectPath = emailHasAccount ? `/signin#email=${email}` : `/signup#email=${email}`;
}
await authClient.signOut({ await authClient.signOut({
redirectPath, redirectPath: emailHasAccount ? `/signin#email=${email}` : `/signup#email=${email}`,
}); });
} catch { } catch {
toast({ toast({
@ -55,13 +49,9 @@ export const DocumentSigningAuthPageView = ({
</h1> </h1>
<p className="text-muted-foreground mt-2 text-sm"> <p className="text-muted-foreground mt-2 text-sm">
{email ? ( <Trans>
<Trans> You need to be logged in as <strong>{email}</strong> to view this page.
You need to be logged in as <strong>{email}</strong> to view this page. </Trans>
</Trans>
) : (
<Trans>You need to be logged in to view this page.</Trans>
)}
</p> </p>
<Button <Button

View File

@ -24,10 +24,7 @@ type PasskeyData = {
isError: boolean; isError: boolean;
}; };
type SigningAuthRecipient = Pick< type SigningAuthRecipient = Pick<Recipient, 'authOptions' | 'email' | 'role' | 'name' | 'token'>;
Recipient,
'authOptions' | 'email' | 'role' | 'name' | 'token' | 'id'
>;
export type DocumentSigningAuthContextValue = { export type DocumentSigningAuthContextValue = {
executeActionAuthProcedure: (_value: ExecuteActionAuthProcedureOptions) => Promise<void>; executeActionAuthProcedure: (_value: ExecuteActionAuthProcedureOptions) => Promise<void>;

View File

@ -1,7 +1,7 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { Field, Recipient } from '@prisma/client'; import type { Field, Recipient } from '@prisma/client';
import { RecipientRole } from '@prisma/client'; import { RecipientRole } from '@prisma/client';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
@ -18,9 +18,7 @@ import { Button } from '@documenso/ui/primitives/button';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogFooter, DialogFooter,
DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from '@documenso/ui/primitives/dialog'; } from '@documenso/ui/primitives/dialog';
@ -34,7 +32,6 @@ import {
} from '@documenso/ui/primitives/form/form'; } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
import { AccessAuth2FAForm } from '~/components/general/document-signing/access-auth-2fa-form'; import { AccessAuth2FAForm } from '~/components/general/document-signing/access-auth-2fa-form';
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure'; import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
@ -48,7 +45,6 @@ export type DocumentSigningCompleteDialogProps = {
onSignatureComplete: ( onSignatureComplete: (
nextSigner?: { name: string; email: string }, nextSigner?: { name: string; email: string },
accessAuthOptions?: TRecipientAccessAuth, accessAuthOptions?: TRecipientAccessAuth,
directRecipient?: { name: string; email: string },
) => void | Promise<void>; ) => void | Promise<void>;
recipient: Pick<Recipient, 'name' | 'email' | 'role' | 'token'>; recipient: Pick<Recipient, 'name' | 'email' | 'role' | 'token'>;
disabled?: boolean; disabled?: boolean;
@ -57,12 +53,6 @@ export type DocumentSigningCompleteDialogProps = {
name: string; name: string;
email: string; email: string;
}; };
directTemplatePayload?: {
name: string;
email: string;
};
buttonSize?: 'sm' | 'lg';
position?: 'start' | 'end' | 'center';
}; };
const ZNextSignerFormSchema = z.object({ const ZNextSignerFormSchema = z.object({
@ -73,13 +63,6 @@ const ZNextSignerFormSchema = z.object({
type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>; type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
const ZDirectRecipientFormSchema = z.object({
name: z.string(),
email: z.string().email('Invalid email address'),
});
type TDirectRecipientFormSchema = z.infer<typeof ZDirectRecipientFormSchema>;
export const DocumentSigningCompleteDialog = ({ export const DocumentSigningCompleteDialog = ({
isSubmitting, isSubmitting,
documentTitle, documentTitle,
@ -89,21 +72,15 @@ export const DocumentSigningCompleteDialog = ({
recipient, recipient,
disabled = false, disabled = false,
allowDictateNextSigner = false, allowDictateNextSigner = false,
directTemplatePayload,
defaultNextSigner, defaultNextSigner,
buttonSize = 'lg',
position,
}: DocumentSigningCompleteDialogProps) => { }: DocumentSigningCompleteDialogProps) => {
const { t } = useLingui();
const [showDialog, setShowDialog] = useState(false); const [showDialog, setShowDialog] = useState(false);
const [isEditingNextSigner, setIsEditingNextSigner] = useState(false);
const [showTwoFactorForm, setShowTwoFactorForm] = useState(false); const [showTwoFactorForm, setShowTwoFactorForm] = useState(false);
const [twoFactorValidationError, setTwoFactorValidationError] = useState<string | null>(null); const [twoFactorValidationError, setTwoFactorValidationError] = useState<string | null>(null);
const { derivedRecipientAccessAuth } = useRequiredDocumentSigningAuthContext(); const { derivedRecipientAccessAuth, user } = useRequiredDocumentSigningAuthContext();
const { isNameLocked, isEmailLocked } = useEmbedSigningContext() || {};
const form = useForm<TNextSignerFormSchema>({ const form = useForm<TNextSignerFormSchema>({
resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined, resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined,
@ -113,14 +90,6 @@ export const DocumentSigningCompleteDialog = ({
}, },
}); });
const directRecipientForm = useForm<TDirectRecipientFormSchema>({
resolver: zodResolver(ZDirectRecipientFormSchema),
defaultValues: {
name: directTemplatePayload?.name ?? '',
email: directTemplatePayload?.email ?? '',
},
});
const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]); const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]);
const completionRequires2FA = useMemo( const completionRequires2FA = useMemo(
@ -140,23 +109,12 @@ export const DocumentSigningCompleteDialog = ({
}); });
} }
setIsEditingNextSigner(false);
setShowDialog(open); setShowDialog(open);
}; };
const onFormSubmit = async (data: TNextSignerFormSchema) => { const onFormSubmit = async (data: TNextSignerFormSchema) => {
try { try {
let directRecipient: { name: string; email: string } | undefined;
if (directTemplatePayload && !directTemplatePayload.email) {
const isFormValid = await directRecipientForm.trigger();
if (!isFormValid) {
return;
}
directRecipient = directRecipientForm.getValues();
}
// Check if 2FA is required // Check if 2FA is required
if (completionRequires2FA && !data.accessAuthOptions) { if (completionRequires2FA && !data.accessAuthOptions) {
setShowTwoFactorForm(true); setShowTwoFactorForm(true);
@ -168,7 +126,7 @@ export const DocumentSigningCompleteDialog = ({
? { name: data.name, email: data.email } ? { name: data.name, email: data.email }
: undefined; : undefined;
await onSignatureComplete(nextSigner, data.accessAuthOptions, directRecipient); await onSignatureComplete(nextSigner, data.accessAuthOptions);
} catch (error) { } catch (error) {
const err = AppError.parseError(error); const err = AppError.parseError(error);
@ -194,19 +152,21 @@ export const DocumentSigningCompleteDialog = ({
void form.handleSubmit(onFormSubmit)(); void form.handleSubmit(onFormSubmit)();
}; };
const isNextSignerValid = !allowDictateNextSigner || (form.watch('name') && form.watch('email'));
return ( return (
<Dialog open={showDialog} onOpenChange={handleOpenChange}> <Dialog open={showDialog} onOpenChange={handleOpenChange}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button <Button
className="w-full" className="w-full"
type="button" type="button"
size={buttonSize} size="lg"
onClick={fieldsValidated} onClick={fieldsValidated}
loading={isSubmitting} loading={isSubmitting}
disabled={disabled} disabled={disabled}
> >
{match({ isComplete, role: recipient.role }) {match({ isComplete, role: recipient.role })
.with({ isComplete: false }, () => <Trans>Next Field</Trans>) .with({ isComplete: false }, () => <Trans>Next field</Trans>)
.with({ isComplete: true, role: RecipientRole.APPROVER }, () => <Trans>Approve</Trans>) .with({ isComplete: true, role: RecipientRole.APPROVER }, () => <Trans>Approve</Trans>)
.with({ isComplete: true, role: RecipientRole.VIEWER }, () => ( .with({ isComplete: true, role: RecipientRole.VIEWER }, () => (
<Trans>Mark as viewed</Trans> <Trans>Mark as viewed</Trans>
@ -216,103 +176,106 @@ export const DocumentSigningCompleteDialog = ({
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent position={position}> <DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Are you sure?</Trans>
</DialogTitle>
<DialogDescription>
<div className="text-muted-foreground max-w-[50ch]">
{match(recipient.role)
.with(RecipientRole.VIEWER, () => (
<span className="inline-flex flex-wrap">
<Trans>You are about to complete viewing the following document</Trans>
</span>
))
.with(RecipientRole.SIGNER, () => (
<span className="inline-flex flex-wrap">
<Trans>You are about to complete signing the following document</Trans>
</span>
))
.with(RecipientRole.APPROVER, () => (
<span className="inline-flex flex-wrap">
<Trans>You are about to complete approving the following document</Trans>
</span>
))
.with(RecipientRole.ASSISTANT, () => (
<span className="inline-flex flex-wrap">
<Trans>You are about to complete assisting the following document</Trans>
</span>
))
.with(RecipientRole.CC, () => null)
.exhaustive()}
</div>
</DialogDescription>
</DialogHeader>
<div className="border-border bg-muted/50 rounded-lg border p-4 text-center">
<p className="text-muted-foreground text-sm font-medium">{documentTitle}</p>
</div>
{!showTwoFactorForm && ( {!showTwoFactorForm && (
<> <Form {...form}>
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0"> <form onSubmit={form.handleSubmit(onFormSubmit)}>
{directTemplatePayload && !directTemplatePayload.email && ( <fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
<Form {...directRecipientForm}> <DialogTitle>
<div className="mb-4 flex flex-col gap-4"> <div className="text-foreground text-xl font-semibold">
<div className="flex flex-col gap-4 md:flex-row"> {match(recipient.role)
<FormField .with(RecipientRole.VIEWER, () => <Trans>Complete Viewing</Trans>)
control={directRecipientForm.control} .with(RecipientRole.SIGNER, () => <Trans>Complete Signing</Trans>)
name="name" .with(RecipientRole.APPROVER, () => <Trans>Complete Approval</Trans>)
render={({ field }) => ( .with(RecipientRole.CC, () => <Trans>Complete Viewing</Trans>)
<FormItem className="flex-1"> .with(RecipientRole.ASSISTANT, () => <Trans>Complete Assisting</Trans>)
<FormLabel> .exhaustive()}
<Trans>Your Name</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
className="mt-2"
placeholder={t`Enter your name`}
disabled={isNameLocked}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={directRecipientForm.control}
name="email"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Your Email</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
type="email"
className="mt-2"
placeholder={t`Enter your email`}
disabled={!!field.value && isEmailLocked}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div> </div>
</Form> </DialogTitle>
)}
<Form {...form}> <div className="text-muted-foreground max-w-[50ch]">
<form onSubmit={form.handleSubmit(onFormSubmit)}> {match(recipient.role)
{allowDictateNextSigner && defaultNextSigner && ( .with(RecipientRole.VIEWER, () => (
<div className="mb-4 flex flex-col gap-4"> <span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete viewing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
))
.with(RecipientRole.SIGNER, () => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete signing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
))
.with(RecipientRole.APPROVER, () => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete approving{' '}
<span className="inline-block max-w-[11rem] truncate align-baseline">
"{documentTitle}"
</span>
.
</span>
<br /> Are you sure?
</Trans>
</span>
))
.otherwise(() => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete viewing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
))}
</div>
{allowDictateNextSigner && (
<div className="mt-4 flex flex-col gap-4">
{!isEditingNextSigner && (
<div>
<p className="text-muted-foreground text-sm">
The next recipient to sign this document will be{' '}
<span className="font-semibold">{form.watch('name')}</span> (
<span className="font-semibold">{form.watch('email')}</span>).
</p>
<Button
type="button"
className="mt-2"
variant="outline"
size="sm"
onClick={() => setIsEditingNextSigner((prev) => !prev)}
>
<Trans>Update Recipient</Trans>
</Button>
</div>
)}
{isEditingNextSigner && (
<div className="flex flex-col gap-4 md:flex-row"> <div className="flex flex-col gap-4 md:flex-row">
<FormField <FormField
control={form.control} control={form.control}
@ -320,13 +283,13 @@ export const DocumentSigningCompleteDialog = ({
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex-1"> <FormItem className="flex-1">
<FormLabel> <FormLabel>
<Trans>Next Recipient Name</Trans> <Trans>Name</Trans>
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input
{...field} {...field}
className="mt-2" className="mt-2"
placeholder={t`Enter the next signer's name`} placeholder="Enter the next signer's name"
/> />
</FormControl> </FormControl>
@ -341,14 +304,14 @@ export const DocumentSigningCompleteDialog = ({
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex-1"> <FormItem className="flex-1">
<FormLabel> <FormLabel>
<Trans>Next Recipient Email</Trans> <Trans>Email</Trans>
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input
{...field} {...field}
type="email" type="email"
className="mt-2" className="mt-2"
placeholder={t`Enter the next signer's email`} placeholder="Enter the next signer's email"
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@ -356,14 +319,17 @@ export const DocumentSigningCompleteDialog = ({
)} )}
/> />
</div> </div>
</div> )}
)} </div>
)}
<DocumentSigningDisclosure /> <DocumentSigningDisclosure className="mt-4" />
<DialogFooter className="mt-4"> <DialogFooter className="mt-4">
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button <Button
type="button" type="button"
className="flex-1"
variant="secondary" variant="secondary"
onClick={() => setShowDialog(false)} onClick={() => setShowDialog(false)}
disabled={form.formState.isSubmitting} disabled={form.formState.isSubmitting}
@ -373,7 +339,8 @@ export const DocumentSigningCompleteDialog = ({
<Button <Button
type="submit" type="submit"
disabled={!isComplete} className="flex-1"
disabled={!isComplete || !isNextSignerValid}
loading={form.formState.isSubmitting} loading={form.formState.isSubmitting}
> >
{match(recipient.role) {match(recipient.role)
@ -384,11 +351,11 @@ export const DocumentSigningCompleteDialog = ({
.with(RecipientRole.ASSISTANT, () => <Trans>Complete</Trans>) .with(RecipientRole.ASSISTANT, () => <Trans>Complete</Trans>)
.exhaustive()} .exhaustive()}
</Button> </Button>
</DialogFooter> </div>
</form> </DialogFooter>
</Form> </fieldset>
</fieldset> </form>
</> </Form>
)} )}
{showTwoFactorForm && ( {showTwoFactorForm && (

View File

@ -1,135 +0,0 @@
import { useEffect, useState } from 'react';
import { Plural, Trans } from '@lingui/react/macro';
import { RecipientRole } from '@prisma/client';
import { motion } from 'framer-motion';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { match } from 'ts-pattern';
import { Button } from '@documenso/ui/primitives/button';
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
import { BrandingLogo } from '../branding-logo';
import EnvelopeSignerForm from '../envelope-signing/envelope-signer-form';
import { EnvelopeSignerCompleteDialog } from '../envelope-signing/envelope-signing-complete-dialog';
import { useRequiredEnvelopeSigningContext } from './envelope-signing-provider';
export const DocumentSigningMobileWidget = () => {
const [isExpanded, setIsExpanded] = useState(false);
const { hidePoweredBy = true } = useEmbedSigningContext() || {};
const { recipientFieldsRemaining, recipient, requiredRecipientFields } =
useRequiredEnvelopeSigningContext();
/**
* Pre open the widget for assistants to let them know it's there.
*/
useEffect(() => {
if (recipient.role === RecipientRole.ASSISTANT) {
setIsExpanded(true);
}
}, []);
return (
<div className="pointer-events-none fixed bottom-0 left-0 right-0 z-50 flex justify-center px-2 pb-2 sm:px-4 sm:pb-6">
<div className="pointer-events-auto w-full max-w-[760px]">
<div className="bg-card border-border overflow-hidden rounded-xl border shadow-2xl">
{/* Main Header Bar */}
<div className="flex items-center justify-between gap-4 p-4">
<div className="flex-1">
<div className="flex items-center gap-3">
{recipient.role !== RecipientRole.VIEWER && (
<Button
variant="outline"
onClick={() => setIsExpanded(!isExpanded)}
className="flex h-8 w-8 items-center justify-center"
aria-label={isExpanded ? 'Collapse' : 'Expand'}
>
{isExpanded ? (
<LucideChevronDown className="text-muted-foreground h-5 w-5 flex-shrink-0" />
) : (
<LucideChevronUp className="text-muted-foreground h-5 w-5 flex-shrink-0" />
)}
</Button>
)}
<div>
<h2 className="text-foreground text-lg font-semibold">
{match(recipient.role)
.with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Sign Document</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Approve Document</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Assist Document</Trans>)
.otherwise(() => null)}
</h2>
<p className="text-muted-foreground -mt-0.5 text-sm">
{recipientFieldsRemaining.length === 0 ? (
match(recipient.role)
.with(RecipientRole.VIEWER, () => (
<Trans>Please mark as viewed to complete</Trans>
))
.with(RecipientRole.SIGNER, () => (
<Trans>Please complete the document once reviewed</Trans>
))
.with(RecipientRole.APPROVER, () => (
<Trans>Please complete the document once reviewed</Trans>
))
.with(RecipientRole.ASSISTANT, () => (
<Trans>Please complete the document once reviewed</Trans>
))
.otherwise(() => null)
) : (
<Plural
value={recipientFieldsRemaining.length}
one="1 Field Remaining"
other="# Fields Remaining"
/>
)}
</p>
</div>
</div>
</div>
<div>
<EnvelopeSignerCompleteDialog />
</div>
</div>
{/* Progress Bar */}
{recipient.role !== RecipientRole.VIEWER &&
recipient.role !== RecipientRole.ASSISTANT && (
<div className="px-4 pb-3">
<div className="bg-muted relative h-[4px] rounded-md">
<motion.div
layout="size"
layoutId="document-signing-mobile-widget-progress-bar"
className="bg-documenso absolute inset-y-0 left-0"
style={{
width: `${100 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
}}
/>
</div>
</div>
)}
{/* Expandable Content */}
{isExpanded && (
<div className="border-border animate-in slide-in-from-bottom-2 border-t p-4 duration-200">
<EnvelopeSignerForm />
{!hidePoweredBy && (
<div className="bg-primary text-primary-foreground mt-2 inline-block rounded px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100 lg:hidden">
<span>Powered by</span>
<BrandingLogo className="ml-2 inline-block h-[14px]" />
</div>
)}
</div>
)}
</div>
</div>
</div>
);
};

View File

@ -245,12 +245,7 @@ export const DocumentSigningPageViewV1 = ({
<div className="flex-1"> <div className="flex-1">
<Card className="rounded-xl before:rounded-xl" gradient> <Card className="rounded-xl before:rounded-xl" gradient>
<CardContent className="p-2"> <CardContent className="p-2">
<PDFViewer <PDFViewer key={documentData.id} documentData={documentData} document={document} />
key={document.envelopeItems[0].id}
envelopeItem={document.envelopeItems[0]}
token={recipient.token}
version="signed"
/>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@ -1,20 +1,16 @@
import { lazy, useMemo } from 'react'; import { lazy } from 'react';
import { Plural, Trans } from '@lingui/react/macro'; import { Plural, Trans } from '@lingui/react/macro';
import { EnvelopeType, RecipientRole } from '@prisma/client';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { ArrowLeftIcon, BanIcon, DownloadCloudIcon, PaperclipIcon } from 'lucide-react'; import { ArrowLeftIcon, BanIcon, DownloadCloudIcon } from 'lucide-react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { match } from 'ts-pattern';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider'; import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope'; import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy'; import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator'; import { Separator } from '@documenso/ui/primitives/separator';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
import { SignFieldCheckboxDialog } from '~/components/dialogs/sign-field-checkbox-dialog';
import { SignFieldDropdownDialog } from '~/components/dialogs/sign-field-dropdown-dialog'; import { SignFieldDropdownDialog } from '~/components/dialogs/sign-field-dropdown-dialog';
import { SignFieldEmailDialog } from '~/components/dialogs/sign-field-email-dialog'; import { SignFieldEmailDialog } from '~/components/dialogs/sign-field-email-dialog';
import { SignFieldInitialsDialog } from '~/components/dialogs/sign-field-initials-dialog'; import { SignFieldInitialsDialog } from '~/components/dialogs/sign-field-initials-dialog';
@ -22,15 +18,11 @@ import { SignFieldNameDialog } from '~/components/dialogs/sign-field-name-dialog
import { SignFieldNumberDialog } from '~/components/dialogs/sign-field-number-dialog'; import { SignFieldNumberDialog } from '~/components/dialogs/sign-field-number-dialog';
import { SignFieldSignatureDialog } from '~/components/dialogs/sign-field-signature-dialog'; import { SignFieldSignatureDialog } from '~/components/dialogs/sign-field-signature-dialog';
import { SignFieldTextDialog } from '~/components/dialogs/sign-field-text-dialog'; import { SignFieldTextDialog } from '~/components/dialogs/sign-field-text-dialog';
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
import { BrandingLogo } from '../branding-logo';
import { DocumentSigningAttachmentsPopover } from '../document-signing/document-signing-attachments-popover'; import { DocumentSigningAttachmentsPopover } from '../document-signing/document-signing-attachments-popover';
import { EnvelopeItemSelector } from '../envelope-editor/envelope-file-selector'; import { EnvelopeItemSelector } from '../envelope-editor/envelope-file-selector';
import EnvelopeSignerForm from '../envelope-signing/envelope-signer-form'; import EnvelopeSignerForm from '../envelope-signing/envelope-signer-form';
import { EnvelopeSignerHeader } from '../envelope-signing/envelope-signer-header'; import { EnvelopeSignerHeader } from '../envelope-signing/envelope-signer-header';
import { DocumentSigningMobileWidget } from './document-signing-mobile-widget';
import { DocumentSigningRejectDialog } from './document-signing-reject-dialog';
import { useRequiredEnvelopeSigningContext } from './envelope-signing-provider'; import { useRequiredEnvelopeSigningContext } from './envelope-signing-provider';
const EnvelopeSignerPageRenderer = lazy( const EnvelopeSignerPageRenderer = lazy(
@ -41,37 +33,15 @@ export const DocumentSigningPageViewV2 = () => {
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender(); const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
const { const {
isDirectTemplate,
envelope, envelope,
recipient, recipient,
recipientFields, recipientFields,
recipientFieldsRemaining, recipientFieldsRemaining,
requiredRecipientFields, showPendingFieldTooltip,
selectedAssistantRecipientFields,
} = useRequiredEnvelopeSigningContext(); } = useRequiredEnvelopeSigningContext();
const {
isEmbed = false,
allowDocumentRejection = true,
hidePoweredBy = true,
onDocumentRejected,
} = useEmbedSigningContext() || {};
/**
* The total remaining fields remaining for the current recipient or selected assistant recipient.
*
* Includes both optional and required fields.
*/
const remainingFields = useMemo(() => {
if (recipient.role === RecipientRole.ASSISTANT) {
return selectedAssistantRecipientFields.filter((field) => !field.inserted);
}
return recipientFields.filter((field) => !field.inserted);
}, [recipientFieldsRemaining, selectedAssistantRecipientFields, currentEnvelopeItem]);
return ( return (
<div className="dark:bg-background min-h-screen w-screen bg-gray-50"> <div className="h-screen w-screen bg-gray-50">
<SignFieldEmailDialog.Root /> <SignFieldEmailDialog.Root />
<SignFieldTextDialog.Root /> <SignFieldTextDialog.Root />
<SignFieldNumberDialog.Root /> <SignFieldNumberDialog.Root />
@ -79,29 +49,19 @@ export const DocumentSigningPageViewV2 = () => {
<SignFieldInitialsDialog.Root /> <SignFieldInitialsDialog.Root />
<SignFieldDropdownDialog.Root /> <SignFieldDropdownDialog.Root />
<SignFieldSignatureDialog.Root /> <SignFieldSignatureDialog.Root />
<SignFieldCheckboxDialog.Root />
<EnvelopeSignerHeader /> <EnvelopeSignerHeader />
{/* Main Content Area */} {/* Main Content Area */}
<div className="flex h-[calc(100vh-4rem)] w-screen"> <div className="flex h-[calc(100vh-73px)] w-screen">
{/* Left Section - Step Navigation */} {/* Left Section - Step Navigation */}
<div className="embed--DocumentWidgetContainer bg-background border-border hidden w-80 flex-shrink-0 flex-col overflow-y-auto border-r py-4 lg:flex"> <div className="hidden w-80 flex-shrink-0 flex-col overflow-y-auto border-r border-gray-200 bg-white py-4 lg:flex">
<div className="px-4"> <div className="px-4">
<h3 className="text-foreground flex items-end justify-between text-sm font-semibold"> <h3 className="flex items-end justify-between text-sm font-semibold text-gray-900">
{match(recipient.role) <Trans>Sign Document</Trans>
.with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Sign Document</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Approve Document</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Assist Document</Trans>)
.otherwise(() => null)}
<span className="text-muted-foreground bg-muted/50 ml-2 rounded border px-2 py-0.5 text-xs"> <span className="text-muted-foreground ml-2 rounded border bg-gray-50 px-2 py-0.5 text-xs">
<Plural <Trans>{recipientFieldsRemaining.length} fields remaining</Trans>
value={recipientFieldsRemaining.length}
one="1 Field Remaining"
other="# Fields Remaining"
/>
</span> </span>
</h3> </h3>
@ -111,12 +71,12 @@ export const DocumentSigningPageViewV2 = () => {
layoutId="document-flow-container-step" layoutId="document-flow-container-step"
className="bg-documenso absolute inset-y-0 left-0" className="bg-documenso absolute inset-y-0 left-0"
style={{ style={{
width: `${100 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`, width: `${(100 / recipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
}} }}
/> />
</div> </div>
<div className="embed--DocumentWidgetContent mt-6 space-y-3"> <div className="mt-6 space-y-3">
<EnvelopeSignerForm /> <EnvelopeSignerForm />
</div> </div>
</div> </div>
@ -124,113 +84,88 @@ export const DocumentSigningPageViewV2 = () => {
<Separator className="my-6" /> <Separator className="my-6" />
{/* Quick Actions. */} {/* Quick Actions. */}
{!isDirectTemplate && ( <div className="space-y-3 px-4">
<div className="embed--Actions space-y-3 px-4"> <h4 className="text-sm font-semibold text-gray-900">
<h4 className="text-foreground text-sm font-semibold"> <Trans>Actions</Trans>
<Trans>Actions</Trans> </h4>
</h4>
<DocumentSigningAttachmentsPopover <div className="w-full">
envelopeId={envelope.id} <DocumentSigningAttachmentsPopover envelopeId={envelope.id} token={recipient.token} />
token={recipient.token}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<PaperclipIcon className="mr-2 h-4 w-4" />
<Trans>Attachments</Trans>
</Button>
}
/>
<EnvelopeDownloadDialog
envelopeId={envelope.id}
envelopeStatus={envelope.status}
envelopeItems={envelope.envelopeItems}
token={recipient.token}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<DownloadCloudIcon className="mr-2 h-4 w-4" />
<Trans>Download PDF</Trans>
</Button>
}
/>
{envelope.type === EnvelopeType.DOCUMENT && allowDocumentRejection && (
<DocumentSigningRejectDialog
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
token={recipient.token}
onRejected={
onDocumentRejected &&
((reason) =>
onDocumentRejected({
token: recipient.token,
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
envelopeId: envelope.id,
recipientId: recipient.id,
reason,
}))
}
trigger={
<Button
variant="ghost"
size="sm"
className="hover:text-destructive w-full justify-start"
>
<BanIcon className="mr-2 h-4 w-4" />
<Trans>Reject Document</Trans>
</Button>
}
/>
)}
</div> </div>
)}
<div className="embed--DocumentWidgetFooter mt-auto"> {/* Todo: Allow selecting which document to download and/or the original */}
{/* Footer of left sidebar. */} <Button variant="ghost" size="sm" className="w-full justify-start">
{!isEmbed && ( <DownloadCloudIcon className="mr-2 h-4 w-4" />
<div className="px-4"> <Trans>Download Original</Trans>
<Button asChild variant="ghost" className="w-full justify-start"> </Button>
<Link to="/">
<ArrowLeftIcon className="mr-2 h-4 w-4" /> {/* Todo: Envelopes */}
<Trans>Return</Trans> <Button
</Link> variant="ghost"
</Button> size="sm"
</div> className="hover:text-destructive w-full justify-start"
)} >
<BanIcon className="mr-2 h-4 w-4" />
<Trans>Reject Document</Trans>
</Button>
</div>
{/* Footer of left sidebar. */}
<div className="mt-auto px-4">
<Button asChild variant="ghost" className="w-full justify-start">
<Link to="/">
<ArrowLeftIcon className="mr-2 h-4 w-4" />
<Trans>Return</Trans>
</Link>
</Button>
</div> </div>
</div> </div>
<div className="embed--DocumentContainer flex-1 overflow-y-auto"> {/* Main Content - Changes based on current step */}
<div className="flex-1 overflow-y-auto">
<div className="flex flex-col"> <div className="flex flex-col">
{/* Horizontal envelope item selector */} {/* Horizontal envelope item selector */}
{envelopeItems.length > 1 && ( <div className="flex h-fit space-x-2 overflow-x-auto p-4">
<div className="flex h-fit space-x-2 overflow-x-auto p-2 pt-4 sm:p-4"> {envelopeItems.map((doc, i) => (
{envelopeItems.map((doc, i) => ( <EnvelopeItemSelector
<EnvelopeItemSelector key={doc.id}
key={doc.id} number={i + 1}
number={i + 1} primaryText={doc.title}
primaryText={doc.title} secondaryText={
secondaryText={ <Plural
<Plural one="1 Field"
one="1 Field" other="# Fields"
other="# Fields" value={
value={ recipientFieldsRemaining.filter((field) => field.envelopeItemId === doc.id)
remainingFields.filter((field) => field.envelopeItemId === doc.id).length .length
} }
/> />
} }
isSelected={currentEnvelopeItem?.id === doc.id} isSelected={currentEnvelopeItem?.id === doc.id}
buttonProps={{ onClick: () => setCurrentEnvelopeItem(doc.id) }} buttonProps={{ onClick: () => setCurrentEnvelopeItem(doc.id) }}
/> />
))} ))}
</div> </div>
)}
{/* Document View */} {/* Document View */}
<div className="embed--DocumentViewer flex flex-col items-center justify-center p-2 sm:mt-4 sm:p-4"> <div className="mt-4 flex justify-center p-4">
{currentEnvelopeItem &&
showPendingFieldTooltip &&
recipientFieldsRemaining.length > 0 &&
recipientFieldsRemaining[0]?.envelopeItemId === currentEnvelopeItem?.id && (
<FieldToolTip
key={recipientFieldsRemaining[0].id}
field={recipientFieldsRemaining[0]}
color="warning"
>
<Trans>Click to insert field</Trans>
</FieldToolTip>
)}
{currentEnvelopeItem ? ( {currentEnvelopeItem ? (
<PDFViewerKonvaLazy <PDFViewerKonvaLazy
renderer="signing"
key={currentEnvelopeItem.id} key={currentEnvelopeItem.id}
documentDataId={currentEnvelopeItem.documentDataId}
customPageRenderer={EnvelopeSignerPageRenderer} customPageRenderer={EnvelopeSignerPageRenderer}
/> />
) : ( ) : (
@ -240,22 +175,6 @@ export const DocumentSigningPageViewV2 = () => {
</p> </p>
</div> </div>
)} )}
{/* Mobile widget - Additional padding to allow users to scroll */}
<div className="block pb-28 lg:hidden">
<DocumentSigningMobileWidget />
</div>
{!hidePoweredBy && (
<a
href="https://documenso.com"
target="_blank"
className="bg-primary text-primary-foreground fixed bottom-0 right-0 z-40 hidden cursor-pointer rounded-tl px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100 lg:block"
>
<span>Powered by</span>
<BrandingLogo className="ml-2 inline-block h-[14px]" />
</a>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -39,14 +39,12 @@ export interface DocumentSigningRejectDialogProps {
documentId: number; documentId: number;
token: string; token: string;
onRejected?: (reason: string) => void | Promise<void>; onRejected?: (reason: string) => void | Promise<void>;
trigger?: React.ReactNode;
} }
export function DocumentSigningRejectDialog({ export function DocumentSigningRejectDialog({
documentId, documentId,
token, token,
onRejected, onRejected,
trigger,
}: DocumentSigningRejectDialogProps) { }: DocumentSigningRejectDialogProps) {
const { toast } = useToast(); const { toast } = useToast();
const navigate = useNavigate(); const navigate = useNavigate();
@ -110,11 +108,9 @@ export function DocumentSigningRejectDialog({
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
{trigger ?? ( <Button variant="outline">
<Button variant="outline"> <Trans>Reject Document</Trans>
<Trans>Reject Document</Trans> </Button>
</Button>
)}
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>

View File

@ -1,30 +1,21 @@
import { createContext, useContext, useMemo, useState } from 'react'; import { createContext, useContext, useMemo, useState } from 'react';
import { import {
EnvelopeType,
type Field, type Field,
FieldType, FieldType,
type Recipient, type Recipient,
RecipientRole, RecipientRole,
SigningStatus, SigningStatus,
} from '@prisma/client'; } from '@prisma/client';
import { prop, sortBy } from 'remeda';
import { isBase64Image } from '@documenso/lib/constants/signatures'; import { isBase64Image } from '@documenso/lib/constants/signatures';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import type { EnvelopeForSigningResponse } from '@documenso/lib/server-only/envelope/get-envelope-for-recipient-signing'; import type { EnvelopeForSigningResponse } from '@documenso/lib/server-only/envelope/get-envelope-for-recipient-signing';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import {
isFieldUnsignedAndRequired,
isRequiredField,
} from '@documenso/lib/utils/advanced-fields-helpers';
import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { TSignEnvelopeFieldValue } from '@documenso/trpc/server/envelope-router/sign-envelope-field.types'; import type { TSignEnvelopeFieldValue } from '@documenso/trpc/server/envelope-router/sign-envelope-field.types';
export type EnvelopeSigningContextValue = { export type EnvelopeSigningContextValue = {
isDirectTemplate: boolean;
fullName: string; fullName: string;
setFullName: (_value: string) => void; setFullName: (_value: string) => void;
email: string; email: string;
@ -41,8 +32,7 @@ export type EnvelopeSigningContextValue = {
recipient: EnvelopeForSigningResponse['recipient']; recipient: EnvelopeForSigningResponse['recipient'];
recipientFieldsRemaining: Field[]; recipientFieldsRemaining: Field[];
recipientFields: Field[]; recipientFields: Field[];
requiredRecipientFields: Field[]; selectedRecipientFields: Field[];
selectedAssistantRecipientFields: Field[];
nextRecipient: EnvelopeForSigningResponse['envelope']['recipients'][number] | null; nextRecipient: EnvelopeForSigningResponse['envelope']['recipients'][number] | null;
otherRecipientCompletedFields: (Field & { otherRecipientCompletedFields: (Field & {
recipient: Pick<Recipient, 'name' | 'email' | 'signingStatus' | 'role'>; recipient: Pick<Recipient, 'name' | 'email' | 'signingStatus' | 'role'>;
@ -52,11 +42,7 @@ export type EnvelopeSigningContextValue = {
setSelectedAssistantRecipientId: (_value: number | null) => void; setSelectedAssistantRecipientId: (_value: number | null) => void;
selectedAssistantRecipient: EnvelopeForSigningResponse['envelope']['recipients'][number] | null; selectedAssistantRecipient: EnvelopeForSigningResponse['envelope']['recipients'][number] | null;
signField: ( signField: (_fieldId: number, _value: TSignEnvelopeFieldValue) => Promise<void>;
_fieldId: number,
_value: TSignEnvelopeFieldValue,
authOptions?: TRecipientActionAuth,
) => Promise<Pick<Field, 'id' | 'inserted'>>;
}; };
const EnvelopeSigningContext = createContext<EnvelopeSigningContextValue | null>(null); const EnvelopeSigningContext = createContext<EnvelopeSigningContextValue | null>(null);
@ -99,31 +85,26 @@ export const EnvelopeSigningProvider = ({
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false); const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
const isDirectTemplate = envelope.type === EnvelopeType.TEMPLATE; const {
mutateAsync: completeDocument,
isPending,
isSuccess,
} = trpc.recipient.completeDocumentWithToken.useMutation();
const { mutateAsync: signEnvelopeField } = trpc.envelope.field.sign.useMutation({ const { mutateAsync: signEnvelopeField } = trpc.envelope.field.sign.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (data) => { onSuccess: (data) => {
console.log('signEnvelopeField', data);
const newRecipientFields = envelopeData.recipient.fields.map((field) =>
field.id === data.signedField.id ? data.signedField : field,
);
setEnvelopeData((prev) => ({ setEnvelopeData((prev) => ({
...prev, ...prev,
envelope: {
...prev.envelope,
recipients: prev.envelope.recipients.map((recipient) =>
recipient.id === data.signedField.recipientId
? {
...recipient,
fields: recipient.fields.map((field) =>
field.id === data.signedField.id ? data.signedField : field,
),
}
: recipient,
),
},
recipient: { recipient: {
...prev.recipient, ...prev.recipient,
fields: prev.recipient.fields.map((field) => fields: newRecipientFields,
field.id === data.signedField.id ? data.signedField : field,
),
}, },
})); }));
}, },
@ -167,49 +148,6 @@ export const EnvelopeSigningProvider = ({
})(), })(),
); );
/**
* The fields that are still required to be signed by the actual recipient.
*/
const recipientFieldsRemaining = useMemo(() => {
const requiredFields = envelopeData.recipient.fields
.filter((field) => isFieldUnsignedAndRequired(field))
.map((field) => {
const envelopeItem = envelope.envelopeItems.find(
(item) => item.id === field.envelopeItemId,
);
if (!envelopeItem) {
throw new Error('Missing envelope item');
}
return {
...field,
envelopeItemOrder: envelopeItem.order,
};
});
return sortBy(
requiredFields,
[prop('envelopeItemOrder'), 'asc'],
[prop('page'), 'asc'],
[prop('positionY'), 'asc'],
);
}, [envelopeData.recipient.fields]);
/**
* All the required fields for the actual recipient.
*/
const requiredRecipientFields = useMemo(() => {
return envelopeData.recipient.fields.filter((field) => isRequiredField(field));
}, [envelopeData.recipient.fields]);
/**
* All the fields for the actual recipient.
*/
const recipientFields = useMemo(() => {
return envelopeData.recipient.fields;
}, [envelopeData.recipient.fields]);
/** /**
* Assistant recipients are those that have a signing order after the assistant. * Assistant recipients are those that have a signing order after the assistant.
*/ */
@ -243,8 +181,22 @@ export const EnvelopeSigningProvider = ({
return envelope.recipients.find((r) => r.id === selectedAssistantRecipientId) || null; return envelope.recipients.find((r) => r.id === selectedAssistantRecipientId) || null;
}, [envelope.recipients, selectedAssistantRecipientId]); }, [envelope.recipients, selectedAssistantRecipientId]);
const selectedAssistantRecipientFields = useMemo(() => { /**
return assistantFields.filter((field) => field.recipientId === selectedAssistantRecipient?.id); * The fields that are still required to be signed by the current recipient.
*/
const recipientFieldsRemaining = useMemo(() => {
return envelopeData.recipient.fields.filter((field) => isFieldUnsignedAndRequired(field));
}, [envelopeData.recipient.fields]);
/**
* All the fields for the current recipient.
*/
const recipientFields = useMemo(() => {
return envelopeData.recipient.fields;
}, [envelopeData.recipient.fields]);
const selectedRecipientFields = useMemo(() => {
return recipientFields.filter((field) => field.recipientId === selectedAssistantRecipient?.id);
}, [recipientFields, selectedAssistantRecipient]); }, [recipientFields, selectedAssistantRecipient]);
/** /**
@ -289,91 +241,20 @@ export const EnvelopeSigningProvider = ({
: null; : null;
}, [envelope.documentMeta?.signingOrder, envelope.recipients, recipient.id]); }, [envelope.documentMeta?.signingOrder, envelope.recipients, recipient.id]);
const signField = async ( const signField = async (fieldId: number, fieldValue: TSignEnvelopeFieldValue) => {
fieldId: number, console.log('insertField', fieldId, fieldValue);
fieldValue: TSignEnvelopeFieldValue,
authOptions?: TRecipientActionAuth,
) => {
// Set the field locally for direct templates.
if (isDirectTemplate) {
const signedField = handleDirectTemplateFieldInsertion(fieldId, fieldValue);
return signedField; await signEnvelopeField({
}
const { signedField } = await signEnvelopeField({
token: envelopeData.recipient.token, token: envelopeData.recipient.token,
fieldId, fieldId,
fieldValue, fieldValue,
authOptions, authOptions: undefined,
}); });
return signedField;
};
const handleDirectTemplateFieldInsertion = (
fieldId: number,
fieldValue: TSignEnvelopeFieldValue,
) => {
const foundField = recipient.fields.find((field) => field.id === fieldId);
if (!foundField) {
throw new Error('Not possible');
}
const insertionValues = extractFieldInsertionValues({
fieldValue,
field: foundField,
documentMeta: envelope.documentMeta,
});
const updatedField = {
...foundField,
...insertionValues,
};
if (fieldValue.type === FieldType.SIGNATURE) {
const isBase64 = isBase64Image(fieldValue.value || '');
updatedField.signature = fieldValue.value
? {
signatureImageAsBase64: isBase64 ? fieldValue.value : null,
typedSignature: isBase64 ? null : fieldValue.value,
recipientId: recipient.id,
created: new Date(),
// Dummy IDs.
id: 0,
fieldId: 0,
}
: null;
}
setEnvelopeData((prev) => ({
...prev,
envelope: {
...prev.envelope,
recipients: prev.envelope.recipients.map((r) =>
r.id === recipient.id
? {
...r,
fields: r.fields.map((field) => (field.id === fieldId ? updatedField : field)),
}
: r,
),
},
recipient: {
...prev.recipient,
fields: prev.recipient.fields.map((field) => (field.id === fieldId ? updatedField : field)),
},
}));
return updatedField;
}; };
return ( return (
<EnvelopeSigningContext.Provider <EnvelopeSigningContext.Provider
value={{ value={{
isDirectTemplate,
fullName, fullName,
setFullName, setFullName,
email, email,
@ -389,7 +270,6 @@ export const EnvelopeSigningProvider = ({
recipient, recipient,
recipientFieldsRemaining, recipientFieldsRemaining,
recipientFields, recipientFields,
requiredRecipientFields,
nextRecipient, nextRecipient,
otherRecipientCompletedFields, otherRecipientCompletedFields,
@ -397,7 +277,7 @@ export const EnvelopeSigningProvider = ({
assistantFields, assistantFields,
setSelectedAssistantRecipientId, setSelectedAssistantRecipientId,
selectedAssistantRecipient, selectedAssistantRecipient,
selectedAssistantRecipientFields, selectedRecipientFields,
signField, signField,
}} }}

View File

@ -10,7 +10,6 @@ import { z } from 'zod';
import { AppError } from '@documenso/lib/errors/app-error'; import { AppError } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
Form, Form,
@ -25,8 +24,6 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export type DocumentAttachmentsPopoverProps = { export type DocumentAttachmentsPopoverProps = {
envelopeId: string; envelopeId: string;
buttonClassName?: string;
buttonSize?: 'sm' | 'default';
}; };
const ZAttachmentFormSchema = z.object({ const ZAttachmentFormSchema = z.object({
@ -36,11 +33,7 @@ const ZAttachmentFormSchema = z.object({
type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>; type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>;
export const DocumentAttachmentsPopover = ({ export const DocumentAttachmentsPopover = ({ envelopeId }: DocumentAttachmentsPopoverProps) => {
envelopeId,
buttonClassName,
buttonSize,
}: DocumentAttachmentsPopoverProps) => {
const { toast } = useToast(); const { toast } = useToast();
const { _ } = useLingui(); const { _ } = useLingui();
@ -125,7 +118,7 @@ export const DocumentAttachmentsPopover = ({
return ( return (
<Popover open={isOpen} onOpenChange={setIsOpen}> <Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant="outline" className={cn('gap-2', buttonClassName)} size={buttonSize}> <Button variant="outline" className="gap-2">
<Paperclip className="h-4 w-4" /> <Paperclip className="h-4 w-4" />
<span> <span>
@ -222,6 +215,9 @@ export const DocumentAttachmentsPopover = ({
/> />
<div className="flex gap-2"> <div className="flex gap-2">
<Button type="submit" size="sm" className="flex-1" loading={isCreating}>
<Trans>Add</Trans>
</Button>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@ -234,9 +230,6 @@ export const DocumentAttachmentsPopover = ({
> >
<Trans>Cancel</Trans> <Trans>Cancel</Trans>
</Button> </Button>
<Button type="submit" size="sm" className="flex-1" loading={isCreating}>
<Trans>Add</Trans>
</Button>
</div> </div>
</form> </form>
</Form> </Form>

View File

@ -1,14 +1,10 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { type DocumentData, DocumentStatus, type EnvelopeItem, EnvelopeType } from '@prisma/client'; import type { DocumentData, EnvelopeItem } from '@prisma/client';
import { DownloadIcon } from 'lucide-react';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
EnvelopeRenderProvider,
useCurrentEnvelopeRender,
} from '@documenso/lib/client-only/providers/envelope-render-provider';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy'; import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
@ -23,10 +19,9 @@ import {
} from '@documenso/ui/primitives/dialog'; } from '@documenso/ui/primitives/dialog';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer'; import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
import { EnvelopeRendererFileSelector } from '../envelope-editor/envelope-file-selector'; import { EnvelopeRendererFileSelector } from '../envelope-editor/envelope-file-selector';
import EnvelopeGenericPageRenderer from '../envelope-editor/envelope-generic-page-renderer'; import EnvelopeGenericPageRenderer from '../envelope-editor/envelope-generic-page-renderer';
import { ShareDocumentDownloadButton } from '../share-document-download-button';
export type DocumentCertificateQRViewProps = { export type DocumentCertificateQRViewProps = {
documentId: number; documentId: number;
@ -36,7 +31,6 @@ export type DocumentCertificateQRViewProps = {
documentTeamUrl: string; documentTeamUrl: string;
recipientCount?: number; recipientCount?: number;
completedDate?: Date; completedDate?: Date;
token: string;
}; };
export const DocumentCertificateQRView = ({ export const DocumentCertificateQRView = ({
@ -47,7 +41,6 @@ export const DocumentCertificateQRView = ({
documentTeamUrl, documentTeamUrl,
recipientCount = 0, recipientCount = 0,
completedDate, completedDate,
token,
}: DocumentCertificateQRViewProps) => { }: DocumentCertificateQRViewProps) => {
const { data: documentViaUser } = trpc.document.get.useQuery({ const { data: documentViaUser } = trpc.document.get.useQuery({
documentId, documentId,
@ -99,83 +92,6 @@ export const DocumentCertificateQRView = ({
</Dialog> </Dialog>
)} )}
{internalVersion === 2 ? (
<EnvelopeRenderProvider
envelope={{
envelopeItems,
status: DocumentStatus.COMPLETED,
type: EnvelopeType.DOCUMENT,
}}
token={token}
>
<DocumentCertificateQrV2
title={title}
recipientCount={recipientCount}
formattedDate={formattedDate}
token={token}
/>
</EnvelopeRenderProvider>
) : (
<>
<div className="flex w-full flex-col justify-between gap-4 md:flex-row md:items-end">
<div className="space-y-1">
<h1 className="text-xl font-medium">{title}</h1>
<div className="text-muted-foreground flex flex-col gap-0.5 text-sm">
<p>
<Trans>{recipientCount} recipients</Trans>
</p>
<p>
<Trans>Completed on {formattedDate}</Trans>
</p>
</div>
</div>
<EnvelopeDownloadDialog
envelopeId={envelopeItems[0].envelopeId}
envelopeStatus={DocumentStatus.COMPLETED}
envelopeItems={envelopeItems}
token={token}
trigger={
<Button type="button" variant="outline" className="w-fit">
<DownloadIcon className="mr-2 h-5 w-5" />
<Trans>Download</Trans>
</Button>
}
/>
</div>
<div className="mt-12 w-full">
<PDFViewer
key={envelopeItems[0].id}
envelopeItem={envelopeItems[0]}
token={token}
version="signed"
/>
</div>
</>
)}
</div>
);
};
type DocumentCertificateQrV2Props = {
title: string;
recipientCount: number;
formattedDate: string;
token: string;
};
const DocumentCertificateQrV2 = ({
title,
recipientCount,
formattedDate,
token,
}: DocumentCertificateQrV2Props) => {
const { currentEnvelopeItem, envelopeItems } = useCurrentEnvelopeRender();
return (
<div className="flex min-h-screen flex-col items-start">
<div className="flex w-full flex-col justify-between gap-4 md:flex-row md:items-end"> <div className="flex w-full flex-col justify-between gap-4 md:flex-row md:items-end">
<div className="space-y-1"> <div className="space-y-1">
<h1 className="text-xl font-medium">{title}</h1> <h1 className="text-xl font-medium">{title}</h1>
@ -190,24 +106,21 @@ const DocumentCertificateQrV2 = ({
</div> </div>
</div> </div>
<EnvelopeDownloadDialog <ShareDocumentDownloadButton title={title} documentData={envelopeItems[0].documentData} />
envelopeId={envelopeItems[0].envelopeId}
envelopeStatus={DocumentStatus.COMPLETED}
envelopeItems={envelopeItems}
token={token}
trigger={
<Button type="button" variant="outline" className="w-fit">
<DownloadIcon className="mr-2 h-5 w-5" />
<Trans>Download</Trans>
</Button>
}
/>
</div> </div>
<div className="mt-12 w-full"> <div className="mt-12 w-full">
<EnvelopeRendererFileSelector className="mb-4 p-0" fields={[]} secondaryOverride={''} /> {internalVersion === 2 ? (
<EnvelopeRenderProvider envelope={{ envelopeItems }}>
<EnvelopeRendererFileSelector className="mb-4 p-0" fields={[]} secondaryOverride={''} />
<PDFViewerKonvaLazy renderer="preview" customPageRenderer={EnvelopeGenericPageRenderer} /> <PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
</EnvelopeRenderProvider>
) : (
<>
<PDFViewer key={envelopeItems[0].id} documentData={envelopeItems[0].documentData} />
</>
)}
</div> </div>
</div> </div>
); );

View File

@ -1,15 +1,10 @@
import { type ReactNode, useState } from 'react'; import { type ReactNode, useState } from 'react';
import { useLingui } from '@lingui/react/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { Loader } from 'lucide-react'; import { Loader } from 'lucide-react';
import { import { ErrorCode, type FileRejection, useDropzone } from 'react-dropzone';
ErrorCode as DropzoneErrorCode,
ErrorCode,
type FileRejection,
useDropzone,
} from 'react-dropzone';
import { Link, useNavigate, useParams } from 'react-router'; import { Link, useNavigate, useParams } from 'react-router';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
@ -21,26 +16,21 @@ import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT, IS_BILLING_ENABLED } from '@documenso/l
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones'; import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions'; import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams'; import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { TCreateEnvelopePayload } from '@documenso/trpc/server/envelope-router/create-envelope.types';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team'; import { useCurrentTeam } from '~/providers/team';
export interface EnvelopeDropZoneWrapperProps { export interface DocumentDropZoneWrapperProps {
children: ReactNode; children: ReactNode;
type: EnvelopeType;
className?: string; className?: string;
} }
export const EnvelopeDropZoneWrapper = ({ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZoneWrapperProps) => {
children, const { _ } = useLingui();
type,
className,
}: EnvelopeDropZoneWrapperProps) => {
const { t } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const { user } = useSession(); const { user } = useSession();
const { folderId } = useParams(); const { folderId } = useParams();
@ -57,13 +47,13 @@ export const EnvelopeDropZoneWrapper = ({
TIME_ZONES.find((timezone) => timezone === Intl.DateTimeFormat().resolvedOptions().timeZone) ?? TIME_ZONES.find((timezone) => timezone === Intl.DateTimeFormat().resolvedOptions().timeZone) ??
DEFAULT_DOCUMENT_TIME_ZONE; DEFAULT_DOCUMENT_TIME_ZONE;
const { quota, remaining, refreshLimits, maximumEnvelopeItemCount } = useLimits(); const { quota, remaining, refreshLimits } = useLimits();
const { mutateAsync: createEnvelope } = trpc.envelope.create.useMutation(); const { mutateAsync: createDocument } = trpc.document.create.useMutation();
const isUploadDisabled = remaining.documents === 0 || !user.emailVerified; const isUploadDisabled = remaining.documents === 0 || !user.emailVerified;
const onFileDrop = async (files: File[]) => { const onFileDrop = async (file: File) => {
if (isUploadDisabled && IS_BILLING_ENABLED()) { if (isUploadDisabled && IS_BILLING_ENABLED()) {
await navigate(`/o/${organisation.url}/settings/billing`); await navigate(`/o/${organisation.url}/settings/billing`);
return; return;
@ -72,68 +62,44 @@ export const EnvelopeDropZoneWrapper = ({
try { try {
setIsLoading(true); setIsLoading(true);
const payload = { const response = await putPdfFile(file);
folderId,
type,
title: files[0].name,
meta: {
timezone: userTimezone,
},
} satisfies TCreateEnvelopePayload;
const formData = new FormData(); const { legacyDocumentId: id } = await createDocument({
title: file.name,
formData.append('payload', JSON.stringify(payload)); documentDataId: response.id,
timezone: userTimezone, // Note: When migrating to v2 document upload remember to pass this through as a 'userTimezone' field.
for (const file of files) { folderId: folderId ?? undefined,
formData.append('files', file); });
}
const { id } = await createEnvelope(formData);
void refreshLimits(); void refreshLimits();
toast({ toast({
title: type === EnvelopeType.DOCUMENT ? t`Document uploaded` : t`Template uploaded`, title: _(msg`Document uploaded`),
description: description: _(msg`Your document has been uploaded successfully.`),
type === EnvelopeType.DOCUMENT
? t`Your document has been uploaded successfully.`
: t`Your template has been uploaded successfully.`,
duration: 5000, duration: 5000,
}); });
if (type === EnvelopeType.DOCUMENT) { analytics.capture('App: Document Uploaded', {
analytics.capture('App: Document Uploaded', { userId: user.id,
userId: user.id, documentId: id,
documentId: id, timestamp: new Date().toISOString(),
timestamp: new Date().toISOString(), });
});
}
const pathPrefix = await navigate(`${formatDocumentsPath(team.url)}/${id}/edit`);
type === EnvelopeType.DOCUMENT
? formatDocumentsPath(team.url)
: formatTemplatesPath(team.url);
await navigate(`${pathPrefix}/${id}/edit`);
} catch (err) { } catch (err) {
const error = AppError.parseError(err); const error = AppError.parseError(err);
const errorMessage = match(error.code) const errorMessage = match(error.code)
.with('INVALID_DOCUMENT_FILE', () => t`You cannot upload encrypted PDFs`) .with('INVALID_DOCUMENT_FILE', () => msg`You cannot upload encrypted PDFs`)
.with( .with(
AppErrorCode.LIMIT_EXCEEDED, AppErrorCode.LIMIT_EXCEEDED,
() => t`You have reached your document limit for this month. Please upgrade your plan.`, () => msg`You have reached your document limit for this month. Please upgrade your plan.`,
) )
.with( .otherwise(() => msg`An error occurred while uploading your document.`);
'ENVELOPE_ITEM_LIMIT_EXCEEDED',
() => t`You have reached the limit of the number of files per envelope`,
)
.otherwise(() => t`An error occurred during upload.`);
toast({ toast({
title: t`Error`, title: _(msg`Error`),
description: errorMessage, description: _(errorMessage),
variant: 'destructive', variant: 'destructive',
duration: 7500, duration: 7500,
}); });
@ -147,20 +113,6 @@ export const EnvelopeDropZoneWrapper = ({
return; return;
} }
const maxItemsReached = fileRejections.some((fileRejection) =>
fileRejection.errors.some((error) => error.code === DropzoneErrorCode.TooManyFiles),
);
if (maxItemsReached) {
toast({
title: t`You cannot upload more than ${maximumEnvelopeItemCount} items per envelope.`,
duration: 5000,
variant: 'destructive',
});
return;
}
// Since users can only upload only one file (no multi-upload), we only handle the first file rejection // Since users can only upload only one file (no multi-upload), we only handle the first file rejection
const { file, errors } = fileRejections[0]; const { file, errors } = fileRejections[0];
@ -188,14 +140,14 @@ export const EnvelopeDropZoneWrapper = ({
const description = ( const description = (
<> <>
<span className="font-medium"> <span className="font-medium">
<Trans>{file.name} couldn't be uploaded:</Trans> {file.name} <Trans>couldn't be uploaded:</Trans>
</span> </span>
{errorNodes} {errorNodes}
</> </>
); );
toast({ toast({
title: t`Upload failed`, title: _(msg`Upload failed`),
description, description,
duration: 5000, duration: 5000,
variant: 'destructive', variant: 'destructive',
@ -205,11 +157,17 @@ export const EnvelopeDropZoneWrapper = ({
accept: { accept: {
'application/pdf': ['.pdf'], 'application/pdf': ['.pdf'],
}, },
multiple: true, //disabled: isUploadDisabled,
multiple: false,
maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT), maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT),
maxFiles: maximumEnvelopeItemCount, onDrop: ([acceptedFile]) => {
onDrop: (files) => void onFileDrop(files), if (acceptedFile) {
onDropRejected: onFileDropRejected, void onFileDrop(acceptedFile);
}
},
onDropRejected: (fileRejections) => {
onFileDropRejected(fileRejections);
},
noClick: true, noClick: true,
noDragEventsBubbling: true, noDragEventsBubbling: true,
}); });
@ -223,11 +181,7 @@ export const EnvelopeDropZoneWrapper = ({
<div className="bg-muted/60 fixed left-0 top-0 z-[9999] h-full w-full backdrop-blur-[4px]"> <div className="bg-muted/60 fixed left-0 top-0 z-[9999] h-full w-full backdrop-blur-[4px]">
<div className="pointer-events-none flex h-full w-full flex-col items-center justify-center"> <div className="pointer-events-none flex h-full w-full flex-col items-center justify-center">
<h2 className="text-foreground text-2xl font-semibold"> <h2 className="text-foreground text-2xl font-semibold">
{type === EnvelopeType.DOCUMENT ? ( <Trans>Upload Document</Trans>
<Trans>Upload Document</Trans>
) : (
<Trans>Upload Template</Trans>
)}
</h2> </h2>
<p className="text-muted-foreground text-md mt-4"> <p className="text-muted-foreground text-md mt-4">
@ -262,7 +216,7 @@ export const EnvelopeDropZoneWrapper = ({
<div className="pointer-events-none flex h-1/2 w-full flex-col items-center justify-center"> <div className="pointer-events-none flex h-1/2 w-full flex-col items-center justify-center">
<Loader className="text-primary h-12 w-12 animate-spin" /> <Loader className="text-primary h-12 w-12 animate-spin" />
<p className="text-foreground mt-8 font-medium"> <p className="text-foreground mt-8 font-medium">
<Trans>Uploading</Trans> <Trans>Uploading document...</Trans>
</p> </p>
</div> </div>
</div> </div>

View File

@ -441,10 +441,9 @@ export const DocumentEditForm = ({
> >
<CardContent className="p-2"> <CardContent className="p-2">
<PDFViewer <PDFViewer
key={document.envelopeItems[0].id} key={document.documentData.id}
envelopeItem={document.envelopeItems[0]} documentData={document.documentData}
token={undefined} document={document}
version="signed"
onDocumentLoad={() => setIsDocumentPdfLoaded(true)} onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
/> />
</CardContent> </CardContent>

View File

@ -1,16 +1,18 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client'; import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
import { CheckCircle, Download, EyeIcon, Pencil } from 'lucide-react'; import { CheckCircle, Download, EyeIcon, Pencil } from 'lucide-react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import type { TEnvelope } from '@documenso/lib/types/envelope'; import type { TEnvelope } from '@documenso/lib/types/envelope';
import { isDocumentCompleted } from '@documenso/lib/utils/document'; import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
export type DocumentPageViewButtonProps = { export type DocumentPageViewButtonProps = {
envelope: TEnvelope; envelope: TEnvelope;
@ -19,6 +21,9 @@ export type DocumentPageViewButtonProps = {
export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps) => { export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps) => {
const { user } = useSession(); const { user } = useSession();
const { toast } = useToast();
const { _ } = useLingui();
const recipient = envelope.recipients.find((recipient) => recipient.email === user.email); const recipient = envelope.recipients.find((recipient) => recipient.email === user.email);
const isRecipient = !!recipient; const isRecipient = !!recipient;
@ -30,12 +35,30 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps
const documentsPath = formatDocumentsPath(envelope.team.url); const documentsPath = formatDocumentsPath(envelope.team.url);
const formatPath = `${documentsPath}/${envelope.id}/edit`; const formatPath = `${documentsPath}/${envelope.id}/edit`;
const onDownloadClick = async () => {
try {
// Todo; Envelopes - Support multiple items
const envelopeItem = envelope.envelopeItems[0];
if (!envelopeItem.documentData) {
throw new Error('No document available');
}
await downloadPDF({ documentData: envelopeItem.documentData, fileName: envelopeItem.title });
} catch (err) {
toast({
title: _(msg`Something went wrong`),
description: _(msg`An error occurred while downloading your document.`),
variant: 'destructive',
});
}
};
return match({ return match({
isRecipient, isRecipient,
isPending, isPending,
isComplete, isComplete,
isSigned, isSigned,
internalVersion: envelope.internalVersion,
}) })
.with({ isRecipient: true, isPending: true, isSigned: false }, () => ( .with({ isRecipient: true, isPending: true, isSigned: false }, () => (
<Button className="w-full" asChild> <Button className="w-full" asChild>
@ -70,18 +93,10 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps
</Button> </Button>
)) ))
.with({ isComplete: true }, () => ( .with({ isComplete: true }, () => (
<EnvelopeDownloadDialog <Button className="w-full" onClick={onDownloadClick}>
envelopeId={envelope.id} <Download className="-ml-1 mr-2 inline h-4 w-4" />
envelopeStatus={envelope.status} <Trans>Download</Trans>
envelopeItems={envelope.envelopeItems} </Button>
token={recipient?.token}
trigger={
<Button className="w-full">
<Download className="-ml-1 mr-2 inline h-4 w-4" />
<Trans>Download</Trans>
</Button>
}
/>
)) ))
.otherwise(() => null); .otherwise(() => null);
}; };

View File

@ -1,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { DocumentStatus } from '@prisma/client'; import { DocumentStatus } from '@prisma/client';
@ -15,11 +16,13 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { Link, useNavigate } from 'react-router'; import { Link, useNavigate } from 'react-router';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import type { TEnvelope } from '@documenso/lib/types/envelope'; import type { TEnvelope } from '@documenso/lib/types/envelope';
import { isDocumentCompleted } from '@documenso/lib/utils/document'; import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope'; import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import { import {
DropdownMenu, DropdownMenu,
@ -33,7 +36,6 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialog'; import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialog';
import { DocumentDuplicateDialog } from '~/components/dialogs/document-duplicate-dialog'; import { DocumentDuplicateDialog } from '~/components/dialogs/document-duplicate-dialog';
import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog'; import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog'; import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
import { useCurrentTeam } from '~/providers/team'; import { useCurrentTeam } from '~/providers/team';
@ -64,6 +66,64 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
const documentsPath = formatDocumentsPath(team.url); const documentsPath = formatDocumentsPath(team.url);
const onDownloadClick = async () => {
try {
const documentWithData = await trpcClient.document.get.query(
{
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
},
{
context: {
teamId: team?.id?.toString(),
},
},
);
const documentData = documentWithData?.documentData;
if (!documentData) {
return;
}
await downloadPDF({ documentData, fileName: envelope.title });
} catch (err) {
toast({
title: _(msg`Something went wrong`),
description: _(msg`An error occurred while downloading your document.`),
variant: 'destructive',
});
}
};
const onDownloadOriginalClick = async () => {
try {
const documentWithData = await trpcClient.document.get.query(
{
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
},
{
context: {
teamId: team?.id?.toString(),
},
},
);
const documentData = documentWithData?.documentData;
if (!documentData) {
return;
}
await downloadPDF({ documentData, fileName: envelope.title, version: 'original' });
} catch (err) {
toast({
title: _(msg`Something went wrong`),
description: _(msg`An error occurred while downloading your document.`),
variant: 'destructive',
});
}
};
const nonSignedRecipients = envelope.recipients.filter((item) => item.signingStatus !== 'SIGNED'); const nonSignedRecipients = envelope.recipients.filter((item) => item.signingStatus !== 'SIGNED');
return ( return (
@ -86,20 +146,17 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<EnvelopeDownloadDialog {isComplete && (
envelopeId={envelope.id} <DropdownMenuItem onClick={onDownloadClick}>
envelopeStatus={envelope.status} <Download className="mr-2 h-4 w-4" />
token={recipient?.token} <Trans>Download</Trans>
envelopeItems={envelope.envelopeItems} </DropdownMenuItem>
trigger={ )}
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div> <DropdownMenuItem onClick={onDownloadOriginalClick}>
<Download className="mr-2 h-4 w-4" /> <Download className="mr-2 h-4 w-4" />
<Trans>Download</Trans> <Trans>Download Original</Trans>
</div> </DropdownMenuItem>
</DropdownMenuItem>
}
/>
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link to={`${documentsPath}/${envelope.id}/logs`}> <Link to={`${documentsPath}/${envelope.id}/logs`}>
@ -173,8 +230,7 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
{isDuplicateDialogOpen && ( {isDuplicateDialogOpen && (
<DocumentDuplicateDialog <DocumentDuplicateDialog
id={envelope.id} id={mapSecondaryIdToDocumentId(envelope.secondaryId)}
token={recipient?.token}
open={isDuplicateDialogOpen} open={isDuplicateDialogOpen}
onOpenChange={setDuplicateDialogOpen} onOpenChange={setDuplicateDialogOpen}
/> />

View File

@ -7,7 +7,6 @@ import { DateTime } from 'luxon';
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted'; import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
import type { TEnvelope } from '@documenso/lib/types/envelope'; import type { TEnvelope } from '@documenso/lib/types/envelope';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
export type DocumentPageViewInformationProps = { export type DocumentPageViewInformationProps = {
userId: number; userId: number;
@ -41,10 +40,6 @@ export const DocumentPageViewInformation = ({
.setLocale(i18n.locales?.[0] || i18n.locale) .setLocale(i18n.locales?.[0] || i18n.locale)
.toRelative(), .toRelative(),
}, },
{
description: msg`Document ID (Legacy)`,
value: mapSecondaryIdToDocumentId(envelope.secondaryId),
},
]; ];
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMounted, envelope, userId]); }, [isMounted, envelope, userId]);

View File

@ -1,10 +1,7 @@
import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client'; import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
import { TooltipArrow } from '@radix-ui/react-tooltip';
import { import {
AlertTriangle, AlertTriangle,
CheckIcon, CheckIcon,
@ -15,7 +12,7 @@ import {
PlusIcon, PlusIcon,
UserIcon, UserIcon,
} from 'lucide-react'; } from 'lucide-react';
import { Link, useSearchParams } from 'react-router'; import { Link } from 'react-router';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
@ -27,12 +24,6 @@ import { SignatureIcon } from '@documenso/ui/icons/signature';
import { AvatarWithText } from '@documenso/ui/primitives/avatar'; import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Badge } from '@documenso/ui/primitives/badge'; import { Badge } from '@documenso/ui/primitives/badge';
import { PopoverHover } from '@documenso/ui/primitives/popover'; import { PopoverHover } from '@documenso/ui/primitives/popover';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
export type DocumentPageViewRecipientsProps = { export type DocumentPageViewRecipientsProps = {
@ -46,24 +37,8 @@ export const DocumentPageViewRecipients = ({
}: DocumentPageViewRecipientsProps) => { }: DocumentPageViewRecipientsProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const [searchParams, setSearchParams] = useSearchParams();
const recipients = envelope.recipients; const recipients = envelope.recipients;
const [shouldHighlightCopyButtons, setShouldHighlightCopyButtons] = useState(false);
// Check for action=view-tokens query parameter and set highlighting state
useEffect(() => {
const hasViewTokensAction = searchParams.get('action') === 'copy-links';
if (hasViewTokensAction) {
setShouldHighlightCopyButtons(true);
// Remove the query parameter immediately
const params = new URLSearchParams(searchParams);
params.delete('action');
setSearchParams(params);
}
}, [searchParams, setSearchParams]);
return ( return (
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border"> <section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
@ -94,7 +69,7 @@ export const DocumentPageViewRecipients = ({
</li> </li>
)} )}
{recipients.map((recipient, i) => ( {recipients.map((recipient) => (
<li key={recipient.id} className="flex items-center justify-between px-4 py-2.5 text-sm"> <li key={recipient.id} className="flex items-center justify-between px-4 py-2.5 text-sm">
<AvatarWithText <AvatarWithText
avatarFallback={recipient.email.slice(0, 1).toUpperCase()} avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
@ -184,33 +159,15 @@ export const DocumentPageViewRecipients = ({
{envelope.status === DocumentStatus.PENDING && {envelope.status === DocumentStatus.PENDING &&
recipient.signingStatus === SigningStatus.NOT_SIGNED && recipient.signingStatus === SigningStatus.NOT_SIGNED &&
recipient.role !== RecipientRole.CC && ( recipient.role !== RecipientRole.CC && (
<TooltipProvider> <CopyTextButton
<Tooltip open={shouldHighlightCopyButtons && i === 0}> value={formatSigningLink(recipient.token)}
<TooltipTrigger asChild> onCopySuccess={() => {
<div toast({
className={shouldHighlightCopyButtons ? 'animate-pulse' : ''} title: _(msg`Copied to clipboard`),
onClick={() => setShouldHighlightCopyButtons(false)} description: _(msg`The signing link has been copied to your clipboard.`),
> });
<CopyTextButton }}
value={formatSigningLink(recipient.token)} />
onCopySuccess={() => {
toast({
title: _(msg`Copied to clipboard`),
description: _(
msg`The signing link has been copied to your clipboard.`,
),
});
setShouldHighlightCopyButtons(false);
}}
/>
</div>
</TooltipTrigger>
<TooltipContent sideOffset={2}>
<Trans>Copy Signing Links</Trans>
<TooltipArrow className="fill-background" />
</TooltipContent>
</Tooltip>
</TooltipProvider>
)} )}
</div> </div>
</li> </li>

View File

@ -3,7 +3,6 @@ import { useMemo, useState } from 'react';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { useNavigate, useParams } from 'react-router'; import { useNavigate, useParams } from 'react-router';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
@ -14,11 +13,11 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones'; import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { TCreateDocumentPayloadSchema } from '@documenso/trpc/server/document-router/create-document.types';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { DocumentUploadButton as DocumentUploadButtonPrimitive } from '@documenso/ui/primitives/document-upload-button'; import { DocumentDropzone } from '@documenso/ui/primitives/document-upload';
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@ -29,11 +28,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team'; import { useCurrentTeam } from '~/providers/team';
export type DocumentUploadButtonLegacyProps = { export type DocumentUploadButtonProps = {
className?: string; className?: string;
}; };
export const DocumentUploadButtonLegacy = ({ className }: DocumentUploadButtonLegacyProps) => { export const DocumentUploadButton = ({ className }: DocumentUploadButtonProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const { user } = useSession(); const { user } = useSession();
@ -74,20 +73,14 @@ export const DocumentUploadButtonLegacy = ({ className }: DocumentUploadButtonLe
try { try {
setIsLoading(true); setIsLoading(true);
const payload = { const response = await putPdfFile(file);
const { legacyDocumentId: id } = await createDocument({
title: file.name, title: file.name,
documentDataId: response.id,
timezone: userTimezone,
folderId: folderId ?? undefined, folderId: folderId ?? undefined,
meta: { });
timezone: userTimezone,
},
} satisfies TCreateDocumentPayloadSchema;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('file', file);
const { envelopeId: id } = await createDocument(formData);
void refreshLimits(); void refreshLimits();
@ -115,10 +108,6 @@ export const DocumentUploadButtonLegacy = ({ className }: DocumentUploadButtonLe
AppErrorCode.LIMIT_EXCEEDED, AppErrorCode.LIMIT_EXCEEDED,
() => msg`You have reached your document limit for this month. Please upgrade your plan.`, () => msg`You have reached your document limit for this month. Please upgrade your plan.`,
) )
.with(
'ENVELOPE_ITEM_LIMIT_EXCEEDED',
() => msg`You have reached the limit of the number of files per envelope`,
)
.otherwise(() => msg`An error occurred while uploading your document.`); .otherwise(() => msg`An error occurred while uploading your document.`);
toast({ toast({
@ -147,14 +136,12 @@ export const DocumentUploadButtonLegacy = ({ className }: DocumentUploadButtonLe
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div> <div>
<DocumentUploadButtonPrimitive <DocumentDropzone
loading={isLoading} loading={isLoading}
disabled={remaining.documents === 0 || !user.emailVerified} disabled={remaining.documents === 0 || !user.emailVerified}
disabledMessage={disabledMessage} disabledMessage={disabledMessage}
onDrop={async (files) => onFileDrop(files[0])} onDrop={async (files) => onFileDrop(files[0])}
onDropRejected={onFileDropRejected} onDropRejected={onFileDropRejected}
type={EnvelopeType.DOCUMENT}
internalVersion="1"
/> />
</div> </div>
</TooltipTrigger> </TooltipTrigger>

View File

@ -4,7 +4,6 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client'; import { EnvelopeType } from '@prisma/client';
import { ErrorCode as DropzoneErrorCode, type FileRejection } from 'react-dropzone';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
@ -14,11 +13,11 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { TIME_ZONES } from '@documenso/lib/constants/time-zones'; import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { TCreateEnvelopePayload } from '@documenso/trpc/server/envelope-router/create-envelope.types';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { DocumentUploadButton } from '@documenso/ui/primitives/document-upload-button'; import { DocumentDropzone } from '@documenso/ui/primitives/document-upload';
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@ -52,7 +51,7 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
(timezone) => timezone === Intl.DateTimeFormat().resolvedOptions().timeZone, (timezone) => timezone === Intl.DateTimeFormat().resolvedOptions().timeZone,
); );
const { quota, remaining, refreshLimits, maximumEnvelopeItemCount } = useLimits(); const { quota, remaining, refreshLimits } = useLimits();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@ -70,7 +69,6 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
if (!user.emailVerified) { if (!user.emailVerified) {
return msg`Verify your email to upload documents.`; return msg`Verify your email to upload documents.`;
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [remaining.documents, user.emailVerified, team]); }, [remaining.documents, user.emailVerified, team]);
@ -78,24 +76,35 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
try { try {
setIsLoading(true); setIsLoading(true);
const payload = { const result = await Promise.all(
files.map(async (file) => {
try {
const response = await putPdfFile(file);
return {
title: file.name,
documentDataId: response.id,
};
} catch (err) {
console.error(err);
throw new Error('Failed to upload document');
}
}),
);
const envelopeItemsToCreate = result.filter(
(item): item is { title: string; documentDataId: string } => item !== undefined,
);
const { id } = await createEnvelope({
folderId, folderId,
type, type,
title: files[0].name, title: files[0].name,
items: envelopeItemsToCreate,
meta: { meta: {
timezone: userTimezone, timezone: userTimezone,
}, },
} satisfies TCreateEnvelopePayload; }).catch((error) => {
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
for (const file of files) {
formData.append('files', file);
}
const { id } = await createEnvelope(formData).catch((error) => {
console.error(error); console.error(error);
throw error; throw error;
@ -129,10 +138,6 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
AppErrorCode.LIMIT_EXCEEDED, AppErrorCode.LIMIT_EXCEEDED,
() => t`You have reached your document limit for this month. Please upgrade your plan.`, () => t`You have reached your document limit for this month. Please upgrade your plan.`,
) )
.with(
'ENVELOPE_ITEM_LIMIT_EXCEEDED',
() => t`You have reached the limit of the number of files per envelope`,
)
.otherwise(() => t`An error occurred while uploading your document.`); .otherwise(() => t`An error occurred while uploading your document.`);
toast({ toast({
@ -146,23 +151,12 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
} }
}; };
const onFileDropRejected = (fileRejections: FileRejection[]) => { const onFileDropRejected = () => {
const maxItemsReached = fileRejections.some((fileRejection) =>
fileRejection.errors.some((error) => error.code === DropzoneErrorCode.TooManyFiles),
);
if (maxItemsReached) {
toast({
title: t`You cannot upload more than ${maximumEnvelopeItemCount} items per envelope.`,
duration: 5000,
variant: 'destructive',
});
return;
}
toast({ toast({
title: t`Upload failed`, title:
type === EnvelopeType.DOCUMENT
? t`Your document failed to upload.`
: t`Your template failed to upload.`,
description: t`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`, description: t`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`,
duration: 5000, duration: 5000,
variant: 'destructive', variant: 'destructive',
@ -175,15 +169,13 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div> <div>
<DocumentUploadButton <DocumentDropzone
loading={isLoading} loading={isLoading}
disabled={remaining.documents === 0 || !user.emailVerified} disabled={remaining.documents === 0 || !user.emailVerified}
disabledMessage={disabledMessage} disabledMessage={disabledMessage}
onDrop={onFileDrop} onDrop={onFileDrop}
onDropRejected={onFileDropRejected} onDropRejected={onFileDropRejected}
type={type} type="envelope"
internalVersion="2"
maxFiles={maximumEnvelopeItemCount}
/> />
</div> </div>
</TooltipTrigger> </TooltipTrigger>

View File

@ -96,7 +96,7 @@ export const EnvelopeEditorFieldDragDrop = ({
selectedRecipientId, selectedRecipientId,
selectedEnvelopeItemId, selectedEnvelopeItemId,
}: EnvelopeEditorFieldDragDropProps) => { }: EnvelopeEditorFieldDragDropProps) => {
const { envelope, editorFields, isTemplate, getRecipientColorKey } = useCurrentEnvelopeEditor(); const { envelope, editorFields, isTemplate } = useCurrentEnvelopeEditor();
const { t } = useLingui(); const { t } = useLingui();
@ -262,10 +262,6 @@ export const EnvelopeEditorFieldDragDrop = ({
}; };
}, [onMouseClick, onMouseMove, selectedField]); }, [onMouseClick, onMouseMove, selectedField]);
const selectedRecipientColor = useMemo(() => {
return selectedRecipientId ? getRecipientColorKey(selectedRecipientId) : 'green';
}, [selectedRecipientId, getRecipientColorKey]);
return ( return (
<> <>
<div className="grid grid-cols-2 gap-x-2 gap-y-2.5"> <div className="grid grid-cols-2 gap-x-2 gap-y-2.5">
@ -277,23 +273,12 @@ export const EnvelopeEditorFieldDragDrop = ({
onClick={() => setSelectedField(field.type)} onClick={() => setSelectedField(field.type)}
onMouseDown={() => setSelectedField(field.type)} onMouseDown={() => setSelectedField(field.type)}
data-selected={selectedField === field.type ? true : undefined} data-selected={selectedField === field.type ? true : undefined}
className={cn( className="group flex h-12 cursor-pointer items-center justify-center rounded-lg border border-gray-200 px-4 transition-colors hover:border-blue-300 hover:bg-blue-50"
'border-border group flex h-12 cursor-pointer items-center justify-center rounded-lg border px-4 transition-colors',
RECIPIENT_COLOR_STYLES[selectedRecipientColor].fieldButton,
)}
> >
<p <p
className={cn( className={cn(
'text-muted-foreground font-noto group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal', 'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
field.className, field.className,
{
'group-hover:text-recipient-green': selectedRecipientColor === 'green',
'group-hover:text-recipient-blue': selectedRecipientColor === 'blue',
'group-hover:text-recipient-purple': selectedRecipientColor === 'purple',
'group-hover:text-recipient-orange': selectedRecipientColor === 'orange',
'group-hover:text-recipient-yellow': selectedRecipientColor === 'yellow',
'group-hover:text-recipient-pink': selectedRecipientColor === 'pink',
},
)} )}
> >
{field.type !== FieldType.SIGNATURE && <field.icon className="h-4 w-4" />} {field.type !== FieldType.SIGNATURE && <field.icon className="h-4 w-4" />}
@ -306,9 +291,9 @@ export const EnvelopeEditorFieldDragDrop = ({
{selectedField && ( {selectedField && (
<div <div
className={cn( className={cn(
'text-muted-foreground dark:text-muted-background font-noto pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white ring-2 transition duration-200 [container-type:size]', 'text-muted-foreground dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white ring-2 transition duration-200 [container-type:size]',
RECIPIENT_COLOR_STYLES[selectedRecipientColor].base, // selectedSignerStyles?.base,
selectedField === FieldType.SIGNATURE && 'font-signature', RECIPIENT_COLOR_STYLES.yellow.base, // Todo: Envelopes
{ {
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds, '-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
'dark:text-black/60': isFieldWithinBounds, 'dark:text-black/60': isFieldWithinBounds,

View File

@ -3,12 +3,15 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import type { FieldType } from '@prisma/client'; import type { FieldType } from '@prisma/client';
import Konva from 'konva'; import Konva from 'konva';
import type { Layer } from 'konva/lib/Layer';
import type { KonvaEventObject } from 'konva/lib/Node'; import type { KonvaEventObject } from 'konva/lib/Node';
import type { Transformer } from 'konva/lib/shapes/Transformer'; import type { Transformer } from 'konva/lib/shapes/Transformer';
import { CopyPlusIcon, SquareStackIcon, TrashIcon } from 'lucide-react'; import { CopyPlusIcon, SquareStackIcon, TrashIcon } from 'lucide-react';
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
import { usePageContext } from 'react-pdf';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields'; import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider'; import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta'; import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
@ -18,16 +21,32 @@ import {
convertPixelToPercentage, convertPixelToPercentage,
} from '@documenso/lib/universal/field-renderer/field-renderer'; } from '@documenso/lib/universal/field-renderer/field-renderer';
import { renderField } from '@documenso/lib/universal/field-renderer/render-field'; import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients'; import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { fieldButtonList } from './envelope-editor-fields-drag-drop'; import { fieldButtonList } from './envelope-editor-fields-drag-drop';
export default function EnvelopeEditorFieldsPageRenderer() { export default function EnvelopeEditorFieldsPageRenderer() {
const { t, i18n } = useLingui(); const pageContext = usePageContext();
const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
if (!pageContext) {
throw new Error('Unable to find Page context.');
}
const { _className, page, rotate, scale } = pageContext;
if (!page) {
throw new Error('Attempted to render page canvas, but no page was specified.');
}
const { t } = useLingui();
const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
const canvasElement = useRef<HTMLCanvasElement>(null);
const konvaContainer = useRef<HTMLDivElement>(null);
const stage = useRef<Konva.Stage | null>(null);
const pageLayer = useRef<Layer | null>(null);
const interactiveTransformer = useRef<Transformer | null>(null); const interactiveTransformer = useRef<Transformer | null>(null);
const [selectedKonvaFieldGroups, setSelectedKonvaFieldGroups] = useState<Konva.Group[]>([]); const [selectedKonvaFieldGroups, setSelectedKonvaFieldGroups] = useState<Konva.Group[]>([]);
@ -35,17 +54,10 @@ export default function EnvelopeEditorFieldsPageRenderer() {
const [isFieldChanging, setIsFieldChanging] = useState(false); const [isFieldChanging, setIsFieldChanging] = useState(false);
const [pendingFieldCreation, setPendingFieldCreation] = useState<Konva.Rect | null>(null); const [pendingFieldCreation, setPendingFieldCreation] = useState<Konva.Rect | null>(null);
const { const viewport = useMemo(
stage, () => page.getViewport({ scale, rotation: rotate }),
pageLayer, [page, rotate, scale],
canvasElement, );
konvaContainer,
pageContext,
scaledViewport,
unscaledViewport,
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer));
const { _className, scale } = pageContext;
const localPageFields = useMemo( const localPageFields = useMemo(
() => () =>
@ -56,7 +68,47 @@ export default function EnvelopeEditorFieldsPageRenderer() {
[editorFields.localFields, pageContext.pageNumber], [editorFields.localFields, pageContext.pageNumber],
); );
// Custom renderer from Konva examples.
useEffect(
function drawPageOnCanvas() {
if (!page) {
return;
}
const { current: canvas } = canvasElement;
const { current: container } = konvaContainer;
if (!canvas || !container) {
return;
}
const renderContext: RenderParameters = {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
viewport,
};
const cancellable = page.render(renderContext);
const runningTask = cancellable;
cancellable.promise.catch(() => {
// Intentionally empty
});
void cancellable.promise.then(() => {
createPageCanvas(container);
});
return () => {
runningTask.cancel();
};
},
[page, viewport],
);
const handleResizeOrMove = (event: KonvaEventObject<Event>) => { const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
console.log('Field resized or moved');
const { current: container } = canvasElement; const { current: container } = canvasElement;
if (!container) { if (!container) {
@ -68,7 +120,6 @@ export default function EnvelopeEditorFieldsPageRenderer() {
const fieldGroup = event.target as Konva.Group; const fieldGroup = event.target as Konva.Group;
const fieldFormId = fieldGroup.id(); const fieldFormId = fieldGroup.id();
// Note: This values are scaled.
const { const {
width: fieldPixelWidth, width: fieldPixelWidth,
height: fieldPixelHeight, height: fieldPixelHeight,
@ -79,8 +130,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
skipShadow: true, skipShadow: true,
}); });
const pageHeight = scaledViewport.height; const { height: pageHeight, width: pageWidth } = getBoundingClientRect(container);
const pageWidth = scaledViewport.width;
// Calculate x and y as a percentage of the page width and height // Calculate x and y as a percentage of the page width and height
const positionPercentX = (fieldX / pageWidth) * 100; const positionPercentX = (fieldX / pageWidth) * 100;
@ -103,6 +153,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
fieldUpdates.height = fieldPageHeight; fieldUpdates.height = fieldPageHeight;
} }
// Todo: envelopes Use id
editorFields.updateFieldByFormId(fieldFormId, fieldUpdates); editorFields.updateFieldByFormId(fieldFormId, fieldUpdates);
// Select the field if it is not already selected. // Select the field if it is not already selected.
@ -113,8 +164,9 @@ export default function EnvelopeEditorFieldsPageRenderer() {
pageLayer.current?.batchDraw(); pageLayer.current?.batchDraw();
}; };
const unsafeRenderFieldOnLayer = (field: TLocalField) => { const renderFieldOnLayer = (field: TLocalField) => {
if (!pageLayer.current) { if (!pageLayer.current || !interactiveTransformer.current) {
console.error('Layer not loaded yet');
return; return;
} }
@ -122,8 +174,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
const isFieldEditable = const isFieldEditable =
recipient !== undefined && canRecipientFieldsBeModified(recipient, envelope.fields); recipient !== undefined && canRecipientFieldsBeModified(recipient, envelope.fields);
const { fieldGroup } = renderField({ const { fieldGroup, isFirstRender } = renderField({
scale,
pageLayer: pageLayer.current, pageLayer: pageLayer.current,
field: { field: {
renderId: field.formId, renderId: field.formId,
@ -132,9 +183,8 @@ export default function EnvelopeEditorFieldsPageRenderer() {
inserted: false, inserted: false,
fieldMeta: field.fieldMeta, fieldMeta: field.fieldMeta,
}, },
translations: getClientSideFieldTranslations(i18n), pageWidth: viewport.width,
pageWidth: unscaledViewport.width, pageHeight: viewport.height,
pageHeight: unscaledViewport.height,
color: getRecipientColorKey(field.recipientId), color: getRecipientColorKey(field.recipientId),
editable: isFieldEditable, editable: isFieldEditable,
mode: 'edit', mode: 'edit',
@ -159,24 +209,25 @@ export default function EnvelopeEditorFieldsPageRenderer() {
fieldGroup.on('dragend', handleResizeOrMove); fieldGroup.on('dragend', handleResizeOrMove);
}; };
const renderFieldOnLayer = (field: TLocalField) => {
try {
unsafeRenderFieldOnLayer(field);
} catch (err) {
console.error(err);
setRenderError(true);
}
};
/** /**
* Initialize the Konva page canvas and all fields and interactions. * Create the initial Konva page canvas and initialize all fields and interactions.
*/ */
const createPageCanvas = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => { const createPageCanvas = (container: HTMLDivElement) => {
stage.current = new Konva.Stage({
container,
width: viewport.width,
height: viewport.height,
});
// Create the main layer for interactive elements.
pageLayer.current = new Konva.Layer();
stage.current?.add(pageLayer.current);
// Initialize snap guides layer // Initialize snap guides layer
// snapGuideLayer.current = initializeSnapGuides(stage.current); // snapGuideLayer.current = initializeSnapGuides(stage.current);
// Add transformer for resizing and rotating. // Add transformer for resizing and rotating.
interactiveTransformer.current = createInteractiveTransformer(currentStage, currentPageLayer); interactiveTransformer.current = createInteractiveTransformer(stage.current, pageLayer.current);
// Render the fields. // Render the fields.
for (const field of localPageFields) { for (const field of localPageFields) {
@ -184,12 +235,12 @@ export default function EnvelopeEditorFieldsPageRenderer() {
} }
// Handle stage click to deselect. // Handle stage click to deselect.
currentStage.on('mousedown', (e) => { stage.current?.on('click', (e) => {
removePendingField(); removePendingField();
if (e.target === stage.current) { if (e.target === stage.current) {
setSelectedFields([]); setSelectedFields([]);
currentPageLayer.batchDraw(); pageLayer.current?.batchDraw();
} }
}); });
@ -216,12 +267,12 @@ export default function EnvelopeEditorFieldsPageRenderer() {
setSelectedFields([e.target]); setSelectedFields([e.target]);
}; };
currentStage.on('dragstart', onDragStartOrEnd); stage.current?.on('dragstart', onDragStartOrEnd);
currentStage.on('dragend', onDragStartOrEnd); stage.current?.on('dragend', onDragStartOrEnd);
currentStage.on('transformstart', () => setIsFieldChanging(true)); stage.current?.on('transformstart', () => setIsFieldChanging(true));
currentStage.on('transformend', () => setIsFieldChanging(false)); stage.current?.on('transformend', () => setIsFieldChanging(false));
currentPageLayer.batchDraw(); pageLayer.current.batchDraw();
}; };
/** /**
@ -233,10 +284,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
* - Selecting multiple fields * - Selecting multiple fields
* - Selecting empty area to create fields * - Selecting empty area to create fields
*/ */
const createInteractiveTransformer = ( const createInteractiveTransformer = (stage: Konva.Stage, layer: Konva.Layer) => {
currentStage: Konva.Stage,
currentPageLayer: Konva.Layer,
) => {
const transformer = new Konva.Transformer({ const transformer = new Konva.Transformer({
rotateEnabled: false, rotateEnabled: false,
keepRatio: false, keepRatio: false,
@ -253,36 +301,36 @@ export default function EnvelopeEditorFieldsPageRenderer() {
}, },
}); });
currentPageLayer.add(transformer); layer.add(transformer);
// Add selection rectangle. // Add selection rectangle.
const selectionRectangle = new Konva.Rect({ const selectionRectangle = new Konva.Rect({
fill: 'rgba(24, 160, 251, 0.3)', fill: 'rgba(24, 160, 251, 0.3)',
visible: false, visible: false,
}); });
currentPageLayer.add(selectionRectangle); layer.add(selectionRectangle);
let x1: number; let x1: number;
let y1: number; let y1: number;
let x2: number; let x2: number;
let y2: number; let y2: number;
currentStage.on('mousedown touchstart', (e) => { stage.on('mousedown touchstart', (e) => {
// do nothing if we mousedown on any shape // do nothing if we mousedown on any shape
if (e.target !== currentStage) { if (e.target !== stage) {
return; return;
} }
const pointerPosition = currentStage.getPointerPosition(); const pointerPosition = stage.getPointerPosition();
if (!pointerPosition) { if (!pointerPosition) {
return; return;
} }
x1 = pointerPosition.x / scale; x1 = pointerPosition.x;
y1 = pointerPosition.y / scale; y1 = pointerPosition.y;
x2 = pointerPosition.x / scale; x2 = pointerPosition.x;
y2 = pointerPosition.y / scale; y2 = pointerPosition.y;
selectionRectangle.setAttrs({ selectionRectangle.setAttrs({
x: x1, x: x1,
@ -293,7 +341,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
}); });
}); });
currentStage.on('mousemove touchmove', () => { stage.on('mousemove touchmove', () => {
// do nothing if we didn't start selection // do nothing if we didn't start selection
if (!selectionRectangle.visible()) { if (!selectionRectangle.visible()) {
return; return;
@ -301,14 +349,14 @@ export default function EnvelopeEditorFieldsPageRenderer() {
selectionRectangle.moveToTop(); selectionRectangle.moveToTop();
const pointerPosition = currentStage.getPointerPosition(); const pointerPosition = stage.getPointerPosition();
if (!pointerPosition) { if (!pointerPosition) {
return; return;
} }
x2 = pointerPosition.x / scale; x2 = pointerPosition.x;
y2 = pointerPosition.y / scale; y2 = pointerPosition.y;
selectionRectangle.setAttrs({ selectionRectangle.setAttrs({
x: Math.min(x1, x2), x: Math.min(x1, x2),
@ -318,7 +366,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
}); });
}); });
currentStage.on('mouseup touchend', () => { stage.on('mouseup touchend', () => {
// do nothing if we didn't start selection // do nothing if we didn't start selection
if (!selectionRectangle.visible()) { if (!selectionRectangle.visible()) {
return; return;
@ -329,41 +377,38 @@ export default function EnvelopeEditorFieldsPageRenderer() {
selectionRectangle.visible(false); selectionRectangle.visible(false);
}); });
const stageFieldGroups = currentStage.find('.field-group') || []; const stageFieldGroups = stage.find('.field-group') || [];
const box = selectionRectangle.getClientRect(); const box = selectionRectangle.getClientRect();
const selectedFieldGroups = stageFieldGroups.filter( const selectedFieldGroups = stageFieldGroups.filter(
(shape) => Konva.Util.haveIntersection(box, shape.getClientRect()) && shape.draggable(), (shape) => Konva.Util.haveIntersection(box, shape.getClientRect()) && shape.draggable(),
); );
setSelectedFields(selectedFieldGroups); setSelectedFields(selectedFieldGroups);
const unscaledBoxWidth = box.width / scale;
const unscaledBoxHeight = box.height / scale;
// Create a field if no items are selected or the size is too small. // Create a field if no items are selected or the size is too small.
if ( if (
selectedFieldGroups.length === 0 && selectedFieldGroups.length === 0 &&
canvasElement.current && canvasElement.current &&
unscaledBoxWidth > MIN_FIELD_WIDTH_PX && box.width > MIN_FIELD_WIDTH_PX &&
unscaledBoxHeight > MIN_FIELD_HEIGHT_PX && box.height > MIN_FIELD_HEIGHT_PX &&
editorFields.selectedRecipient && editorFields.selectedRecipient &&
canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields) canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields)
) { ) {
const pendingFieldCreation = new Konva.Rect({ const pendingFieldCreation = new Konva.Rect({
name: 'pending-field-creation', name: 'pending-field-creation',
x: box.x / scale, x: box.x,
y: box.y / scale, y: box.y,
width: unscaledBoxWidth, width: box.width,
height: unscaledBoxHeight, height: box.height,
fill: 'rgba(24, 160, 251, 0.3)', fill: 'rgba(24, 160, 251, 0.3)',
}); });
currentPageLayer.add(pendingFieldCreation); layer.add(pendingFieldCreation);
setPendingFieldCreation(pendingFieldCreation); setPendingFieldCreation(pendingFieldCreation);
} }
}); });
// Clicks should select/deselect shapes // Clicks should select/deselect shapes
currentStage.on('click tap', function (e) { stage.on('click tap', function (e) {
// if we are selecting with rect, do nothing // if we are selecting with rect, do nothing
if ( if (
selectionRectangle.visible() && selectionRectangle.visible() &&
@ -374,7 +419,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
} }
// If empty area clicked, remove all selections // If empty area clicked, remove all selections
if (e.target === stage.current) { if (e.target === stage) {
setSelectedFields([]); setSelectedFields([]);
return; return;
} }
@ -423,15 +468,20 @@ export default function EnvelopeEditorFieldsPageRenderer() {
group.name() === 'field-group' && group.name() === 'field-group' &&
!localPageFields.some((field) => field.formId === group.id()) !localPageFields.some((field) => field.formId === group.id())
) { ) {
console.log('Field removed, removing from canvas');
group.destroy(); group.destroy();
} }
}); });
// If it exists, rerender. // If it exists, rerender.
localPageFields.forEach((field) => { localPageFields.forEach((field) => {
console.log('Field created/updated, rendering on canvas');
renderFieldOnLayer(field); renderFieldOnLayer(field);
}); });
// If it doesn't exist, render it.
//
// Rerender the transformer // Rerender the transformer
interactiveTransformer.current?.forceUpdate(); interactiveTransformer.current?.forceUpdate();
@ -505,13 +555,15 @@ export default function EnvelopeEditorFieldsPageRenderer() {
return; return;
} }
const { height: pageHeight, width: pageWidth } = getBoundingClientRect(canvasElement.current);
const { fieldX, fieldY, fieldWidth, fieldHeight } = convertPixelToPercentage({ const { fieldX, fieldY, fieldWidth, fieldHeight } = convertPixelToPercentage({
width: pixelWidth, width: pixelWidth,
height: pixelHeight, height: pixelHeight,
positionX: pixelX, positionX: pixelX,
positionY: pixelY, positionY: pixelY,
pageWidth: unscaledViewport.width, pageWidth,
pageHeight: unscaledViewport.height, pageHeight,
}); });
editorFields.addField({ editorFields.addField({
@ -545,10 +597,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
} }
return ( return (
<div <div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}>
className="relative w-full"
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
>
{selectedKonvaFieldGroups.length > 0 && {selectedKonvaFieldGroups.length > 0 &&
interactiveTransformer.current && interactiveTransformer.current &&
!isFieldChanging && ( !isFieldChanging && (
@ -600,30 +649,23 @@ export default function EnvelopeEditorFieldsPageRenderer() {
</div> </div>
)} )}
{/* Todo: Envelopes - This will not overflow the page when close to edges */}
{pendingFieldCreation && ( {pendingFieldCreation && (
<div <div
style={{ style={{
position: 'absolute', position: 'absolute',
top: top: pendingFieldCreation.y() + pendingFieldCreation.getClientRect().height + 5 + 'px',
pendingFieldCreation.y() * scale + left: pendingFieldCreation.x() + pendingFieldCreation.getClientRect().width / 2 + 'px',
pendingFieldCreation.getClientRect().height +
5 +
'px',
left:
pendingFieldCreation.x() * scale +
pendingFieldCreation.getClientRect().width / 2 +
'px',
transform: 'translateX(-50%)', transform: 'translateX(-50%)',
zIndex: 50, zIndex: 50,
}} }}
// Don't use darkmode for this component, it should look the same for both light/dark modes. className="text-muted-foreground grid w-fit grid-cols-5 gap-x-1 gap-y-0.5 rounded-md border bg-white p-1 shadow-sm"
className="grid w-max grid-cols-5 gap-x-1 gap-y-0.5 rounded-md border border-gray-300 bg-white p-1 text-gray-500 shadow-sm"
> >
{fieldButtonList.map((field) => ( {fieldButtonList.map((field) => (
<button <button
key={field.type} key={field.type}
onClick={() => createFieldFromPendingTemplate(pendingFieldCreation, field.type)} onClick={() => createFieldFromPendingTemplate(pendingFieldCreation, field.type)}
className="col-span-1 w-full flex-shrink-0 rounded-sm px-2 py-1 text-xs hover:bg-gray-100 hover:text-gray-600" className="hover:text-foreground col-span-1 w-full flex-shrink-0 rounded-sm px-2 py-1 text-xs hover:bg-gray-100"
> >
{t(field.name)} {t(field.name)}
</button> </button>
@ -631,15 +673,13 @@ export default function EnvelopeEditorFieldsPageRenderer() {
</div> </div>
)} )}
{/* The element Konva will inject it's canvas into. */} <div className="konva-container absolute inset-0 z-10" ref={konvaContainer}></div>
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
{/* Canvas the PDF will be rendered on. */}
<canvas <canvas
className={`${_className}__canvas z-0`} className={`${_className}__canvas z-0`}
height={viewport.height}
ref={canvasElement} ref={canvasElement}
height={scaledViewport.height} width={viewport.width}
width={scaledViewport.width}
/> />
</div> </div>
); );

View File

@ -13,6 +13,7 @@ import { match } from 'ts-pattern';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope'; import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { Badge } from '@documenso/ui/primitives/badge'; import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator'; import { Separator } from '@documenso/ui/primitives/separator';
@ -23,6 +24,7 @@ import { TemplateUseDialog } from '~/components/dialogs/template-use-dialog';
import { BrandingLogo } from '~/components/general/branding-logo'; import { BrandingLogo } from '~/components/general/branding-logo';
import { DocumentAttachmentsPopover } from '~/components/general/document/document-attachments-popover'; import { DocumentAttachmentsPopover } from '~/components/general/document/document-attachments-popover';
import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog'; import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog';
import { useCurrentTeam } from '~/providers/team';
import { TemplateDirectLinkBadge } from '../template/template-direct-link-badge'; import { TemplateDirectLinkBadge } from '../template/template-direct-link-badge';
import { EnvelopeItemTitleInput } from './envelope-editor-title-input'; import { EnvelopeItemTitleInput } from './envelope-editor-title-input';
@ -30,34 +32,30 @@ import { EnvelopeItemTitleInput } from './envelope-editor-title-input';
export default function EnvelopeEditorHeader() { export default function EnvelopeEditorHeader() {
const { t } = useLingui(); const { t } = useLingui();
const { const team = useCurrentTeam();
envelope,
isDocument, const { envelope, isDocument, isTemplate, updateEnvelope, autosaveError } =
isTemplate, useCurrentEnvelopeEditor();
updateEnvelope,
autosaveError, // Todo: Envelopes this probably won't work with embed? Maybe hide the back items when no team?
relativePath,
editorFields, const rootPath = isDocument ? formatDocumentsPath(team.url) : formatTemplatesPath(team.url);
} = useCurrentEnvelopeEditor();
return ( return (
<nav className="bg-background border-border w-full border-b px-4 py-3 md:px-6"> <nav className="w-full border-b border-gray-200 bg-white px-6 py-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<Link to="/"> <Link to="/">
<BrandingLogo className="h-6 w-auto" /> <BrandingLogo className="h-6 w-auto" />
</Link> </Link>
<Separator orientation="vertical" className="h-6" /> <Separator orientation="vertical" className="h-6" />
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<EnvelopeItemTitleInput <EnvelopeItemTitleInput
disabled={envelope.status !== DocumentStatus.DRAFT} disabled={envelope.status !== DocumentStatus.DRAFT}
value={envelope.title} value={envelope.title}
onChange={(title) => { onChange={(title) => {
updateEnvelope({ updateEnvelope({
data: { title,
title,
},
}); });
}} }}
placeholder={t`Envelope Title`} placeholder={t`Envelope Title`}
@ -134,7 +132,7 @@ export default function EnvelopeEditorHeader() {
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<DocumentAttachmentsPopover envelopeId={envelope.id} buttonSize="sm" /> <DocumentAttachmentsPopover envelopeId={envelope.id} />
<EnvelopeEditorSettingsDialog <EnvelopeEditorSettingsDialog
trigger={ trigger={
@ -147,11 +145,7 @@ export default function EnvelopeEditorHeader() {
{isDocument && ( {isDocument && (
<> <>
<EnvelopeDistributeDialog <EnvelopeDistributeDialog
envelope={{ envelope={envelope}
...envelope,
fields: editorFields.localFields,
}}
documentRootPath={relativePath.documentRootPath}
trigger={ trigger={
<Button size="sm"> <Button size="sm">
<SendIcon className="mr-2 h-4 w-4" /> <SendIcon className="mr-2 h-4 w-4" />
@ -174,11 +168,10 @@ export default function EnvelopeEditorHeader() {
{isTemplate && ( {isTemplate && (
<TemplateUseDialog <TemplateUseDialog
envelopeId={envelope.id}
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)} templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
templateSigningOrder={envelope.documentMeta?.signingOrder} templateSigningOrder={envelope.documentMeta?.signingOrder}
recipients={envelope.recipients} recipients={envelope.recipients}
documentRootPath={relativePath.documentRootPath} documentRootPath={rootPath}
trigger={ trigger={
<Button size="sm"> <Button size="sm">
<Trans>Use Template</Trans> <Trans>Use Template</Trans>

View File

@ -5,7 +5,6 @@ import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro'; import { Trans, useLingui } from '@lingui/react/macro';
import { FieldType, RecipientRole } from '@prisma/client'; import { FieldType, RecipientRole } from '@prisma/client';
import { FileTextIcon } from 'lucide-react'; import { FileTextIcon } from 'lucide-react';
import { Link } from 'react-router';
import { isDeepEqual } from 'remeda'; import { isDeepEqual } from 'remeda';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
@ -21,14 +20,12 @@ import type {
TNameFieldMeta, TNameFieldMeta,
TNumberFieldMeta, TNumberFieldMeta,
TRadioFieldMeta, TRadioFieldMeta,
TSignatureFieldMeta,
TTextFieldMeta, TTextFieldMeta,
} from '@documenso/lib/types/field-meta'; } from '@documenso/lib/types/field-meta';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients'; import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy'; import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector'; import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
import { Separator } from '@documenso/ui/primitives/separator'; import { Separator } from '@documenso/ui/primitives/separator';
@ -40,7 +37,6 @@ import { EditorFieldInitialsForm } from '~/components/forms/editor/editor-field-
import { EditorFieldNameForm } from '~/components/forms/editor/editor-field-name-form'; import { EditorFieldNameForm } from '~/components/forms/editor/editor-field-name-form';
import { EditorFieldNumberForm } from '~/components/forms/editor/editor-field-number-form'; import { EditorFieldNumberForm } from '~/components/forms/editor/editor-field-number-form';
import { EditorFieldRadioForm } from '~/components/forms/editor/editor-field-radio-form'; import { EditorFieldRadioForm } from '~/components/forms/editor/editor-field-radio-form';
import { EditorFieldSignatureForm } from '~/components/forms/editor/editor-field-signature-form';
import { EditorFieldTextForm } from '~/components/forms/editor/editor-field-text-form'; import { EditorFieldTextForm } from '~/components/forms/editor/editor-field-text-form';
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop'; import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
@ -64,8 +60,8 @@ const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
[FieldType.DROPDOWN]: msg`Dropdown Settings`, [FieldType.DROPDOWN]: msg`Dropdown Settings`,
}; };
export const EnvelopeEditorFieldsPage = () => { export const EnvelopeEditorPageFields = () => {
const { envelope, editorFields, relativePath } = useCurrentEnvelopeEditor(); const { envelope, editorFields } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender(); const { currentEnvelopeItem } = useCurrentEnvelopeRender();
@ -108,39 +104,14 @@ export const EnvelopeEditorFieldsPage = () => {
return ( return (
<div className="relative flex h-full"> <div className="relative flex h-full">
<div className="flex w-full flex-col overflow-y-auto"> <div className="flex w-full flex-col">
{/* Horizontal envelope item selector */} {/* Horizontal envelope item selector */}
<EnvelopeRendererFileSelector fields={editorFields.localFields} /> <EnvelopeRendererFileSelector fields={editorFields.localFields} />
{/* Document View */} {/* Document View */}
<div className="mt-4 flex flex-col items-center justify-center"> <div className="mt-4 flex justify-center">
{envelope.recipients.length === 0 && (
<Alert
variant="neutral"
className="border-border bg-background mb-4 flex max-w-[800px] flex-row items-center justify-between space-y-0 rounded-sm border"
>
<div className="flex flex-col gap-1">
<AlertTitle>
<Trans>Missing Recipients</Trans>
</AlertTitle>
<AlertDescription>
<Trans>You need at least one recipient to add fields</Trans>
</AlertDescription>
</div>
<Button asChild variant="outline">
<Link to={`${relativePath.editorPath}`}>
<Trans>Add Recipients</Trans>
</Link>
</Button>
</Alert>
)}
{currentEnvelopeItem !== null ? ( {currentEnvelopeItem !== null ? (
<PDFViewerKonvaLazy <PDFViewerKonvaLazy customPageRenderer={EnvelopeEditorFieldsPageRenderer} />
renderer="editor"
customPageRenderer={EnvelopeEditorFieldsPageRenderer}
/>
) : ( ) : (
<div className="flex flex-col items-center justify-center py-32"> <div className="flex flex-col items-center justify-center py-32">
<FileTextIcon className="text-muted-foreground h-10 w-10" /> <FileTextIcon className="text-muted-foreground h-10 w-10" />
@ -156,23 +127,31 @@ export const EnvelopeEditorFieldsPage = () => {
</div> </div>
{/* Right Section - Form Fields Panel */} {/* Right Section - Form Fields Panel */}
{currentEnvelopeItem && envelope.recipients.length > 0 && ( {currentEnvelopeItem && (
<div className="bg-background border-border sticky top-0 h-full w-80 flex-shrink-0 overflow-y-auto border-l py-4"> <div className="sticky top-0 h-[calc(100vh-73px)] w-80 flex-shrink-0 overflow-y-auto border-l border-gray-200 bg-white py-4">
{/* Recipient selector section. */} {/* Recipient selector section. */}
<section className="px-4"> <section className="px-4">
<h3 className="text-foreground mb-2 text-sm font-semibold"> <h3 className="mb-2 text-sm font-semibold text-gray-900">
<Trans>Selected Recipient</Trans> <Trans>Selected Recipient</Trans>
</h3> </h3>
<RecipientSelector {envelope.recipients.length === 0 ? (
selectedRecipient={editorFields.selectedRecipient} <Alert variant="warning">
onSelectedRecipientChange={(recipient) => <AlertDescription>
editorFields.setSelectedRecipient(recipient.id) <Trans>You need at least one recipient to add fields</Trans>
} </AlertDescription>
recipients={envelope.recipients} </Alert>
className="w-full" ) : (
align="end" <RecipientSelector
/> selectedRecipient={editorFields.selectedRecipient}
onSelectedRecipientChange={(recipient) =>
editorFields.setSelectedRecipient(recipient.id)
}
recipients={envelope.recipients}
className="w-full"
align="end"
/>
)}
{editorFields.selectedRecipient && {editorFields.selectedRecipient &&
!canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields) && ( !canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields) && (
@ -191,7 +170,7 @@ export const EnvelopeEditorFieldsPage = () => {
{/* Add fields section. */} {/* Add fields section. */}
<section className="px-4"> <section className="px-4">
<h3 className="text-foreground mb-2 text-sm font-semibold"> <h3 className="mb-2 text-sm font-semibold text-gray-900">
<Trans>Add Fields</Trans> <Trans>Add Fields</Trans>
</h3> </h3>
@ -203,7 +182,7 @@ export const EnvelopeEditorFieldsPage = () => {
{/* Field details section. */} {/* Field details section. */}
<AnimateGenericFadeInOut key={editorFields.selectedField?.formId}> <AnimateGenericFadeInOut key={editorFields.selectedField?.formId}>
{selectedField && ( {selectedField && selectedField.type !== FieldType.SIGNATURE && (
<section> <section>
<Separator className="my-4" /> <Separator className="my-4" />
@ -213,12 +192,6 @@ export const EnvelopeEditorFieldsPage = () => {
</h3> </h3>
{match(selectedField.type) {match(selectedField.type)
.with(FieldType.SIGNATURE, () => (
<EditorFieldSignatureForm
value={selectedField?.fieldMeta as TSignatureFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.CHECKBOX, () => ( .with(FieldType.CHECKBOX, () => (
<EditorFieldCheckboxForm <EditorFieldCheckboxForm
value={selectedField?.fieldMeta as TCheckboxFieldMeta | undefined} value={selectedField?.fieldMeta as TCheckboxFieldMeta | undefined}

View File

@ -0,0 +1,176 @@
import { useEffect, useMemo, useRef } from 'react';
import Konva from 'konva';
import type { Layer } from 'konva/lib/Layer';
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
import { usePageContext } from 'react-pdf';
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
export default function EnvelopeEditorPagePreviewRenderer() {
const pageContext = usePageContext();
if (!pageContext) {
throw new Error('Unable to find Page context.');
}
const { _className, page, rotate, scale } = pageContext;
if (!page) {
throw new Error('Attempted to render page canvas, but no page was specified.');
}
const { editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
const canvasElement = useRef<HTMLCanvasElement>(null);
const konvaContainer = useRef<HTMLDivElement>(null);
const stage = useRef<Konva.Stage | null>(null);
const pageLayer = useRef<Layer | null>(null);
const viewport = useMemo(
() => page.getViewport({ scale, rotation: rotate }),
[page, rotate, scale],
);
const localPageFields = useMemo(
() =>
editorFields.localFields.filter(
(field) =>
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
),
[editorFields.localFields, pageContext.pageNumber],
);
// Custom renderer from Konva examples.
useEffect(
function drawPageOnCanvas() {
if (!page) {
return;
}
const { current: canvas } = canvasElement;
const { current: container } = konvaContainer;
if (!canvas || !container) {
return;
}
const renderContext: RenderParameters = {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
viewport,
};
const cancellable = page.render(renderContext);
const runningTask = cancellable;
cancellable.promise.catch(() => {
// Intentionally empty
});
void cancellable.promise.then(() => {
createPageCanvas(container);
});
return () => {
runningTask.cancel();
};
},
[page, viewport],
);
const renderFieldOnLayer = (field: TLocalField) => {
if (!pageLayer.current) {
console.error('Layer not loaded yet');
return;
}
renderField({
pageLayer: pageLayer.current,
field: {
renderId: field.formId,
...field,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta,
},
pageWidth: viewport.width,
pageHeight: viewport.height,
color: getRecipientColorKey(field.recipientId),
editable: false,
mode: 'export',
});
};
/**
* Create the initial Konva page canvas and initialize all fields and interactions.
*/
const createPageCanvas = (container: HTMLDivElement) => {
stage.current = new Konva.Stage({
container,
width: viewport.width,
height: viewport.height,
});
// Create the main layer for interactive elements.
pageLayer.current = new Konva.Layer();
stage.current?.add(pageLayer.current);
// Render the fields.
for (const field of localPageFields) {
renderFieldOnLayer(field);
}
pageLayer.current.batchDraw();
};
/**
* Render fields when they are added or removed from the localFields.
*/
useEffect(() => {
if (!pageLayer.current || !stage.current) {
return;
}
// If doesn't exist in localFields, destroy it since it's been deleted.
pageLayer.current.find('Group').forEach((group) => {
if (
group.name() === 'field-group' &&
!localPageFields.some((field) => field.formId === group.id())
) {
console.log('Field removed, removing from canvas');
group.destroy();
}
});
// If it exists, rerender.
localPageFields.forEach((field) => {
console.log('Field created/updated, rendering on canvas');
renderFieldOnLayer(field);
});
pageLayer.current.batchDraw();
}, [localPageFields]);
if (!currentEnvelopeItem) {
return null;
}
return (
<div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}>
<div className="konva-container absolute inset-0 z-10" ref={konvaContainer}></div>
<canvas
className={`${_className}__canvas z-0`}
height={viewport.height}
ref={canvasElement}
width={viewport.width}
/>
</div>
);
}

View File

@ -0,0 +1,158 @@
import { lazy, useEffect, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { FileTextIcon } from 'lucide-react';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
import { Separator } from '@documenso/ui/primitives/separator';
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
const EnvelopeEditorPagePreviewRenderer = lazy(
async () => import('./envelope-editor-page-preview-renderer'),
);
export const EnvelopeEditorPagePreview = () => {
const { envelope, editorFields } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
const [selectedPreviewMode, setSelectedPreviewMode] = useState<'recipient' | 'signed'>(
'recipient',
);
/**
* Set the selected recipient to the first recipient in the envelope.
*/
useEffect(() => {
editorFields.setSelectedRecipient(envelope.recipients[0]?.id ?? null);
}, []);
return (
<div className="relative flex h-full">
<div className="flex w-full flex-col">
{/* Horizontal envelope item selector */}
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
{/* Document View */}
<div className="mt-4 flex flex-col items-center justify-center">
<Alert variant="warning" className="mb-4 max-w-[800px]">
<AlertTitle>
<Trans>Preview Mode</Trans>
</AlertTitle>
<AlertDescription>
<Trans>Preview what the signed document will look like with placeholder data</Trans>
</AlertDescription>
</Alert>
{currentEnvelopeItem !== null ? (
<PDFViewerKonvaLazy customPageRenderer={EnvelopeEditorPagePreviewRenderer} />
) : (
<div className="flex flex-col items-center justify-center py-32">
<FileTextIcon className="text-muted-foreground h-10 w-10" />
<p className="text-foreground mt-1 text-sm">
<Trans>No documents found</Trans>
</p>
<p className="text-muted-foreground mt-1 text-sm">
<Trans>Please upload a document to continue</Trans>
</p>
</div>
)}
</div>
</div>
{/* Right Section - Form Fields Panel */}
{currentEnvelopeItem && false && (
<div className="sticky top-0 h-[calc(100vh-73px)] w-80 flex-shrink-0 overflow-y-auto border-l border-gray-200 bg-white py-4">
{/* Add fields section. */}
<section className="px-4">
{/* <h3 className="mb-2 text-sm font-semibold text-gray-900">
<Trans>Preivew Mode</Trans>
</h3> */}
<Alert variant="neutral">
<AlertTitle>
<Trans>Preview Mode</Trans>
</AlertTitle>
<AlertDescription>
<Trans>Preview what the signed document will look like with placeholder data</Trans>
</AlertDescription>
</Alert>
{/* <Alert variant="neutral">
<RadioGroup
className="gap-y-1"
value={selectedPreviewMode}
onValueChange={(value) => setSelectedPreviewMode(value as 'recipient' | 'signed')}
>
<div className="flex items-center">
<RadioGroupItem
id="document-signed-preview"
className="pointer-events-none h-3 w-3"
value="signed"
/>
<Label
htmlFor="document-signed-preview"
className="text-foreground ml-1.5 text-xs font-normal"
>
<Trans>Document Signed Preview</Trans>
</Label>
</div>
<div className="flex items-center">
<RadioGroupItem
id="recipient-preview"
className="pointer-events-none h-3 w-3"
value="recipient"
/>
<Label
htmlFor="recipient-preview"
className="text-foreground ml-1.5 text-xs font-normal"
>
<Trans>Recipient Preview</Trans>
</Label>
</div>
</RadioGroup>
</Alert>
<div>Preview what a recipient will see</div>
<div>Preview the signed document</div> */}
</section>
{false && (
<AnimateGenericFadeInOut key={selectedPreviewMode}>
{selectedPreviewMode === 'recipient' && (
<>
<Separator className="my-4" />
{/* Recipient selector section. */}
<section className="px-4">
<h3 className="mb-2 text-sm font-semibold text-gray-900">
<Trans>Selected Recipient</Trans>
</h3>
<RecipientSelector
selectedRecipient={editorFields.selectedRecipient}
onSelectedRecipientChange={(recipient) =>
editorFields.setSelectedRecipient(recipient.id)
}
recipients={envelope.recipients}
className="w-full"
align="end"
/>
</section>
</>
)}
</AnimateGenericFadeInOut>
)}
</div>
)}
</div>
);
};

View File

@ -7,20 +7,16 @@ import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentStatus } from '@prisma/client'; import { DocumentStatus } from '@prisma/client';
import { FileWarningIcon, GripVerticalIcon, Loader2 } from 'lucide-react'; import { FileWarningIcon, GripVerticalIcon, Loader2 } from 'lucide-react';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { ErrorCode as DropzoneErrorCode, type FileRejection } from 'react-dropzone';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { import {
useCurrentEnvelopeEditor, useCurrentEnvelopeEditor,
useDebounceFunction, useDebounceFunction,
} from '@documenso/lib/client-only/providers/envelope-editor-provider'; } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { nanoid } from '@documenso/lib/universal/id'; import { nanoid } from '@documenso/lib/universal/id';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope'; import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { TCreateEnvelopeItemsPayload } from '@documenso/trpc/server/envelope-router/create-envelope-items.types';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
Card, Card,
@ -30,9 +26,9 @@ import {
CardTitle, CardTitle,
} from '@documenso/ui/primitives/card'; } from '@documenso/ui/primitives/card';
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone'; import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { EnvelopeItemDeleteDialog } from '~/components/dialogs/envelope-item-delete-dialog'; import { EnvelopeItemDeleteDialog } from '~/components/dialogs/envelope-item-delete-dialog';
import { useCurrentTeam } from '~/providers/team';
import { EnvelopeEditorRecipientForm } from './envelope-editor-recipient-form'; import { EnvelopeEditorRecipientForm } from './envelope-editor-recipient-form';
import { EnvelopeItemTitleInput } from './envelope-editor-title-input'; import { EnvelopeItemTitleInput } from './envelope-editor-title-input';
@ -45,13 +41,11 @@ type LocalFile = {
isError: boolean; isError: boolean;
}; };
export const EnvelopeEditorUploadPage = () => { export const EnvelopeEditorPageUpload = () => {
const organisation = useCurrentOrganisation(); const team = useCurrentTeam();
const { t } = useLingui(); const { t } = useLingui();
const { envelope, setLocalEnvelope, relativePath, editorFields } = useCurrentEnvelopeEditor();
const { maximumEnvelopeItemCount, remaining } = useLimits(); const { envelope, setLocalEnvelope } = useCurrentEnvelopeEditor();
const { toast } = useToast();
const [localFiles, setLocalFiles] = useState<LocalFile[]>( const [localFiles, setLocalFiles] = useState<LocalFile[]>(
envelope.envelopeItems envelope.envelopeItems
@ -67,8 +61,8 @@ export const EnvelopeEditorUploadPage = () => {
const { mutateAsync: createEnvelopeItems, isPending: isCreatingEnvelopeItems } = const { mutateAsync: createEnvelopeItems, isPending: isCreatingEnvelopeItems } =
trpc.envelope.item.createMany.useMutation({ trpc.envelope.item.createMany.useMutation({
onSuccess: ({ data }) => { onSuccess: (data) => {
const createdEnvelopes = data.filter( const createdEnvelopes = data.createdEnvelopeItems.filter(
(item) => !envelope.envelopeItems.find((envelopeItem) => envelopeItem.id === item.id), (item) => !envelope.envelopeItems.find((envelopeItem) => envelopeItem.id === item.id),
); );
@ -79,10 +73,10 @@ export const EnvelopeEditorUploadPage = () => {
}); });
const { mutateAsync: updateEnvelopeItems } = trpc.envelope.item.updateMany.useMutation({ const { mutateAsync: updateEnvelopeItems } = trpc.envelope.item.updateMany.useMutation({
onSuccess: ({ data }) => { onSuccess: (data) => {
setLocalEnvelope({ setLocalEnvelope({
envelopeItems: envelope.envelopeItems.map((originalItem) => { envelopeItems: envelope.envelopeItems.map((originalItem) => {
const updatedItem = data.find((item) => item.id === originalItem.id); const updatedItem = data.updatedEnvelopeItems.find((item) => item.id === originalItem.id);
if (updatedItem) { if (updatedItem) {
return { return {
@ -114,19 +108,36 @@ export const EnvelopeEditorUploadPage = () => {
setLocalFiles((prev) => [...prev, ...newUploadingFiles]); setLocalFiles((prev) => [...prev, ...newUploadingFiles]);
const payload = { const result = await Promise.all(
files.map(async (file, index) => {
try {
const response = await putPdfFile(file);
// Mark as uploaded (remove from uploading state)
return {
title: file.name,
documentDataId: response.id,
};
} catch (_error) {
setLocalFiles((prev) =>
prev.map((uploadingFile) =>
uploadingFile.id === newUploadingFiles[index].id
? { ...uploadingFile, isError: true, isUploading: false }
: uploadingFile,
),
);
}
}),
);
const envelopeItemsToCreate = result.filter(
(item): item is { title: string; documentDataId: string } => item !== undefined,
);
const { createdEnvelopeItems } = await createEnvelopeItems({
envelopeId: envelope.id, envelopeId: envelope.id,
} satisfies TCreateEnvelopeItemsPayload; items: envelopeItemsToCreate,
}).catch((error) => {
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
for (const file of files) {
formData.append('files', file);
}
const { data } = await createEnvelopeItems(formData).catch((error) => {
console.error(error); console.error(error);
// Set error state on files in batch upload. // Set error state on files in batch upload.
@ -148,7 +159,7 @@ export const EnvelopeEditorUploadPage = () => {
); );
return filteredFiles.concat( return filteredFiles.concat(
data.map((item) => ({ createdEnvelopeItems.map((item) => ({
id: item.id, id: item.id,
envelopeItemId: item.id, envelopeItemId: item.id,
title: item.title, title: item.title,
@ -165,17 +176,9 @@ export const EnvelopeEditorUploadPage = () => {
const onFileDelete = (envelopeItemId: string) => { const onFileDelete = (envelopeItemId: string) => {
setLocalFiles((prev) => prev.filter((uploadingFile) => uploadingFile.id !== envelopeItemId)); setLocalFiles((prev) => prev.filter((uploadingFile) => uploadingFile.id !== envelopeItemId));
const fieldsWithoutDeletedItem = envelope.fields.filter(
(field) => field.envelopeItemId !== envelopeItemId,
);
setLocalEnvelope({ setLocalEnvelope({
envelopeItems: envelope.envelopeItems.filter((item) => item.id !== envelopeItemId), envelopeItems: envelope.envelopeItems.filter((item) => item.id !== envelopeItemId),
fields: envelope.fields.filter((field) => field.envelopeItemId !== envelopeItemId),
}); });
// Reset editor fields.
editorFields.resetForm(fieldsWithoutDeletedItem);
}; };
/** /**
@ -194,6 +197,7 @@ export const EnvelopeEditorUploadPage = () => {
debouncedUpdateEnvelopeItems(items); debouncedUpdateEnvelopeItems(items);
}; };
// Todo: Envelopes - Sync into envelopes data
const debouncedUpdateEnvelopeItems = useDebounceFunction((files: LocalFile[]) => { const debouncedUpdateEnvelopeItems = useDebounceFunction((files: LocalFile[]) => {
void updateEnvelopeItems({ void updateEnvelopeItems({
envelopeId: envelope.id, envelopeId: envelope.id,
@ -216,56 +220,12 @@ export const EnvelopeEditorUploadPage = () => {
debouncedUpdateEnvelopeItems(newLocalFilesValue); debouncedUpdateEnvelopeItems(newLocalFilesValue);
}; };
const dropzoneDisabledMessage = useMemo(() => {
if (!canItemsBeModified) {
return msg`Cannot upload items after the document has been sent`;
}
if (organisation.subscription && remaining.documents === 0) {
return msg`Document upload disabled due to unpaid invoices`;
}
if (maximumEnvelopeItemCount <= localFiles.length) {
return msg`You cannot upload more than ${maximumEnvelopeItemCount} items per envelope.`;
}
return null;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [localFiles.length, maximumEnvelopeItemCount, remaining.documents]);
const onFileDropRejected = (fileRejections: FileRejection[]) => {
const maxItemsReached = fileRejections.some((fileRejection) =>
fileRejection.errors.some((error) => error.code === DropzoneErrorCode.TooManyFiles),
);
if (maxItemsReached) {
toast({
title: t`You cannot upload more than ${maximumEnvelopeItemCount} items per envelope.`,
duration: 5000,
variant: 'destructive',
});
return;
}
toast({
title: t`Upload failed`,
description: t`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`,
duration: 5000,
variant: 'destructive',
});
};
return ( return (
<div className="mx-auto max-w-4xl space-y-6 p-8"> <div className="mx-auto max-w-4xl space-y-6 p-8">
<Card backdropBlur={false} className="border"> <Card backdropBlur={false} className="border">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle> <CardTitle>Documents</CardTitle>
<Trans>Documents</Trans> <CardDescription>Add and configure multiple documents</CardDescription>
</CardTitle>
<CardDescription>
<Trans>Add and configure multiple documents</Trans>
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -273,11 +233,9 @@ export const EnvelopeEditorUploadPage = () => {
onDrop={onFileDrop} onDrop={onFileDrop}
allowMultiple allowMultiple
className="pb-4 pt-6" className="pb-4 pt-6"
disabled={dropzoneDisabledMessage !== null} disabled={!canItemsBeModified}
disabledMessage={dropzoneDisabledMessage || undefined} disabledMessage={msg`Cannot upload items after the document has been sent`}
disabledHeading={msg`Upload disabled`} disabledHeading={msg`Upload disabled`}
maxFiles={maximumEnvelopeItemCount - localFiles.length}
onDropRejected={onFileDropRejected}
/> />
{/* Uploaded Files List */} {/* Uploaded Files List */}
@ -298,7 +256,7 @@ export const EnvelopeEditorUploadPage = () => {
ref={provided.innerRef} ref={provided.innerRef}
{...provided.draggableProps} {...provided.draggableProps}
style={provided.draggableProps.style} style={provided.draggableProps.style}
className={`bg-accent/50 flex items-center justify-between rounded-lg p-3 transition-shadow ${ className={`flex items-center justify-between rounded-lg bg-gray-50 p-3 transition-shadow ${
snapshot.isDragging ? 'shadow-md' : '' snapshot.isDragging ? 'shadow-md' : ''
}`} }`}
> >
@ -324,7 +282,7 @@ export const EnvelopeEditorUploadPage = () => {
<p className="text-sm font-medium">{localFile.title}</p> <p className="text-sm font-medium">{localFile.title}</p>
)} )}
<div className="text-muted-foreground text-xs"> <div className="text-xs text-gray-500">
{localFile.isUploading ? ( {localFile.isUploading ? (
<Trans>Uploading</Trans> <Trans>Uploading</Trans>
) : localFile.isError ? ( ) : localFile.isError ? (
@ -337,7 +295,7 @@ export const EnvelopeEditorUploadPage = () => {
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
{localFile.isUploading && ( {localFile.isUploading && (
<div className="flex h-6 w-10 items-center justify-center"> <div className="flex h-6 w-10 items-center justify-center">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin text-gray-500" />
</div> </div>
)} )}
@ -380,7 +338,7 @@ export const EnvelopeEditorUploadPage = () => {
<div className="flex justify-end"> <div className="flex justify-end">
<Button asChild> <Button asChild>
<Link to={`${relativePath.editorPath}?step=addFields`}> <Link to={`/t/${team.url}/documents/${envelope.id}/edit?step=addFields`}>
<Trans>Add Fields</Trans> <Trans>Add Fields</Trans>
</Link> </Link>
</Button> </Button>

View File

@ -1,339 +0,0 @@
import { lazy, useEffect, useMemo, useState } from 'react';
import { faker } from '@faker-js/faker/locale/en';
import { Trans } from '@lingui/react/macro';
import { FieldType, SigningStatus } from '@prisma/client';
import { FileTextIcon } from 'lucide-react';
import { match } from 'ts-pattern';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import {
EnvelopeRenderProvider,
useCurrentEnvelopeRender,
} from '@documenso/lib/client-only/providers/envelope-render-provider';
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing';
import { toCheckboxCustomText } from '@documenso/lib/utils/fields';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
import { Separator } from '@documenso/ui/primitives/separator';
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
const EnvelopeGenericPageRenderer = lazy(async () => import('./envelope-generic-page-renderer'));
// Todo: Envelopes - Dynamically import faker
export const EnvelopeEditorPreviewPage = () => {
const { envelope, editorFields } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem, fields } = useCurrentEnvelopeRender();
const [selectedPreviewMode, setSelectedPreviewMode] = useState<'recipient' | 'signed'>(
'recipient',
);
const fieldsWithPlaceholders = useMemo(() => {
return fields.map((field) => {
const fieldMeta = ZFieldAndMetaSchema.parse(field);
const recipient = envelope.recipients.find((recipient) => recipient.id === field.recipientId);
if (!recipient) {
throw new Error('Recipient not found');
}
faker.seed(recipient.id);
const recipientName = recipient.name || faker.person.fullName();
const recipientEmail = recipient.email || faker.internet.email();
faker.seed(recipient.id + field.id);
return {
...field,
inserted: true,
...match(fieldMeta)
.with({ type: FieldType.TEXT }, ({ fieldMeta }) => {
let text = fieldMeta?.text || faker.lorem.words(5);
if (fieldMeta?.characterLimit) {
text = text.slice(0, fieldMeta?.characterLimit);
}
return {
customText: text,
};
})
.with({ type: FieldType.NUMBER }, ({ fieldMeta }) => {
let number = fieldMeta?.value ?? '';
if (number === '') {
number = faker.number
.int({
min: fieldMeta?.minValue ?? 0,
max: fieldMeta?.maxValue ?? 1000,
})
.toString();
}
return {
customText: number,
};
})
.with({ type: FieldType.DATE }, () => {
const date = extractFieldInsertionValues({
fieldValue: {
type: FieldType.DATE,
value: true,
},
field,
documentMeta: envelope.documentMeta,
});
return {
customText: date.customText,
};
})
.with({ type: FieldType.EMAIL }, () => {
return {
customText: recipientEmail,
};
})
.with({ type: FieldType.NAME }, () => {
return {
customText: recipientName,
};
})
.with({ type: FieldType.INITIALS }, () => {
return {
customText: extractInitials(recipientName),
};
})
.with({ type: FieldType.RADIO }, ({ fieldMeta }) => {
const values = fieldMeta?.values ?? [];
if (values.length === 0) {
return '';
}
let customText = '';
const preselectedValue = values.findIndex((value) => value.checked);
if (preselectedValue !== -1) {
customText = preselectedValue.toString();
} else {
const randomIndex = faker.number.int({ min: 0, max: values.length - 1 });
customText = randomIndex.toString();
}
return {
customText,
};
})
.with({ type: FieldType.CHECKBOX }, ({ fieldMeta }) => {
let checkedValues: number[] = [];
const values = fieldMeta?.values ?? [];
values.forEach((value, index) => {
if (value.checked) {
checkedValues.push(index);
}
});
if (checkedValues.length === 0 && values.length > 0) {
const numberOfValues = fieldMeta?.validationLength || 1;
checkedValues = Array.from({ length: numberOfValues }, (_, index) => index);
}
return {
customText: toCheckboxCustomText(checkedValues),
};
})
.with({ type: FieldType.DROPDOWN }, ({ fieldMeta }) => {
const values = fieldMeta?.values ?? [];
let customText = fieldMeta?.defaultValue || '';
if (!customText && values.length > 0) {
const randomIndex = faker.number.int({ min: 0, max: values.length - 1 });
customText = values[randomIndex].value;
}
return {
customText,
};
})
.with({ type: FieldType.SIGNATURE }, () => {
return {
customText: '',
signature: {
signatureImageAsBase64: '',
typedSignature: recipientName,
},
};
})
.with({ type: FieldType.FREE_SIGNATURE }, () => {
return {
customText: '',
};
})
.exhaustive(),
};
});
}, [fields, envelope, envelope.recipients, envelope.documentMeta]);
/**
* Set the selected recipient to the first recipient in the envelope.
*/
useEffect(() => {
editorFields.setSelectedRecipient(envelope.recipients[0]?.id ?? null);
}, []);
// Override the parent renderer provider so we can inject custom fields.
return (
<EnvelopeRenderProvider
envelope={envelope}
token={undefined}
fields={fieldsWithPlaceholders}
recipients={envelope.recipients.map((recipient) => ({
...recipient,
signingStatus: SigningStatus.SIGNED,
}))}
overrideSettings={{
mode: 'export',
}}
>
<div className="relative flex h-full">
<div className="flex w-full flex-col overflow-y-auto">
{/* Horizontal envelope item selector */}
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
{/* Document View */}
<div className="mt-4 flex flex-col items-center justify-center">
<Alert variant="warning" className="mb-4 max-w-[800px]">
<AlertTitle>
<Trans>Preview Mode</Trans>
</AlertTitle>
<AlertDescription>
<Trans>Preview what the signed document will look like with placeholder data</Trans>
</AlertDescription>
</Alert>
{currentEnvelopeItem !== null ? (
<PDFViewerKonvaLazy
renderer="editor"
customPageRenderer={EnvelopeGenericPageRenderer}
/>
) : (
<div className="flex flex-col items-center justify-center py-32">
<FileTextIcon className="text-muted-foreground h-10 w-10" />
<p className="text-foreground mt-1 text-sm">
<Trans>No documents found</Trans>
</p>
<p className="text-muted-foreground mt-1 text-sm">
<Trans>Please upload a document to continue</Trans>
</p>
</div>
)}
</div>
</div>
{/* Right Section - Form Fields Panel */}
{currentEnvelopeItem && false && (
<div className="sticky top-0 h-full w-80 flex-shrink-0 overflow-y-auto border-l border-gray-200 bg-white py-4">
{/* Add fields section. */}
<section className="px-4">
{/* <h3 className="mb-2 text-sm font-semibold text-gray-900">
<Trans>Preivew Mode</Trans>
</h3> */}
<Alert variant="neutral">
<AlertTitle>
<Trans>Preview Mode</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Preview what the signed document will look like with placeholder data
</Trans>
</AlertDescription>
</Alert>
{/* <Alert variant="neutral">
<RadioGroup
className="gap-y-1"
value={selectedPreviewMode}
onValueChange={(value) => setSelectedPreviewMode(value as 'recipient' | 'signed')}
>
<div className="flex items-center">
<RadioGroupItem
id="document-signed-preview"
className="pointer-events-none h-3 w-3"
value="signed"
/>
<Label
htmlFor="document-signed-preview"
className="text-foreground ml-1.5 text-xs font-normal"
>
<Trans>Document Signed Preview</Trans>
</Label>
</div>
<div className="flex items-center">
<RadioGroupItem
id="recipient-preview"
className="pointer-events-none h-3 w-3"
value="recipient"
/>
<Label
htmlFor="recipient-preview"
className="text-foreground ml-1.5 text-xs font-normal"
>
<Trans>Recipient Preview</Trans>
</Label>
</div>
</RadioGroup>
</Alert>
<div>Preview what a recipient will see</div>
<div>Preview the signed document</div> */}
</section>
{false && (
<AnimateGenericFadeInOut key={selectedPreviewMode}>
{selectedPreviewMode === 'recipient' && (
<>
<Separator className="my-4" />
{/* Recipient selector section. */}
<section className="px-4">
<h3 className="mb-2 text-sm font-semibold text-gray-900">
<Trans>Selected Recipient</Trans>
</h3>
<RecipientSelector
selectedRecipient={editorFields.selectedRecipient}
onSelectedRecipientChange={(recipient) =>
editorFields.setSelectedRecipient(recipient.id)
}
recipients={envelope.recipients}
className="w-full"
align="end"
/>
</section>
</>
)}
</AnimateGenericFadeInOut>
)}
</div>
)}
</div>
</EnvelopeRenderProvider>
);
};

View File

@ -14,7 +14,7 @@ import { DocumentSigningOrder, EnvelopeType, RecipientRole, SendStatus } from '@
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { GripVerticalIcon, HelpCircleIcon, PlusIcon, TrashIcon } from 'lucide-react'; import { GripVerticalIcon, HelpCircleIcon, PlusIcon, TrashIcon } from 'lucide-react';
import { useFieldArray, useForm, useWatch } from 'react-hook-form'; import { useFieldArray, useForm, useWatch } from 'react-hook-form';
import { isDeepEqual, prop, sortBy } from 'remeda'; import { prop, sortBy } from 'remeda';
import { z } from 'zod'; import { z } from 'zod';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
@ -75,6 +75,7 @@ const ZEnvelopeRecipientsForm = z.object({
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]), actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
}), }),
), ),
// Todo: Envelopes - These aren't synced to the server
signingOrder: z.nativeEnum(DocumentSigningOrder), signingOrder: z.nativeEnum(DocumentSigningOrder),
allowDictateNextSigner: z.boolean().default(false), allowDictateNextSigner: z.boolean().default(false),
}); });
@ -82,7 +83,7 @@ const ZEnvelopeRecipientsForm = z.object({
type TEnvelopeRecipientsForm = z.infer<typeof ZEnvelopeRecipientsForm>; type TEnvelopeRecipientsForm = z.infer<typeof ZEnvelopeRecipientsForm>;
export const EnvelopeEditorRecipientForm = () => { export const EnvelopeEditorRecipientForm = () => {
const { envelope, setRecipientsDebounced, updateEnvelope } = useCurrentEnvelopeEditor(); const { envelope, setRecipientsDebounced } = useCurrentEnvelopeEditor();
const organisation = useCurrentOrganisation(); const organisation = useCurrentOrganisation();
@ -148,7 +149,8 @@ export const EnvelopeEditorRecipientForm = () => {
}, },
}); });
const recipientHasAuthSettings = useMemo(() => { // Always show advanced settings if any recipient has auth options.
const alwaysShowAdvancedSettings = useMemo(() => {
const recipientHasAuthOptions = recipients.find((recipient) => { const recipientHasAuthOptions = recipients.find((recipient) => {
const recipientAuthOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions); const recipientAuthOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
@ -164,7 +166,7 @@ export const EnvelopeEditorRecipientForm = () => {
return recipientHasAuthOptions !== undefined || formHasActionAuth !== undefined; return recipientHasAuthOptions !== undefined || formHasActionAuth !== undefined;
}, [recipients, form]); }, [recipients, form]);
const [showAdvancedSettings, setShowAdvancedSettings] = useState(recipientHasAuthSettings); const [showAdvancedSettings, setShowAdvancedSettings] = useState(alwaysShowAdvancedSettings);
const [showSigningOrderConfirmation, setShowSigningOrderConfirmation] = useState(false); const [showSigningOrderConfirmation, setShowSigningOrderConfirmation] = useState(false);
const { const {
@ -212,7 +214,7 @@ export const EnvelopeEditorRecipientForm = () => {
); );
const hasDocumentBeenSent = recipients.some( const hasDocumentBeenSent = recipients.some(
(recipient) => recipient.role !== RecipientRole.CC && recipient.sendStatus === SendStatus.SENT, (recipient) => recipient.sendStatus === SendStatus.SENT,
); );
const canRecipientBeModified = (recipientId?: number) => { const canRecipientBeModified = (recipientId?: number) => {
@ -449,8 +451,6 @@ export const EnvelopeEditorRecipientForm = () => {
shouldValidate: true, shouldValidate: true,
shouldDirty: true, shouldDirty: true,
}); });
void form.trigger();
}, [form]); }, [form]);
// Dupecode/Inefficient: Done because native isValid won't work for our usecase. // Dupecode/Inefficient: Done because native isValid won't work for our usecase.
@ -460,77 +460,15 @@ export const EnvelopeEditorRecipientForm = () => {
return; return;
} }
const formValueSigners = formValues.signers || []; const validatedFormValues = ZEnvelopeRecipientsForm.safeParse(formValues);
// Remove the last signer if it's empty. if (validatedFormValues.success) {
const nonEmptyRecipients = formValueSigners.filter((signer, i) => { console.log('validatedFormValues', validatedFormValues);
if (i === formValueSigners.length - 1 && signer.email === '') {
return false;
}
return true; setRecipientsDebounced(validatedFormValues.data.signers);
});
const validatedFormValues = ZEnvelopeRecipientsForm.safeParse({ // Todo: Envelopes - Need to save the other data as well
...formValues, // setEnvelope
signers: nonEmptyRecipients,
});
if (!validatedFormValues.success) {
return;
}
const { data } = validatedFormValues;
// Weird edge case where the whole envelope is created via API
// with no signing order. If they come to this page it will show an error
// since they aren't equal and the recipient is no longer editable.
const envelopeRecipients = data.signers.map((recipient) => {
if (!canRecipientBeModified(recipient.id)) {
return {
...recipient,
signingOrder: recipient.signingOrder,
};
}
return recipient;
});
const hasSigningOrderChanged = envelope.documentMeta.signingOrder !== data.signingOrder;
const hasAllowDictateNextSignerChanged =
envelope.documentMeta.allowDictateNextSigner !== data.allowDictateNextSigner;
const hasSignersChanged =
envelopeRecipients.length !== recipients.length ||
envelopeRecipients.some((signer) => {
const recipient = recipients.find((recipient) => recipient.id === signer.id);
if (!recipient) {
return true;
}
const signerActionAuth = signer.actionAuth;
const recipientActionAuth = recipient.authOptions?.actionAuth || [];
return (
signer.email !== recipient.email ||
signer.name !== recipient.name ||
signer.role !== recipient.role ||
signer.signingOrder !== recipient.signingOrder ||
!isDeepEqual(signerActionAuth, recipientActionAuth)
);
});
if (hasSignersChanged) {
setRecipientsDebounced(envelopeRecipients);
}
if (hasSigningOrderChanged || hasAllowDictateNextSignerChanged) {
updateEnvelope({
meta: {
signingOrder: validatedFormValues.data.signingOrder,
allowDictateNextSigner: validatedFormValues.data.allowDictateNextSigner,
},
});
} }
}, [formValues]); }, [formValues]);
@ -570,17 +508,18 @@ export const EnvelopeEditorRecipientForm = () => {
<CardContent> <CardContent>
<AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}> <AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}>
<Form {...form}> <Form {...form}>
<div className="bg-accent/50 -mt-2 mb-2 space-y-4 rounded-md p-4"> <div className="-mt-2 mb-2 space-y-4 rounded-md bg-gray-50/80 p-4">
{organisation.organisationClaim.flags.cfr21 && ( {!alwaysShowAdvancedSettings && organisation.organisationClaim.flags.cfr21 && (
<div className="flex flex-row items-center"> <div className="flex flex-row items-center">
<Checkbox <Checkbox
id="showAdvancedRecipientSettings" id="showAdvancedRecipientSettings"
className="h-5 w-5"
checked={showAdvancedSettings} checked={showAdvancedSettings}
onCheckedChange={(value) => setShowAdvancedSettings(Boolean(value))} onCheckedChange={(value) => setShowAdvancedSettings(Boolean(value))}
/> />
<label <label
className="ml-2 text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" className="text-muted-foreground ml-2 text-sm"
htmlFor="showAdvancedRecipientSettings" htmlFor="showAdvancedRecipientSettings"
> >
<Trans>Show advanced settings</Trans> <Trans>Show advanced settings</Trans>
@ -739,202 +678,152 @@ export const EnvelopeEditorRecipientForm = () => {
<motion.fieldset <motion.fieldset
data-native-id={signer.id} data-native-id={signer.id}
disabled={isSubmitting || !canRecipientBeModified(signer.id)} disabled={isSubmitting || !canRecipientBeModified(signer.id)}
className={cn('pb-2', { className={cn('grid grid-cols-10 items-end gap-2 pb-2', {
'border-b pb-4': 'border-b pt-2': showAdvancedSettings,
showAdvancedSettings && index !== signers.length - 1, 'grid-cols-12 pr-3': isSigningOrderSequential,
'pt-2': showAdvancedSettings && index === 0,
'pr-3': isSigningOrderSequential,
})} })}
> >
<div className="flex flex-row items-center gap-x-2"> {isSigningOrderSequential && (
{isSigningOrderSequential && ( <FormField
<FormField control={form.control}
control={form.control} name={`signers.${index}.signingOrder`}
name={`signers.${index}.signingOrder`} render={({ field }) => (
render={({ field }) => ( <FormItem
<FormItem className={cn(
className={cn( 'col-span-1 mt-auto flex items-center gap-x-1 space-y-0',
'mt-auto flex items-center gap-x-1 space-y-0', {
{ 'mb-6':
'mb-6': form.formState.errors.signers?.[index] &&
form.formState.errors.signers?.[index] && !form.formState.errors.signers[index]?.signingOrder,
!form.formState.errors.signers[index]?.signingOrder, },
}, )}
)} >
> <GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" />
<GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" /> <FormControl>
<FormControl> <Input
<Input type="number"
type="number" max={signers.length}
max={signers.length} data-testid="signing-order-input"
data-testid="signing-order-input" className={cn(
className={cn( 'w-full text-center',
'w-10 text-center', '[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none', )}
)} {...field}
{...field} onChange={(e) => {
onChange={(e) => { field.onChange(e);
field.onChange(e); handleSigningOrderChange(index, e.target.value);
handleSigningOrderChange(index, e.target.value); }}
}} onBlur={(e) => {
onBlur={(e) => { field.onBlur();
field.onBlur(); handleSigningOrderChange(index, e.target.value);
handleSigningOrderChange(index, e.target.value); }}
}} disabled={
disabled={ snapshot.isDragging ||
snapshot.isDragging || isSubmitting ||
isSubmitting || !canRecipientBeModified(signer.id)
!canRecipientBeModified(signer.id) }
} />
/> </FormControl>
</FormControl> <FormMessage />
<FormMessage /> </FormItem>
</FormItem> )}
/>
)}
<FormField
control={form.control}
name={`signers.${index}.email`}
render={({ field }) => (
<FormItem
className={cn('relative', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.email,
'col-span-4': !showAdvancedSettings,
'col-span-5': showAdvancedSettings,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel required>
<Trans>Email</Trans>
</FormLabel>
)} )}
/>
<FormControl>
<RecipientAutoCompleteInput
type="email"
placeholder={t`Email`}
value={field.value}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id)
}
options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
field.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
data-testid="signer-email-input"
maxLength={254}
/>
</FormControl>
<FormMessage />
</FormItem>
)} )}
/>
<FormField <FormField
control={form.control} control={form.control}
name={`signers.${index}.email`} name={`signers.${index}.name`}
render={({ field }) => ( render={({ field }) => (
<FormItem <FormItem
className={cn('relative w-full', { className={cn({
'mb-6': 'mb-6':
form.formState.errors.signers?.[index] && form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.email, !form.formState.errors.signers[index]?.name,
})} 'col-span-4': !showAdvancedSettings,
> 'col-span-5': showAdvancedSettings,
{!showAdvancedSettings && index === 0 && ( })}
<FormLabel required> >
<Trans>Email</Trans> {!showAdvancedSettings && index === 0 && (
</FormLabel> <FormLabel>
)} <Trans>Name</Trans>
</FormLabel>
)}
<FormControl> <FormControl>
<RecipientAutoCompleteInput <RecipientAutoCompleteInput
type="email" type="text"
placeholder={t`Email`} placeholder={t`Name`}
value={field.value} {...field}
disabled={ disabled={
snapshot.isDragging || snapshot.isDragging ||
isSubmitting || isSubmitting ||
!canRecipientBeModified(signer.id) !canRecipientBeModified(signer.id)
} }
options={recipientSuggestions} options={recipientSuggestions}
onSelect={(suggestion) => onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion) handleRecipientAutoCompleteSelect(index, suggestion)
} }
onSearchQueryChange={(query) => { onSearchQueryChange={(query) => {
field.onChange(query); field.onChange(query);
setRecipientSearchQuery(query); setRecipientSearchQuery(query);
}} }}
loading={isLoading} loading={isLoading}
data-testid="signer-email-input" maxLength={255}
maxLength={254} />
/> </FormControl>
</FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name={`signers.${index}.name`}
render={({ field }) => (
<FormItem
className={cn('w-full', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.name,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
)}
<FormControl>
<RecipientAutoCompleteInput
type="text"
placeholder={t`Name`}
{...field}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id)
}
options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
field.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
maxLength={255}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${index}.role`}
render={({ field }) => (
<FormItem
className={cn('mt-auto w-fit', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.role,
})}
>
<FormControl>
<RecipientRoleSelect
{...field}
isAssistantEnabled={isSigningOrderSequential}
onValueChange={(value) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
handleRoleChange(index, value as RecipientRole);
field.onChange(value);
}}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
variant="ghost"
className={cn('mt-auto px-2', {
'mb-6': form.formState.errors.signers?.[index],
})}
data-testid="remove-signer-button"
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id) ||
signers.length === 1
}
onClick={() => onRemoveSigner(index)}
>
<TrashIcon className="h-4 w-4" />
</Button>
</div>
{showAdvancedSettings && {showAdvancedSettings &&
organisation.organisationClaim.flags.cfr21 && ( organisation.organisationClaim.flags.cfr21 && (
@ -943,11 +832,11 @@ export const EnvelopeEditorRecipientForm = () => {
name={`signers.${index}.actionAuth`} name={`signers.${index}.actionAuth`}
render={({ field }) => ( render={({ field }) => (
<FormItem <FormItem
className={cn('mt-2 w-full', { className={cn('col-span-8', {
'mb-6': 'mb-6':
form.formState.errors.signers?.[index] && form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.actionAuth, !form.formState.errors.signers[index]?.actionAuth,
'pl-6': isSigningOrderSequential, 'col-span-10': isSigningOrderSequential,
})} })}
> >
<FormControl> <FormControl>
@ -967,6 +856,60 @@ export const EnvelopeEditorRecipientForm = () => {
)} )}
/> />
)} )}
<div className="col-span-2 flex gap-x-2">
<FormField
control={form.control}
name={`signers.${index}.role`}
render={({ field }) => (
<FormItem
className={cn('mt-auto', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.role,
})}
>
<FormControl>
<RecipientRoleSelect
{...field}
isAssistantEnabled={isSigningOrderSequential}
onValueChange={(value) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
handleRoleChange(index, value as RecipientRole);
}}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<button
type="button"
className={cn(
'mt-auto inline-flex h-10 w-10 items-center justify-center hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50',
{
'mb-6': form.formState.errors.signers?.[index],
},
)}
data-testid="remove-signer-button"
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id) ||
signers.length === 1
}
onClick={() => onRemoveSigner(index)}
>
<TrashIcon className="h-4 w-4" />
</button>
</div>
</motion.fieldset> </motion.fieldset>
</div> </div>
)} )}

View File

@ -174,7 +174,7 @@ export const EnvelopeEditorSettingsDialog = ({
const { t, i18n } = useLingui(); const { t, i18n } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const { envelope, updateEnvelopeAsync } = useCurrentEnvelopeEditor(); const { envelope } = useCurrentEnvelopeEditor();
const team = useCurrentTeam(); const team = useCurrentTeam();
const organisation = useCurrentOrganisation(); const organisation = useCurrentOrganisation();
@ -186,12 +186,14 @@ export const EnvelopeEditorSettingsDialog = ({
documentAuth: envelope.authOptions, documentAuth: envelope.authOptions,
}); });
const createDefaultValues = () => { const form = useForm<TAddSettingsFormSchema>({
return { resolver: zodResolver(ZAddSettingsFormSchema),
externalId: envelope.externalId || '', defaultValues: {
externalId: envelope.externalId || '', // Todo: String or undefined?
visibility: envelope.visibility || '', visibility: envelope.visibility || '',
globalAccessAuth: documentAuthOption?.globalAccessAuth || [], globalAccessAuth: documentAuthOption?.globalAccessAuth || [],
globalActionAuth: documentAuthOption?.globalActionAuth || [], globalActionAuth: documentAuthOption?.globalActionAuth || [],
meta: { meta: {
subject: envelope.documentMeta.subject ?? '', subject: envelope.documentMeta.subject ?? '',
message: envelope.documentMeta.message ?? '', message: envelope.documentMeta.message ?? '',
@ -208,14 +210,12 @@ export const EnvelopeEditorSettingsDialog = ({
emailSettings: ZDocumentEmailSettingsSchema.parse(envelope.documentMeta.emailSettings), emailSettings: ZDocumentEmailSettingsSchema.parse(envelope.documentMeta.emailSettings),
signatureTypes: extractTeamSignatureSettings(envelope.documentMeta), signatureTypes: extractTeamSignatureSettings(envelope.documentMeta),
}, },
}; },
};
const form = useForm<TAddSettingsFormSchema>({
resolver: zodResolver(ZAddSettingsFormSchema),
defaultValues: createDefaultValues(),
}); });
const { mutateAsync: updateEnvelope } = trpc.envelope.update.useMutation();
// Todo: Envelopes - Extract into provider.
const envelopeHasBeenSent = const envelopeHasBeenSent =
envelope.type === EnvelopeType.DOCUMENT && envelope.type === EnvelopeType.DOCUMENT &&
envelope.recipients.some((recipient) => recipient.sendStatus === SendStatus.SENT); envelope.recipients.some((recipient) => recipient.sendStatus === SendStatus.SENT);
@ -230,6 +230,7 @@ export const EnvelopeEditorSettingsDialog = ({
const emails = emailData?.data || []; const emails = emailData?.data || [];
// Todo: Envelopes this doesn't make sense (look at previous)
const canUpdateVisibility = canAccessTeamDocument(team.currentTeamRole, envelope.visibility); const canUpdateVisibility = canAccessTeamDocument(team.currentTeamRole, envelope.visibility);
const onFormSubmit = async (data: TAddSettingsFormSchema) => { const onFormSubmit = async (data: TAddSettingsFormSchema) => {
@ -240,7 +241,9 @@ export const EnvelopeEditorSettingsDialog = ({
.safeParse(data.globalAccessAuth); .safeParse(data.globalAccessAuth);
try { try {
await updateEnvelopeAsync({ await updateEnvelope({
envelopeId: envelope.id,
envelopeType: envelope.type,
data: { data: {
externalId: data.externalId || null, externalId: data.externalId || null,
visibility: data.visibility, visibility: data.visibility,
@ -295,10 +298,12 @@ export const EnvelopeEditorSettingsDialog = ({
]); ]);
useEffect(() => { useEffect(() => {
form.reset(createDefaultValues()); form.reset();
setActiveTab('general'); setActiveTab('general');
}, [open, form]); }, [open, form]);
// Todo: Envelopes - Show error indicator if error is in different tab.
const selectedTab = tabs.find((tab) => tab.id === activeTab); const selectedTab = tabs.find((tab) => tab.id === activeTab);
if (!selectedTab) { if (!selectedTab) {
@ -321,7 +326,7 @@ export const EnvelopeEditorSettingsDialog = ({
<DialogContent className="flex w-full !max-w-5xl flex-row gap-0 p-0"> <DialogContent className="flex w-full !max-w-5xl flex-row gap-0 p-0">
{/* Sidebar. */} {/* Sidebar. */}
<div className="bg-accent/20 flex w-80 flex-col border-r"> <div className="flex w-80 flex-col border-r bg-gray-50">
<DialogHeader className="p-6 pb-4"> <DialogHeader className="p-6 pb-4">
<DialogTitle>Document Settings</DialogTitle> <DialogTitle>Document Settings</DialogTitle>
</DialogHeader> </DialogHeader>
@ -353,7 +358,7 @@ export const EnvelopeEditorSettingsDialog = ({
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}> <form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset <fieldset
className="flex h-[45rem] max-h-[calc(100vh-14rem)] w-full flex-col space-y-6 overflow-y-auto px-6 pt-6" className="flex min-h-[45rem] w-full flex-col space-y-6 px-6 pt-6"
disabled={form.formState.isSubmitting} disabled={form.formState.isSubmitting}
key={activeTab} key={activeTab}
> >

View File

@ -24,6 +24,7 @@ import {
mapSecondaryIdToDocumentId, mapSecondaryIdToDocumentId,
mapSecondaryIdToTemplateId, mapSecondaryIdToTemplateId,
} from '@documenso/lib/utils/envelope'; } from '@documenso/lib/utils/envelope';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator'; import { Separator } from '@documenso/ui/primitives/separator';
@ -31,17 +32,17 @@ import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialog'; import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialog';
import { EnvelopeDistributeDialog } from '~/components/dialogs/envelope-distribute-dialog'; import { EnvelopeDistributeDialog } from '~/components/dialogs/envelope-distribute-dialog';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
import { EnvelopeDuplicateDialog } from '~/components/dialogs/envelope-duplicate-dialog'; import { EnvelopeDuplicateDialog } from '~/components/dialogs/envelope-duplicate-dialog';
import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog'; import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog';
import { TemplateDeleteDialog } from '~/components/dialogs/template-delete-dialog'; import { TemplateDeleteDialog } from '~/components/dialogs/template-delete-dialog';
import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog'; import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog';
import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog'; import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog';
import { useCurrentTeam } from '~/providers/team';
import { EnvelopeEditorFieldsPage } from './envelope-editor-fields-page';
import EnvelopeEditorHeader from './envelope-editor-header'; import EnvelopeEditorHeader from './envelope-editor-header';
import { EnvelopeEditorPreviewPage } from './envelope-editor-preview-page'; import { EnvelopeEditorPageFields } from './envelope-editor-page-fields';
import { EnvelopeEditorUploadPage } from './envelope-editor-upload-page'; import { EnvelopeEditorPagePreview } from './envelope-editor-page-preview';
import { EnvelopeEditorPageUpload } from './envelope-editor-page-upload';
type EnvelopeEditorStep = 'upload' | 'addFields' | 'preview'; type EnvelopeEditorStep = 'upload' | 'addFields' | 'preview';
@ -73,16 +74,10 @@ export default function EnvelopeEditor() {
const { t } = useLingui(); const { t } = useLingui();
const navigate = useNavigate(); const navigate = useNavigate();
const team = useCurrentTeam();
const { const { envelope, isDocument, isTemplate, isAutosaving, flushAutosave } =
envelope, useCurrentEnvelopeEditor();
isDocument,
isTemplate,
isAutosaving,
flushAutosave,
relativePath,
editorFields,
} = useCurrentEnvelopeEditor();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false); const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
@ -105,10 +100,13 @@ export default function EnvelopeEditor() {
return 'upload'; return 'upload';
}); });
const documentsPath = formatDocumentsPath(team.url);
const templatesPath = formatTemplatesPath(team.url);
const navigateToStep = (step: EnvelopeEditorStep) => { const navigateToStep = (step: EnvelopeEditorStep) => {
setCurrentStep(step); setCurrentStep(step);
void flushAutosave(); flushAutosave();
if (!isStepLoading && isAutosaving) { if (!isStepLoading && isAutosaving) {
setIsStepLoading(true); setIsStepLoading(true);
@ -130,18 +128,6 @@ export default function EnvelopeEditor() {
} }
}; };
// Watch the URL params and setStep if the step changes.
useEffect(() => {
const stepParam = searchParams.get('step') || envelopeEditorSteps[0].id;
const foundStep = envelopeEditorSteps.find((step) => step.id === stepParam);
if (foundStep && foundStep.id !== currentStep) {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
navigateToStep(foundStep.id as EnvelopeEditorStep);
}
}, [searchParams]);
useEffect(() => { useEffect(() => {
if (!isAutosaving) { if (!isAutosaving) {
setIsStepLoading(false); setIsStepLoading(false);
@ -152,22 +138,20 @@ export default function EnvelopeEditor() {
envelopeEditorSteps.find((step) => step.id === currentStep) || envelopeEditorSteps[0]; envelopeEditorSteps.find((step) => step.id === currentStep) || envelopeEditorSteps[0];
return ( return (
<div className="dark:bg-background h-screen w-screen bg-gray-50"> <div className="h-screen w-screen bg-gray-50">
<EnvelopeEditorHeader /> <EnvelopeEditorHeader />
{/* Main Content Area */} {/* Main Content Area */}
<div className="flex h-[calc(100vh-4rem)] w-screen"> <div className="flex h-[calc(100vh-73px)] w-screen">
{/* Left Section - Step Navigation */} {/* Left Section - Step Navigation */}
<div className="bg-background border-border flex w-80 flex-shrink-0 flex-col overflow-y-auto border-r py-4"> <div className="flex w-80 flex-shrink-0 flex-col overflow-y-auto border-r border-gray-200 bg-white py-4">
{/* Left section step selector. */} {/* Left section step selector. */}
<div className="px-4"> <div className="px-4">
<h3 className="text-foreground flex items-end justify-between text-sm font-semibold"> <h3 className="flex items-end justify-between text-sm font-semibold text-gray-900">
{isDocument ? <Trans>Document Editor</Trans> : <Trans>Template Editor</Trans>} {isDocument ? <Trans>Document Editor</Trans> : <Trans>Template Editor</Trans>}
<span className="text-muted-foreground bg-muted/50 ml-2 rounded border px-2 py-0.5 text-xs"> <span className="text-muted-foreground ml-2 rounded border bg-gray-50 px-2 py-0.5 text-xs">
<Trans context="The step counter"> Step {currentStepData.order}/{envelopeEditorSteps.length}
Step {currentStepData.order}/{envelopeEditorSteps.length}
</Trans>
</span> </span>
</h3> </h3>
@ -192,17 +176,15 @@ export default function EnvelopeEditor() {
key={step.id} key={step.id}
className={`cursor-pointer rounded-lg p-3 transition-colors ${ className={`cursor-pointer rounded-lg p-3 transition-colors ${
isActive isActive
? 'border border-green-200 bg-green-50 dark:border-green-500/20 dark:bg-green-500/10' ? 'border border-green-200 bg-green-50'
: 'border border-gray-200 hover:bg-gray-50 dark:border-gray-400/20 dark:hover:bg-gray-400/10' : 'border border-gray-200 hover:bg-gray-50'
}`} }`}
onClick={() => navigateToStep(step.id as EnvelopeEditorStep)} onClick={() => navigateToStep(step.id as EnvelopeEditorStep)}
> >
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div <div
className={`rounded border p-2 ${ className={`rounded border p-2 ${
isActive isActive ? 'border-green-200 bg-green-50' : 'border-gray-100 bg-gray-100'
? 'border-green-200 bg-green-50 dark:border-green-500/20 dark:bg-green-500/10'
: 'border-gray-100 bg-gray-100 dark:border-gray-400/20 dark:bg-gray-400/10'
}`} }`}
> >
<Icon <Icon
@ -212,14 +194,12 @@ export default function EnvelopeEditor() {
<div> <div>
<div <div
className={`text-sm font-medium ${ className={`text-sm font-medium ${
isActive isActive ? 'text-green-900' : 'text-gray-700'
? 'text-green-900 dark:text-green-400'
: 'text-foreground dark:text-muted-foreground'
}`} }`}
> >
{t(step.title)} {t(step.title)}
</div> </div>
<div className="text-muted-foreground text-xs">{t(step.description)}</div> <div className="text-xs text-gray-500">{t(step.description)}</div>
</div> </div>
</div> </div>
</div> </div>
@ -232,25 +212,12 @@ export default function EnvelopeEditor() {
{/* Quick Actions. */} {/* Quick Actions. */}
<div className="space-y-3 px-4"> <div className="space-y-3 px-4">
<h4 className="text-foreground text-sm font-semibold"> <h4 className="text-sm font-semibold text-gray-900">
<Trans>Quick Actions</Trans> <Trans>Quick Actions</Trans>
</h4> </h4>
<EnvelopeEditorSettingsDialog
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<SettingsIcon className="mr-2 h-4 w-4" />
{isDocument ? <Trans>Document Settings</Trans> : <Trans>Template Settings</Trans>}
</Button>
}
/>
{isDocument && ( {isDocument && (
<EnvelopeDistributeDialog <EnvelopeDistributeDialog
envelope={{ envelope={envelope}
...envelope,
fields: editorFields.localFields,
}}
documentRootPath={relativePath.documentRootPath}
trigger={ trigger={
<Button variant="ghost" size="sm" className="w-full justify-start"> <Button variant="ghost" size="sm" className="w-full justify-start">
<SendIcon className="mr-2 h-4 w-4" /> <SendIcon className="mr-2 h-4 w-4" />
@ -272,6 +239,16 @@ export default function EnvelopeEditor() {
/> />
)} )}
<EnvelopeEditorSettingsDialog
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<SettingsIcon className="mr-2 h-4 w-4" />
{isDocument ? <Trans>Document Settings</Trans> : <Trans>Template Settings</Trans>}
</Button>
}
/>
{/* Todo: Envelopes */}
{/* <Button variant="ghost" size="sm" className="w-full justify-start"> {/* <Button variant="ghost" size="sm" className="w-full justify-start">
<FileText className="mr-2 h-4 w-4" /> <FileText className="mr-2 h-4 w-4" />
Save as Template Save as Template
@ -306,17 +283,11 @@ export default function EnvelopeEditor() {
} }
/> />
<EnvelopeDownloadDialog {/* Todo: Allow selecting which document to download and/or the original */}
envelopeId={envelope.id} <Button variant="ghost" size="sm" className="w-full justify-start">
envelopeStatus={envelope.status} <DownloadCloudIcon className="mr-2 h-4 w-4" />
envelopeItems={envelope.envelopeItems} <Trans>Download PDF</Trans>
trigger={ </Button>
<Button variant="ghost" size="sm" className="w-full justify-start">
<DownloadCloudIcon className="mr-2 h-4 w-4" />
<Trans>Download PDF</Trans>
</Button>
}
/>
<Button <Button
variant="ghost" variant="ghost"
@ -338,7 +309,7 @@ export default function EnvelopeEditor() {
open={isDeleteDialogOpen} open={isDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen} onOpenChange={setDeleteDialogOpen}
onDelete={async () => { onDelete={async () => {
await navigate(relativePath.documentRootPath); await navigate(documentsPath);
}} }}
/> />
) : ( ) : (
@ -347,7 +318,7 @@ export default function EnvelopeEditor() {
open={isDeleteDialogOpen} open={isDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen} onOpenChange={setDeleteDialogOpen}
onDelete={async () => { onDelete={async () => {
await navigate(relativePath.templateRootPath); await navigate(templatesPath);
}} }}
/> />
)} )}
@ -355,7 +326,7 @@ export default function EnvelopeEditor() {
{/* Footer of left sidebar. */} {/* Footer of left sidebar. */}
<div className="mt-auto px-4"> <div className="mt-auto px-4">
<Button variant="ghost" className="w-full justify-start" asChild> <Button variant="ghost" className="w-full justify-start" asChild>
<Link to={relativePath.basePath}> <Link to={isDocument ? documentsPath : templatesPath}>
<ArrowLeftIcon className="mr-2 h-4 w-4" /> <ArrowLeftIcon className="mr-2 h-4 w-4" />
{isDocument ? ( {isDocument ? (
<Trans>Return to documents</Trans> <Trans>Return to documents</Trans>
@ -368,14 +339,17 @@ export default function EnvelopeEditor() {
</div> </div>
{/* Main Content - Changes based on current step */} {/* Main Content - Changes based on current step */}
<AnimateGenericFadeInOut className="flex-1 overflow-y-auto" key={currentStep}> <div className="flex-1 overflow-y-auto">
{match({ currentStep, isStepLoading }) <p>{isAutosaving ? 'Autosaving...' : 'Not autosaving'}</p>
.with({ isStepLoading: true }, () => <SpinnerBox className="py-32" />) <AnimateGenericFadeInOut key={currentStep}>
.with({ currentStep: 'upload' }, () => <EnvelopeEditorUploadPage />) {match({ currentStep, isStepLoading })
.with({ currentStep: 'addFields' }, () => <EnvelopeEditorFieldsPage />) .with({ isStepLoading: true }, () => <SpinnerBox className="py-32" />)
.with({ currentStep: 'preview' }, () => <EnvelopeEditorPreviewPage />) .with({ currentStep: 'upload' }, () => <EnvelopeEditorPageUpload />)
.exhaustive()} .with({ currentStep: 'addFields' }, () => <EnvelopeEditorPageFields />)
</AnimateGenericFadeInOut> .with({ currentStep: 'preview' }, () => <EnvelopeEditorPagePreview />)
.exhaustive()}
</AnimateGenericFadeInOut>
</div>
</div> </div>
</div> </div>
); );

View File

@ -20,17 +20,16 @@ export const EnvelopeItemSelector = ({
}: EnvelopeItemSelectorProps) => { }: EnvelopeItemSelectorProps) => {
return ( return (
<button <button
title={typeof primaryText === 'string' ? primaryText : undefined} className={`flex min-w-0 cursor-pointer items-center space-x-3 rounded-lg border px-4 py-3 transition-colors ${
className={`flex h-fit max-w-72 flex-shrink-0 cursor-pointer items-center space-x-3 rounded-lg border px-4 py-3 transition-colors ${
isSelected isSelected
? 'border-green-200 bg-green-50 text-green-900 dark:border-green-400/30 dark:bg-green-400/10 dark:text-green-400' ? 'border-blue-200 bg-blue-50 text-blue-900'
: 'border-border bg-muted/50 hover:bg-muted/70' : 'border-gray-200 bg-gray-50 hover:bg-gray-100'
}`} }`}
{...buttonProps} {...buttonProps}
> >
<div <div
className={`flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-xs font-medium ${ className={`flex h-6 w-6 items-center justify-center rounded-full text-xs font-medium ${
isSelected ? 'bg-green-100 text-green-600' : 'bg-gray-200 text-gray-600' isSelected ? 'bg-blue-100 text-blue-600' : 'bg-gray-200 text-gray-600'
}`} }`}
> >
{number} {number}
@ -40,8 +39,8 @@ export const EnvelopeItemSelector = ({
<div className="text-xs text-gray-500">{secondaryText}</div> <div className="text-xs text-gray-500">{secondaryText}</div>
</div> </div>
<div <div
className={cn('h-2 w-2 flex-shrink-0 rounded-full', { className={cn('h-2 w-2 rounded-full', {
'bg-green-500': isSelected, 'bg-blue-500': isSelected,
})} })}
></div> ></div>
</button> </button>
@ -62,7 +61,7 @@ export const EnvelopeRendererFileSelector = ({
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender(); const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
return ( return (
<div className={cn('flex h-fit flex-shrink-0 space-x-2 overflow-x-auto p-4', className)}> <div className={cn('flex h-fit space-x-2 overflow-x-auto p-4', className)}>
{envelopeItems.map((doc, i) => ( {envelopeItems.map((doc, i) => (
<EnvelopeItemSelector <EnvelopeItemSelector
key={doc.id} key={doc.id}

View File

@ -1,136 +1,141 @@
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo, useRef } from 'react';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import { DocumentStatus, type Recipient, SigningStatus } from '@prisma/client'; import Konva from 'konva';
import type Konva from 'konva'; import type { Layer } from 'konva/lib/Layer';
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
import { usePageContext } from 'react-pdf';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider'; import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import type { TEnvelope } from '@documenso/lib/types/envelope'; import type { TEnvelope } from '@documenso/lib/types/envelope';
import { renderField } from '@documenso/lib/universal/field-renderer/render-field'; import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
import { EnvelopeRecipientFieldTooltip } from '@documenso/ui/components/document/envelope-recipient-field-tooltip';
type GenericLocalField = TEnvelope['fields'][number] & {
recipient: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>;
};
export default function EnvelopeGenericPageRenderer() { export default function EnvelopeGenericPageRenderer() {
const { i18n } = useLingui(); const pageContext = usePageContext();
const { if (!pageContext) {
envelopeStatus, throw new Error('Unable to find Page context.');
currentEnvelopeItem, }
fields,
recipients,
getRecipientColorKey,
setRenderError,
overrideSettings,
} = useCurrentEnvelopeRender();
const { const { _className, page, rotate, scale } = pageContext;
stage,
pageLayer,
canvasElement,
konvaContainer,
pageContext,
scaledViewport,
unscaledViewport,
} = usePageRenderer(({ stage, pageLayer }) => {
createPageCanvas(stage, pageLayer);
});
const { _className, scale } = pageContext; if (!page) {
throw new Error('Attempted to render page canvas, but no page was specified.');
}
const localPageFields = useMemo((): GenericLocalField[] => { const { t } = useLingui();
if (envelopeStatus === DocumentStatus.COMPLETED) { const { currentEnvelopeItem, fields } = useCurrentEnvelopeRender();
return [];
}
return fields const canvasElement = useRef<HTMLCanvasElement>(null);
.filter( const konvaContainer = useRef<HTMLDivElement>(null);
const stage = useRef<Konva.Stage | null>(null);
const pageLayer = useRef<Layer | null>(null);
const viewport = useMemo(
() => page.getViewport({ scale, rotation: rotate }),
[page, rotate, scale],
);
const localPageFields = useMemo(
() =>
fields.filter(
(field) => (field) =>
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id, field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
) ),
.map((field) => { [fields, pageContext.pageNumber],
const recipient = recipients.find((recipient) => recipient.id === field.recipientId); );
if (!recipient) { // Custom renderer from Konva examples.
throw new Error(`Recipient not found for field ${field.id}`); useEffect(
} function drawPageOnCanvas() {
if (!page) {
return;
}
const isInserted = recipient.signingStatus === SigningStatus.SIGNED && field.inserted; const { current: canvas } = canvasElement;
const { current: container } = konvaContainer;
return { if (!canvas || !container) {
...field, return;
inserted: isInserted, }
customText: isInserted ? field.customText : '',
recipient,
};
})
.filter(
({ inserted, fieldMeta, recipient }) =>
(recipient.signingStatus === SigningStatus.SIGNED ? inserted : true) ||
fieldMeta?.readOnly,
);
}, [fields, pageContext.pageNumber, currentEnvelopeItem?.id, recipients]);
const unsafeRenderFieldOnLayer = (field: GenericLocalField) => { const renderContext: RenderParameters = {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
viewport,
};
const cancellable = page.render(renderContext);
const runningTask = cancellable;
cancellable.promise.catch(() => {
// Intentionally empty
});
void cancellable.promise.then(() => {
createPageCanvas(container);
});
return () => {
runningTask.cancel();
};
},
[page, viewport],
);
const renderFieldOnLayer = (field: TEnvelope['fields'][number]) => {
if (!pageLayer.current) { if (!pageLayer.current) {
console.error('Layer not loaded yet'); console.error('Layer not loaded yet');
return; return;
} }
const fieldTranslations = getClientSideFieldTranslations(i18n);
renderField({ renderField({
scale,
pageLayer: pageLayer.current, pageLayer: pageLayer.current,
field: { field: {
renderId: field.id.toString(), renderId: field.id.toString(),
...field, ...field,
customText: '',
width: Number(field.width), width: Number(field.width),
height: Number(field.height), height: Number(field.height),
positionX: Number(field.positionX), positionX: Number(field.positionX),
positionY: Number(field.positionY), positionY: Number(field.positionY),
inserted: false,
fieldMeta: field.fieldMeta, fieldMeta: field.fieldMeta,
signature: {
signatureImageAsBase64: '',
typedSignature: fieldTranslations.SIGNATURE,
},
}, },
translations: fieldTranslations, pageWidth: viewport.width,
pageWidth: unscaledViewport.width, pageHeight: viewport.height,
pageHeight: unscaledViewport.height, // color: getRecipientColorKey(field.recipientId),
color: getRecipientColorKey(field.recipientId), color: 'purple', // Todo
editable: false, editable: false,
mode: overrideSettings?.mode ?? 'edit', mode: 'sign',
}); });
}; };
const renderFieldOnLayer = (field: GenericLocalField) => {
try {
unsafeRenderFieldOnLayer(field);
} catch (err) {
console.error(err);
setRenderError(true);
}
};
/** /**
* Initialize the Konva page canvas and all fields and interactions. * Create the initial Konva page canvas and initialize all fields and interactions.
*/ */
const createPageCanvas = (_currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => { const createPageCanvas = (container: HTMLDivElement) => {
stage.current = new Konva.Stage({
container,
width: viewport.width,
height: viewport.height,
});
// Create the main layer for interactive elements.
pageLayer.current = new Konva.Layer();
stage.current?.add(pageLayer.current);
// Render the fields. // Render the fields.
for (const field of localPageFields) { for (const field of localPageFields) {
renderFieldOnLayer(field); renderFieldOnLayer(field);
} }
currentPageLayer.batchDraw(); pageLayer.current.batchDraw();
}; };
/** /**
* Render fields when they are added or removed * Render fields when they are added or removed from the localFields.
*/ */
useEffect(() => { useEffect(() => {
if (!pageLayer.current || !stage.current) { if (!pageLayer.current || !stage.current) {
@ -143,12 +148,14 @@ export default function EnvelopeGenericPageRenderer() {
group.name() === 'field-group' && group.name() === 'field-group' &&
!localPageFields.some((field) => field.id.toString() === group.id()) !localPageFields.some((field) => field.id.toString() === group.id())
) { ) {
console.log('Field removed, removing from canvas');
group.destroy(); group.destroy();
} }
}); });
// If it exists, rerender. // If it exists, rerender.
localPageFields.forEach((field) => { localPageFields.forEach((field) => {
console.log('Field created/updated, rendering on canvas');
renderFieldOnLayer(field); renderFieldOnLayer(field);
}); });
@ -160,29 +167,14 @@ export default function EnvelopeGenericPageRenderer() {
} }
return ( return (
<div <div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}>
className="relative w-full" <div className="konva-container absolute inset-0 z-10" ref={konvaContainer}></div>
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
>
{overrideSettings?.showRecipientTooltip &&
localPageFields.map((field) => (
<EnvelopeRecipientFieldTooltip
key={field.id}
field={field}
showFieldStatus={overrideSettings?.showRecipientSigningStatus}
showRecipientTooltip={overrideSettings?.showRecipientTooltip}
/>
))}
{/* The element Konva will inject it's canvas into. */}
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
{/* Canvas the PDF will be rendered on. */}
<canvas <canvas
className={`${_className}__canvas z-0`} className={`${_className}__canvas z-0`}
height={viewport.height}
ref={canvasElement} ref={canvasElement}
height={scaledViewport.height} width={viewport.width}
width={scaledViewport.width}
/> />
</div> </div>
); );

View File

@ -1,33 +1,17 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Plural, Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { FieldType, RecipientRole } from '@prisma/client'; import { FieldType } from '@prisma/client';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label'; import { Label } from '@documenso/ui/primitives/label';
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog'; import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider'; import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
export default function EnvelopeSignerForm() { export default function EnvelopeSignerForm() {
const { const { fullName, signature, setFullName, setSignature, envelope, recipientFields } =
fullName, useRequiredEnvelopeSigningContext();
signature,
setFullName,
setSignature,
envelope,
recipientFields,
recipient,
assistantFields,
assistantRecipients,
selectedAssistantRecipient,
setSelectedAssistantRecipientId,
} = useRequiredEnvelopeSigningContext();
const { isNameLocked, isEmailLocked } = useEmbedSigningContext() || {};
const hasSignatureField = useMemo(() => { const hasSignatureField = useMemo(() => {
return recipientFields.some((field) => field.type === FieldType.SIGNATURE); return recipientFields.some((field) => field.type === FieldType.SIGNATURE);
@ -35,63 +19,6 @@ export default function EnvelopeSignerForm() {
const isSubmitting = false; const isSubmitting = false;
if (recipient.role === RecipientRole.VIEWER) {
return null;
}
if (recipient.role === RecipientRole.ASSISTANT) {
return (
<fieldset className="embed--DocumentWidgetForm dark:bg-background border-border rounded-2xl sm:border sm:p-3">
<RadioGroup
className="gap-0 space-y-2 shadow-none sm:space-y-3"
value={selectedAssistantRecipient?.id?.toString()}
onValueChange={(value) => {
setSelectedAssistantRecipientId(Number(value));
}}
>
{assistantRecipients
.filter((r) => r.fields.length > 0)
.map((r) => (
<div
key={r.id}
className="bg-widget border-border relative flex flex-col gap-4 rounded-lg border p-4"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<RadioGroupItem
id={r.id.toString()}
value={r.id.toString()}
className="after:absolute after:inset-0"
/>
<div className="grid grow gap-1">
<Label className="inline-flex items-start" htmlFor={r.id.toString()}>
{r.name}
{r.id === recipient.id && (
<span className="text-muted-foreground ml-2">
<Trans>(You)</Trans>
</span>
)}
</Label>
<p className="text-muted-foreground text-xs">{r.email}</p>
</div>
</div>
<div className="text-muted-foreground text-xs leading-[inherit]">
<Plural
value={assistantFields.filter((field) => field.recipientId === r.id).length}
one="# field"
other="# fields"
/>
</div>
</div>
</div>
))}
</RadioGroup>
</fieldset>
);
}
return ( return (
<fieldset disabled={isSubmitting} className="flex flex-1 flex-col gap-4"> <fieldset disabled={isSubmitting} className="flex flex-1 flex-col gap-4">
<div className="flex flex-1 flex-col gap-y-4"> <div className="flex flex-1 flex-col gap-y-4">
@ -105,8 +32,7 @@ export default function EnvelopeSignerForm() {
id="full-name" id="full-name"
className="bg-background mt-2" className="bg-background mt-2"
value={fullName} value={fullName}
disabled={isNameLocked} onChange={(e) => setFullName(e.target.value.trimStart())}
onChange={(e) => !isNameLocked && setFullName(e.target.value.trimStart())}
/> />
</div> </div>

View File

@ -1,142 +1,131 @@
import { Plural, Trans } from '@lingui/react/macro'; import { Plural, Trans, useLingui } from '@lingui/react/macro';
import { EnvelopeType, RecipientRole } from '@prisma/client'; import { Link, useNavigate } from 'react-router';
import { BanIcon, DownloadCloudIcon } from 'lucide-react';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope'; import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { trpc } from '@documenso/trpc/react';
import { Badge } from '@documenso/ui/primitives/badge'; import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { Separator } from '@documenso/ui/primitives/separator'; import { Separator } from '@documenso/ui/primitives/separator';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
import { BrandingLogo } from '~/components/general/branding-logo'; import { BrandingLogo } from '~/components/general/branding-logo';
import { BrandingLogoIcon } from '../branding-logo-icon'; import { DocumentSigningCompleteDialog } from '../document-signing/document-signing-complete-dialog';
import { DocumentSigningRejectDialog } from '../document-signing/document-signing-reject-dialog';
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider'; import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
import { EnvelopeSignerCompleteDialog } from './envelope-signing-complete-dialog';
export const EnvelopeSignerHeader = () => { export const EnvelopeSignerHeader = () => {
const { envelopeData, envelope, recipientFieldsRemaining, recipient } = const { t } = useLingui();
const navigate = useNavigate();
const analytics = useAnalytics();
const { envelope, setShowPendingFieldTooltip, recipientFieldsRemaining, recipient } =
useRequiredEnvelopeSigningContext(); useRequiredEnvelopeSigningContext();
const { currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
const {
mutateAsync: completeDocument,
isPending,
isSuccess,
} = trpc.recipient.completeDocumentWithToken.useMutation();
const handleOnNextFieldClick = () => {
const nextField = recipientFieldsRemaining[0];
if (!nextField) {
setShowPendingFieldTooltip(false);
return;
}
if (nextField.envelopeItemId !== currentEnvelopeItem?.id) {
setCurrentEnvelopeItem(nextField.envelopeItemId);
}
const fieldTooltip = document.querySelector(`#field-tooltip`);
if (fieldTooltip) {
fieldTooltip.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
setShowPendingFieldTooltip(true);
};
const handleOnCompleteClick = async (
nextSigner?: { name: string; email: string },
accessAuthOptions?: TRecipientAccessAuth,
) => {
const payload = {
token: recipient.token,
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
authOptions: accessAuthOptions,
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
};
await completeDocument(payload);
analytics.capture('App: Recipient has completed signing', {
signerId: recipient.id,
documentId: envelope.id,
timestamp: new Date().toISOString(),
});
if (envelope.documentMeta.redirectUrl) {
window.location.href = envelope.documentMeta.redirectUrl;
} else {
await navigate(`/sign/${recipient.token}/complete`);
}
};
return ( return (
<nav className="embed--DocumentWidgetHeader bg-background border-border max-w-screen flex flex-row justify-between border-b px-4 py-3 md:px-6"> <nav className="w-full border-b border-gray-200 bg-white px-6 py-3">
{/* Left side - Logo and title */} <div className="flex items-center justify-between">
<div className="flex min-w-0 flex-1 items-center space-x-2 md:w-auto md:flex-none"> <div className="flex items-center space-x-4">
<Link to="/" className="flex-shrink-0"> <Link to="/">
{envelopeData.settings.brandingEnabled && envelopeData.settings.brandingLogo ? ( <BrandingLogo className="h-6 w-auto" />
<img </Link>
src={`/api/branding/logo/team/${envelope.teamId}`} <Separator orientation="vertical" className="h-6" />
alt={`${envelope.team.name}'s Logo`}
className="h-6 w-auto"
/>
) : (
<>
<BrandingLogo className="hidden h-6 w-auto md:block" />
<BrandingLogoIcon className="h-6 w-auto md:hidden" />
</>
)}
</Link>
<h1 <div className="flex items-center space-x-2">
title={envelope.title} <h1 className="whitespace-nowrap text-sm font-medium text-gray-600">
className="text-foreground min-w-0 truncate text-base font-semibold md:hidden" {envelope.title}
> </h1>
{envelope.title}
</h1>
<Separator orientation="vertical" className="hidden h-6 md:block" /> <Badge variant="secondary">
<Trans>Approver</Trans>
<div className="hidden items-center space-x-2 md:flex"> </Badge>
<h1 className="text-foreground whitespace-nowrap text-sm font-medium"> </div>
{envelope.title}
</h1>
<Badge>
{match(recipient.role)
.with(RecipientRole.VIEWER, () => <Trans>Viewer</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Signer</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Approver</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Assistant</Trans>)
.otherwise(() => null)}
</Badge>
</div> </div>
</div>
{/* Right side - Desktop content */} <div className="flex items-center space-x-2">
<div className="hidden items-center space-x-2 lg:flex"> <p className="text-muted-foreground mr-2 flex-shrink-0 text-sm">
<p className="text-muted-foreground mr-2 flex-shrink-0 text-sm"> <Plural
<Plural one="1 Field Remaining"
one="1 Field Remaining" other="# Fields Remaining"
other="# Fields Remaining" value={recipientFieldsRemaining.length}
value={recipientFieldsRemaining.length} />
</p>
<DocumentSigningCompleteDialog
isSubmitting={isPending}
onSignatureComplete={handleOnCompleteClick}
documentTitle={envelope.title}
fields={recipientFieldsRemaining}
fieldsValidated={handleOnNextFieldClick}
recipient={recipient}
// Todo: Envelopes
allowDictateNextSigner={envelope.documentMeta.allowDictateNextSigner}
// defaultNextSigner={
// nextRecipient
// ? { name: nextRecipient.name, email: nextRecipient.email }
// : undefined
// }
// Todo: Envelopes - use
// buttonSize="sm"
/> />
</p> </div>
<EnvelopeSignerCompleteDialog />
</div>
{/* Mobile Actions button */}
<div className="flex-shrink-0 lg:hidden">
<MobileDropdownMenu />
</div> </div>
</nav> </nav>
); );
}; };
const MobileDropdownMenu = () => {
const { envelope, recipient } = useRequiredEnvelopeSigningContext();
const { allowDocumentRejection } = useEmbedSigningContext() || {};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Trans>Actions</Trans>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<EnvelopeDownloadDialog
envelopeId={envelope.id}
envelopeStatus={envelope.status}
envelopeItems={envelope.envelopeItems}
token={recipient.token}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
<DownloadCloudIcon className="mr-2 h-4 w-4" />
<Trans>Download PDF</Trans>
</div>
</DropdownMenuItem>
}
/>
{envelope.type === EnvelopeType.DOCUMENT && allowDocumentRejection !== false && (
<DocumentSigningRejectDialog
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
token={recipient.token}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
<BanIcon className="mr-2 h-4 w-4" />
<Trans>Reject</Trans>
</div>
</DropdownMenuItem>
}
/>
)}
</DropdownMenuContent>
</DropdownMenu>
);
};

View File

@ -1,39 +1,22 @@
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo, useRef } from 'react';
import { Trans, useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import { import { type Field, FieldType } from '@prisma/client';
type Field, import Konva from 'konva';
FieldType, import type { Layer } from 'konva/lib/Layer';
type Recipient,
RecipientRole,
type Signature,
SigningStatus,
} from '@prisma/client';
import type Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node'; import type { KonvaEventObject } from 'konva/lib/Node';
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
import { usePageContext } from 'react-pdf';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider'; import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates';
import { isBase64Image } from '@documenso/lib/constants/signatures';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import type { TEnvelope } from '@documenso/lib/types/envelope';
import { ZFullFieldSchema } from '@documenso/lib/types/field'; import { ZFullFieldSchema } from '@documenso/lib/types/field';
import { createSpinner } from '@documenso/lib/universal/field-renderer/field-generic-items'; import { createSpinner } from '@documenso/lib/universal/field-renderer/field-generic-items';
import { renderField } from '@documenso/lib/universal/field-renderer/render-field'; import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers'; import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import type { TSignEnvelopeFieldValue } from '@documenso/trpc/server/envelope-router/sign-envelope-field.types';
import { EnvelopeRecipientFieldTooltip } from '@documenso/ui/components/document/envelope-recipient-field-tooltip';
import { EnvelopeFieldToolTip } from '@documenso/ui/components/field/envelope-field-tooltip';
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors'; import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
import { handleCheckboxFieldClick } from '~/utils/field-signing/checkbox-field';
import { handleDropdownFieldClick } from '~/utils/field-signing/dropdown-field'; import { handleDropdownFieldClick } from '~/utils/field-signing/dropdown-field';
import { handleEmailFieldClick } from '~/utils/field-signing/email-field'; import { handleEmailFieldClick } from '~/utils/field-signing/email-field';
import { handleInitialsFieldClick } from '~/utils/field-signing/initial-field'; import { handleInitialsFieldClick } from '~/utils/field-signing/initial-field';
@ -42,99 +25,102 @@ import { handleNumberFieldClick } from '~/utils/field-signing/number-field';
import { handleSignatureFieldClick } from '~/utils/field-signing/signature-field'; import { handleSignatureFieldClick } from '~/utils/field-signing/signature-field';
import { handleTextFieldClick } from '~/utils/field-signing/text-field'; import { handleTextFieldClick } from '~/utils/field-signing/text-field';
import { useRequiredDocumentSigningAuthContext } from '../document-signing/document-signing-auth-provider';
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider'; import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
type GenericLocalField = TEnvelope['fields'][number] & {
recipient: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>;
};
export default function EnvelopeSignerPageRenderer() { export default function EnvelopeSignerPageRenderer() {
const { t, i18n } = useLingui(); const pageContext = usePageContext();
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
const { sessionData } = useOptionalSession();
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext(); if (!pageContext) {
const { toast } = useToast(); throw new Error('Unable to find Page context.');
}
const { _className, page, rotate, scale } = pageContext;
if (!page) {
throw new Error('Attempted to render page canvas, but no page was specified.');
}
const { t } = useLingui();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
const { const {
envelopeData, envelopeData,
recipient,
recipientFields, recipientFields,
recipientFieldsRemaining, recipientFieldsRemaining,
showPendingFieldTooltip, showPendingFieldTooltip,
signField: signFieldInternal, signField,
email, email,
setEmail, setEmail,
fullName, fullName,
setFullName, setFullName,
signature, signature,
setSignature, setSignature,
selectedAssistantRecipientFields,
selectedAssistantRecipient,
isDirectTemplate,
} = useRequiredEnvelopeSigningContext(); } = useRequiredEnvelopeSigningContext();
const { onFieldSigned, onFieldUnsigned } = useEmbedSigningContext() || {}; console.log({ fullName });
const {
stage,
pageLayer,
canvasElement,
konvaContainer,
pageContext,
scaledViewport,
unscaledViewport,
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer));
const { _className, scale } = pageContext;
const { envelope } = envelopeData; const { envelope } = envelopeData;
const localPageFields = useMemo(() => { const canvasElement = useRef<HTMLCanvasElement>(null);
let fieldsToRender = recipientFields; const konvaContainer = useRef<HTMLDivElement>(null);
if (recipient.role === RecipientRole.ASSISTANT) { const stage = useRef<Konva.Stage | null>(null);
fieldsToRender = selectedAssistantRecipientFields; const pageLayer = useRef<Layer | null>(null);
}
return fieldsToRender.filter( const viewport = useMemo(
(field) => () => page.getViewport({ scale, rotation: rotate }),
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id, [page, rotate, scale],
); );
}, [recipientFields, selectedAssistantRecipientFields, pageContext.pageNumber]);
/** const localPageFields = useMemo(
* Returns fields that have been fully signed by other recipients for this specific () =>
* page. recipientFields.filter(
*/ (field) =>
const localPageOtherRecipientFields = useMemo((): GenericLocalField[] => { field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
const signedRecipients = envelope.recipients.filter( ),
(recipient) => recipient.signingStatus === SigningStatus.SIGNED, [recipientFields, pageContext.pageNumber],
); );
return signedRecipients.flatMap((recipient) => { // Custom renderer from Konva examples.
return recipient.fields useEffect(
.filter( function drawPageOnCanvas() {
(field) => if (!page) {
field.page === pageContext.pageNumber && return;
field.envelopeItemId === currentEnvelopeItem?.id && }
(field.inserted || field.fieldMeta?.readOnly),
)
.map((field) => ({
...field,
recipient: {
id: recipient.id,
name: recipient.name,
email: recipient.email,
signingStatus: recipient.signingStatus,
role: recipient.role,
},
}));
});
}, [envelope.recipients, pageContext.pageNumber]);
const unsafeRenderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => { const { current: canvas } = canvasElement;
const { current: container } = konvaContainer;
if (!canvas || !container) {
return;
}
const renderContext: RenderParameters = {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
viewport,
};
const cancellable = page.render(renderContext);
const runningTask = cancellable;
cancellable.promise.catch(() => {
// Intentionally empty
});
void cancellable.promise.then(() => {
createPageCanvas(container);
});
return () => {
runningTask.cancel();
};
},
[page, viewport],
);
const renderFieldOnLayer = (unparsedField: Field) => {
if (!pageLayer.current) { if (!pageLayer.current) {
console.error('Layer not loaded yet'); console.error('Layer not loaded yet');
return; return;
@ -151,7 +137,6 @@ export default function EnvelopeSignerPageRenderer() {
} }
const { fieldGroup } = renderField({ const { fieldGroup } = renderField({
scale,
pageLayer: pageLayer.current, pageLayer: pageLayer.current,
field: { field: {
renderId: fieldToRender.id.toString(), renderId: fieldToRender.id.toString(),
@ -160,11 +145,9 @@ export default function EnvelopeSignerPageRenderer() {
height: Number(fieldToRender.height), height: Number(fieldToRender.height),
positionX: Number(fieldToRender.positionX), positionX: Number(fieldToRender.positionX),
positionY: Number(fieldToRender.positionY), positionY: Number(fieldToRender.positionY),
signature: unparsedField.signature,
}, },
translations: getClientSideFieldTranslations(i18n), pageWidth: viewport.width,
pageWidth: unscaledViewport.width, pageHeight: viewport.height,
pageHeight: unscaledViewport.height,
color, color,
mode: 'sign', mode: 'sign',
}); });
@ -175,36 +158,20 @@ export default function EnvelopeSignerPageRenderer() {
const { width: fieldWidth, height: fieldHeight } = fieldGroup.getClientRect(); const { width: fieldWidth, height: fieldHeight } = fieldGroup.getClientRect();
const foundField = localPageFields.find((f) => f.id === unparsedField.id); const foundField = recipientFields.find((f) => f.id === unparsedField.id);
const foundLoadingGroup = currentTarget.findOne('.loading-spinner-group'); const foundLoadingGroup = currentTarget.findOne('.loading-spinner-group');
if (!foundField || foundLoadingGroup || foundField.fieldMeta?.readOnly) { if (!foundField || foundLoadingGroup || foundField.fieldMeta?.readOnly) {
return; return;
} }
let localEmail: string | null = email;
let localFullName: string | null = fullName;
let placeholderEmail: string | null = null;
if (recipient.role === RecipientRole.ASSISTANT) {
localEmail = selectedAssistantRecipient?.email || null;
localFullName = selectedAssistantRecipient?.name || null;
}
// Allows us let the user set a different email than their current logged in email.
if (isDirectTemplate) {
placeholderEmail = sessionData?.user?.email || email || recipient.email;
if (!placeholderEmail || placeholderEmail === DIRECT_TEMPLATE_RECIPIENT_EMAIL) {
placeholderEmail = null;
}
}
const loadingSpinnerGroup = createSpinner({ const loadingSpinnerGroup = createSpinner({
fieldWidth: fieldWidth / scale, fieldWidth,
fieldHeight: fieldHeight / scale, fieldHeight,
}); });
fieldGroup.add(loadingSpinnerGroup);
const parsedFoundField = ZFullFieldSchema.parse(foundField); const parsedFoundField = ZFullFieldSchema.parse(foundField);
match(parsedFoundField) match(parsedFoundField)
@ -212,39 +179,34 @@ export default function EnvelopeSignerPageRenderer() {
* CHECKBOX FIELD. * CHECKBOX FIELD.
*/ */
.with({ type: FieldType.CHECKBOX }, (field) => { .with({ type: FieldType.CHECKBOX }, (field) => {
const clickedCheckboxIndex = Number(target.getAttr('internalCheckboxIndex')); const { fieldMeta } = field;
if (Number.isNaN(clickedCheckboxIndex)) { const { values } = fieldMeta;
return;
}
handleCheckboxFieldClick({ field, clickedCheckboxIndex }) const checkedValues = (values || [])
.then(async (payload) => { .map((v) => ({
if (payload) { ...v,
fieldGroup.add(loadingSpinnerGroup); checked: v.id === target.getAttr('internalCheckboxId') ? !v.checked : v.checked,
await signField(field.id, payload); }))
} .filter((v) => v.checked);
})
.finally(() => { void signField(field.id, {
loadingSpinnerGroup.destroy(); type: FieldType.CHECKBOX,
}); value: checkedValues.map((v) => v.id),
}).finally(() => {
loadingSpinnerGroup.destroy();
});
}) })
/** /**
* RADIO FIELD. * RADIO FIELD.
*/ */
.with({ type: FieldType.RADIO }, (field) => { .with({ type: FieldType.RADIO }, (field) => {
const selectedRadioIndex = Number(target.getAttr('internalRadioIndex')); const { fieldMeta } = foundField;
const fieldCustomText = Number(field.customText);
if (Number.isNaN(selectedRadioIndex)) { const checkedValue = target.getAttr('internalRadioValue');
return;
}
fieldGroup.add(loadingSpinnerGroup);
// Uncheck the value if it's already pressed. // Uncheck the value if it's already pressed.
const value = const value = field.inserted && checkedValue === field.customText ? null : checkedValue;
field.inserted && selectedRadioIndex === fieldCustomText ? null : selectedRadioIndex;
void signField(field.id, { void signField(field.id, {
type: FieldType.RADIO, type: FieldType.RADIO,
@ -260,7 +222,6 @@ export default function EnvelopeSignerPageRenderer() {
handleNumberFieldClick({ field, number: null }) handleNumberFieldClick({ field, number: null })
.then(async (payload) => { .then(async (payload) => {
if (payload) { if (payload) {
fieldGroup.add(loadingSpinnerGroup);
await signField(field.id, payload); await signField(field.id, payload);
} }
}) })
@ -275,7 +236,6 @@ export default function EnvelopeSignerPageRenderer() {
handleTextFieldClick({ field, text: null }) handleTextFieldClick({ field, text: null })
.then(async (payload) => { .then(async (payload) => {
if (payload) { if (payload) {
fieldGroup.add(loadingSpinnerGroup);
await signField(field.id, payload); await signField(field.id, payload);
} }
}) })
@ -287,11 +247,10 @@ export default function EnvelopeSignerPageRenderer() {
* EMAIL FIELD. * EMAIL FIELD.
*/ */
.with({ type: FieldType.EMAIL }, (field) => { .with({ type: FieldType.EMAIL }, (field) => {
handleEmailFieldClick({ field, email: localEmail, placeholderEmail }) handleEmailFieldClick({ field, email })
.then(async (payload) => { .then(async (payload) => {
if (payload) { if (payload) {
fieldGroup.add(loadingSpinnerGroup); await signField(field.id, payload); // Todo: Envelopes - Handle errors
await signField(field.id, payload);
} }
if (payload?.value) { if (payload?.value) {
@ -306,12 +265,11 @@ export default function EnvelopeSignerPageRenderer() {
* INITIALS FIELD. * INITIALS FIELD.
*/ */
.with({ type: FieldType.INITIALS }, (field) => { .with({ type: FieldType.INITIALS }, (field) => {
const initials = localFullName ? extractInitials(localFullName) : null; const initials = fullName ? extractInitials(fullName) : null;
handleInitialsFieldClick({ field, initials }) handleInitialsFieldClick({ field, initials })
.then(async (payload) => { .then(async (payload) => {
if (payload) { if (payload) {
fieldGroup.add(loadingSpinnerGroup);
await signField(field.id, payload); await signField(field.id, payload);
} }
}) })
@ -323,10 +281,9 @@ export default function EnvelopeSignerPageRenderer() {
* NAME FIELD. * NAME FIELD.
*/ */
.with({ type: FieldType.NAME }, (field) => { .with({ type: FieldType.NAME }, (field) => {
handleNameFieldClick({ field, name: localFullName }) handleNameFieldClick({ field, name: fullName })
.then(async (payload) => { .then(async (payload) => {
if (payload) { if (payload) {
fieldGroup.add(loadingSpinnerGroup);
await signField(field.id, payload); await signField(field.id, payload);
} }
@ -345,7 +302,6 @@ export default function EnvelopeSignerPageRenderer() {
handleDropdownFieldClick({ field, text: null }) handleDropdownFieldClick({ field, text: null })
.then(async (payload) => { .then(async (payload) => {
if (payload) { if (payload) {
fieldGroup.add(loadingSpinnerGroup);
await signField(field.id, payload); await signField(field.id, payload);
} }
@ -359,8 +315,6 @@ export default function EnvelopeSignerPageRenderer() {
* DATE FIELD. * DATE FIELD.
*/ */
.with({ type: FieldType.DATE }, (field) => { .with({ type: FieldType.DATE }, (field) => {
fieldGroup.add(loadingSpinnerGroup);
void signField(field.id, { void signField(field.id, {
type: FieldType.DATE, type: FieldType.DATE,
value: !field.inserted, value: !field.inserted,
@ -372,6 +326,7 @@ export default function EnvelopeSignerPageRenderer() {
* SIGNATURE FIELD. * SIGNATURE FIELD.
*/ */
.with({ type: FieldType.SIGNATURE }, (field) => { .with({ type: FieldType.SIGNATURE }, (field) => {
// Todo: Envelopes - Reauth
handleSignatureFieldClick({ handleSignatureFieldClick({
field, field,
signature, signature,
@ -381,22 +336,11 @@ export default function EnvelopeSignerPageRenderer() {
}) })
.then(async (payload) => { .then(async (payload) => {
if (payload) { if (payload) {
fieldGroup.add(loadingSpinnerGroup); await signField(field.id, payload);
}
if (payload.value) { if (payload?.value) {
void executeActionAuthProcedure({ setSignature(payload.value);
onReauthFormSubmit: async (authOptions) => {
await signField(field.id, payload, authOptions);
loadingSpinnerGroup.destroy();
},
actionTarget: field.type,
});
setSignature(payload.value);
} else {
await signField(field.id, payload);
}
} }
}) })
.finally(() => { .finally(() => {
@ -404,99 +348,38 @@ export default function EnvelopeSignerPageRenderer() {
}); });
}) })
.exhaustive(); .exhaustive();
console.log('Field clicked');
}; };
fieldGroup.off('pointerdown'); fieldGroup.off('click');
fieldGroup.on('pointerdown', handleFieldGroupClick); fieldGroup.on('click', handleFieldGroupClick);
}; };
const renderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => { /**
try { * Create the initial Konva page canvas and initialize all fields and interactions.
unsafeRenderFieldOnLayer(unparsedField); */
} catch (err) { const createPageCanvas = (container: HTMLDivElement) => {
console.error(err); stage.current = new Konva.Stage({
setRenderError(true); container,
} width: viewport.width,
}; height: viewport.height,
});
const renderFields = () => { // Create the main layer for interactive elements.
if (!pageLayer.current) { pageLayer.current = new Konva.Layer();
console.error('Layer not loaded yet'); stage.current?.add(pageLayer.current);
return;
}
// Render current recipient fields. console.log({
localPageFields,
});
// Render the fields.
for (const field of localPageFields) { for (const field of localPageFields) {
renderFieldOnLayer(field); renderFieldOnLayer(field);
} }
// Render other recipient signed and inserted fields. pageLayer.current.batchDraw();
for (const field of localPageOtherRecipientFields) {
try {
renderField({
scale,
pageLayer: pageLayer.current,
field: {
renderId: field.id.toString(),
...field,
width: Number(field.width),
height: Number(field.height),
positionX: Number(field.positionX),
positionY: Number(field.positionY),
fieldMeta: field.fieldMeta,
},
translations: getClientSideFieldTranslations(i18n),
pageWidth: unscaledViewport.width,
pageHeight: unscaledViewport.height,
color: 'readOnly',
editable: false,
mode: 'sign',
});
} catch (err) {
console.error('Unable to render one or more fields belonging to other recipients.');
console.error(err);
}
}
};
const signField = async (
fieldId: number,
payload: TSignEnvelopeFieldValue,
authOptions?: TRecipientActionAuth,
) => {
try {
const { inserted } = await signFieldInternal(fieldId, payload, authOptions);
// ?: The two callbacks below are used within the embedding context
if (inserted && onFieldSigned) {
const value = payload.value ? JSON.stringify(payload.value) : undefined;
const isBase64 = value ? isBase64Image(value) : undefined;
onFieldSigned({ fieldId, value, isBase64 });
}
if (!inserted && onFieldUnsigned) {
onFieldUnsigned({ fieldId });
}
} catch (err) {
console.error(err);
toast({
title: t`Error`,
description: t`An error occurred while signing the field.`,
variant: 'destructive',
});
throw err;
}
};
/**
* Initialize the Konva page canvas and all fields and interactions.
*/
const createPageCanvas = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
renderFields();
currentPageLayer.batchDraw();
}; };
/** /**
@ -507,67 +390,27 @@ export default function EnvelopeSignerPageRenderer() {
return; return;
} }
renderFields(); localPageFields.forEach((field) => {
console.log('Field changed/inserted, rendering on canvas');
renderFieldOnLayer(field);
});
pageLayer.current.batchDraw(); pageLayer.current.batchDraw();
}, [localPageFields, showPendingFieldTooltip, fullName, signature, email]); }, [localPageFields, showPendingFieldTooltip, fullName, signature, email]);
/**
* Rerender the whole page if the selected assistant recipient changes.
*/
useEffect(() => {
if (!pageLayer.current || !stage.current) {
return;
}
// Rerender the whole page.
pageLayer.current.destroyChildren();
renderFields();
pageLayer.current.batchDraw();
}, [selectedAssistantRecipient]);
if (!currentEnvelopeItem) { if (!currentEnvelopeItem) {
return null; return null;
} }
return ( return (
<div <div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}>
className="relative w-full"
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
>
{showPendingFieldTooltip &&
recipientFieldsRemaining.length > 0 &&
recipientFieldsRemaining[0]?.envelopeItemId === currentEnvelopeItem?.id &&
recipientFieldsRemaining[0]?.page === pageContext.pageNumber && (
<EnvelopeFieldToolTip
key={recipientFieldsRemaining[0].id}
field={recipientFieldsRemaining[0]}
color="warning"
>
<Trans>Click to insert field</Trans>
</EnvelopeFieldToolTip>
)}
{localPageOtherRecipientFields.map((field) => (
<EnvelopeRecipientFieldTooltip
key={field.id}
field={field}
showFieldStatus={true}
showRecipientTooltip={true}
/>
))}
{/* The element Konva will inject it's canvas into. */}
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div> <div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
{/* Canvas the PDF will be rendered on. */}
<canvas <canvas
className={`${_className}__canvas z-0`} className={`${_className}__canvas z-0`}
height={viewport.height}
ref={canvasElement} ref={canvasElement}
height={scaledViewport.height} width={viewport.width}
width={scaledViewport.width}
/> />
</div> </div>
); );

View File

@ -1,233 +0,0 @@
import { useMemo } from 'react';
import { useLingui } from '@lingui/react/macro';
import { FieldType } from '@prisma/client';
import { useNavigate, useRevalidator, useSearchParams } from 'react-router';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { isBase64Image } from '@documenso/lib/constants/signatures';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { trpc } from '@documenso/trpc/react';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
import { DocumentSigningCompleteDialog } from '../document-signing/document-signing-complete-dialog';
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
export const EnvelopeSignerCompleteDialog = () => {
const navigate = useNavigate();
const analytics = useAnalytics();
const { t } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const [searchParams] = useSearchParams();
const {
isDirectTemplate,
envelope,
setShowPendingFieldTooltip,
recipientFieldsRemaining,
recipient,
nextRecipient,
email,
fullName,
} = useRequiredEnvelopeSigningContext();
const { currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
const { onDocumentCompleted, onDocumentError } = useEmbedSigningContext() || {};
const { mutateAsync: completeDocument, isPending } =
trpc.recipient.completeDocumentWithToken.useMutation();
const { mutateAsync: createDocumentFromDirectTemplate } =
trpc.template.createDocumentFromDirectTemplate.useMutation();
const handleOnNextFieldClick = () => {
const nextField = recipientFieldsRemaining[0];
if (!nextField) {
setShowPendingFieldTooltip(false);
return;
}
if (nextField.envelopeItemId !== currentEnvelopeItem?.id) {
setCurrentEnvelopeItem(nextField.envelopeItemId);
}
const fieldTooltip = document.querySelector(`#field-tooltip`);
if (fieldTooltip) {
fieldTooltip.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
setShowPendingFieldTooltip(true);
};
const handleOnCompleteClick = async (
nextSigner?: { name: string; email: string },
accessAuthOptions?: TRecipientAccessAuth,
) => {
try {
await completeDocument({
token: recipient.token,
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
accessAuthOptions,
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
});
analytics.capture('App: Recipient has completed signing', {
signerId: recipient.id,
documentId: envelope.id,
timestamp: new Date().toISOString(),
});
if (onDocumentCompleted) {
onDocumentCompleted({
token: recipient.token,
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
recipientId: recipient.id,
envelopeId: envelope.id,
});
await revalidate();
return;
}
if (envelope.documentMeta.redirectUrl) {
window.location.href = envelope.documentMeta.redirectUrl;
} else {
await navigate(`/sign/${recipient.token}/complete`);
}
} catch (err) {
const error = AppError.parseError(err);
if (error.code !== AppErrorCode.TWO_FACTOR_AUTH_FAILED) {
toast({
title: t`Something went wrong`,
description: t`We were unable to submit this document at this time. Please try again later.`,
variant: 'destructive',
});
onDocumentError?.();
}
throw err;
}
};
/**
* Direct template completion flow.
*/
const handleDirectTemplateCompleteClick = async (
nextSigner?: { name: string; email: string },
accessAuthOptions?: TRecipientAccessAuth,
recipientDetails?: { name: string; email: string },
) => {
try {
let directTemplateExternalId = searchParams?.get('externalId') || undefined;
if (directTemplateExternalId) {
directTemplateExternalId = decodeURIComponent(directTemplateExternalId);
}
if (!recipient.directToken) {
throw new Error('Recipient direct token is required');
}
const { token } = await createDocumentFromDirectTemplate({
directTemplateToken: recipient.directToken, // The direct template token is inserted into the recipient token for ease of use.
directTemplateExternalId,
directRecipientName: recipientDetails?.name || fullName,
directRecipientEmail: recipientDetails?.email || email,
templateUpdatedAt: envelope.updatedAt,
signedFieldValues: recipient.fields.map((field) => {
let value = field.customText;
let isBase64 = false;
if (field.type === FieldType.SIGNATURE && field.signature) {
value = field.signature.signatureImageAsBase64 || field.signature.typedSignature || '';
isBase64 = isBase64Image(value);
}
return {
token: '',
fieldId: field.id,
value,
isBase64,
};
}),
nextSigner,
});
const redirectUrl = envelope.documentMeta.redirectUrl;
if (onDocumentCompleted) {
await navigate({
pathname: `/embed/sign/${token}`,
search: window.location.search,
hash: window.location.hash,
});
return;
}
if (redirectUrl) {
window.location.href = redirectUrl;
} else {
await navigate(`/sign/${token}/complete`);
}
} catch (err) {
console.log('err', err);
toast({
title: t`Something went wrong`,
description: t`We were unable to submit this document at this time. Please try again later.`,
variant: 'destructive',
});
onDocumentError?.();
throw err;
}
};
const directTemplatePayload = useMemo(() => {
if (!isDirectTemplate) {
return;
}
return {
name: fullName,
email: email,
};
}, [email, fullName, isDirectTemplate]);
return (
<DocumentSigningCompleteDialog
isSubmitting={isPending}
directTemplatePayload={directTemplatePayload}
onSignatureComplete={
isDirectTemplate ? handleDirectTemplateCompleteClick : handleOnCompleteClick
}
documentTitle={envelope.title}
fields={recipientFieldsRemaining}
fieldsValidated={handleOnNextFieldClick}
recipient={recipient}
allowDictateNextSigner={Boolean(
nextRecipient && envelope.documentMeta.allowDictateNextSigner,
)}
defaultNextSigner={
nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined
}
buttonSize="sm"
position="center"
/>
);
};

View File

@ -5,7 +5,6 @@ import { FolderType } from '@prisma/client';
import { FolderIcon, HomeIcon } from 'lucide-react'; import { FolderIcon, HomeIcon } from 'lucide-react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema'; import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
@ -16,12 +15,10 @@ import { FolderDeleteDialog } from '~/components/dialogs/folder-delete-dialog';
import { FolderMoveDialog } from '~/components/dialogs/folder-move-dialog'; import { FolderMoveDialog } from '~/components/dialogs/folder-move-dialog';
import { FolderUpdateDialog } from '~/components/dialogs/folder-update-dialog'; import { FolderUpdateDialog } from '~/components/dialogs/folder-update-dialog';
import { TemplateCreateDialog } from '~/components/dialogs/template-create-dialog'; import { TemplateCreateDialog } from '~/components/dialogs/template-create-dialog';
import { DocumentUploadButtonLegacy } from '~/components/general/document/document-upload-button-legacy'; import { DocumentUploadButton } from '~/components/general/document/document-upload-button';
import { FolderCard, FolderCardEmpty } from '~/components/general/folder/folder-card'; import { FolderCard, FolderCardEmpty } from '~/components/general/folder/folder-card';
import { useCurrentTeam } from '~/providers/team'; import { useCurrentTeam } from '~/providers/team';
import { EnvelopeUploadButton } from '../envelope/envelope-upload-button';
export type FolderGridProps = { export type FolderGridProps = {
type: FolderType; type: FolderType;
parentId: string | null; parentId: string | null;
@ -29,7 +26,6 @@ export type FolderGridProps = {
export const FolderGrid = ({ type, parentId }: FolderGridProps) => { export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
const team = useCurrentTeam(); const team = useCurrentTeam();
const organisation = useCurrentOrganisation();
const [isMovingFolder, setIsMovingFolder] = useState(false); const [isMovingFolder, setIsMovingFolder] = useState(false);
const [folderToMove, setFolderToMove] = useState<TFolderWithSubfolders | null>(null); const [folderToMove, setFolderToMove] = useState<TFolderWithSubfolders | null>(null);
@ -98,12 +94,13 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
</div> </div>
<div className="flex gap-4 sm:flex-row sm:justify-end"> <div className="flex gap-4 sm:flex-row sm:justify-end">
<EnvelopeUploadButton type={type} folderId={parentId || undefined} /> {/* Todo: Envelopes - Feature flag */}
{/* <EnvelopeUploadButton type={type} folderId={parentId || undefined} /> */}
{type === FolderType.DOCUMENT ? ( {type === FolderType.DOCUMENT ? (
<DocumentUploadButtonLegacy /> // If you delete this, delete the component as well. <DocumentUploadButton />
) : ( ) : (
<TemplateCreateDialog folderId={parentId ?? undefined} /> // If you delete this, delete the component as well. <TemplateCreateDialog folderId={parentId ?? undefined} />
)} )}
<FolderCreateDialog type={type} /> <FolderCreateDialog type={type} />

View File

@ -0,0 +1,50 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Download } from 'lucide-react';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import type { DocumentData } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type ShareDocumentDownloadButtonProps = {
title: string;
documentData: DocumentData;
};
// Todo: Envelopes - Support multiple item downloads.
export const ShareDocumentDownloadButton = ({
title,
documentData,
}: ShareDocumentDownloadButtonProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const [isDownloading, setIsDownloading] = useState(false);
const onDownloadClick = async () => {
try {
setIsDownloading(true);
await downloadPDF({ documentData, fileName: title });
} catch (err) {
toast({
title: _(msg`Something went wrong`),
description: _(msg`An error occurred while downloading your document.`),
variant: 'destructive',
});
} finally {
setIsDownloading(false);
}
};
return (
<Button loading={isDownloading} onClick={onDownloadClick}>
{!isDownloading && <Download className="mr-2 h-4 w-4" />}
<Trans>Download</Trans>
</Button>
);
};

View File

@ -0,0 +1,167 @@
import { type ReactNode, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { ErrorCode, type FileRejection, useDropzone } from 'react-dropzone';
import { useNavigate, useParams } from 'react-router';
import { match } from 'ts-pattern';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
export interface TemplateDropZoneWrapperProps {
children: ReactNode;
className?: string;
}
export const TemplateDropZoneWrapper = ({ children, className }: TemplateDropZoneWrapperProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { folderId } = useParams();
const team = useCurrentTeam();
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation();
const onFileDrop = async (file: File) => {
try {
setIsLoading(true);
const documentData = await putPdfFile(file);
const { legacyTemplateId: id } = await createTemplate({
title: file.name,
templateDocumentDataId: documentData.id,
folderId: folderId ?? undefined,
});
toast({
title: _(msg`Template uploaded`),
description: _(
msg`Your template has been uploaded successfully. You will be redirected to the template page.`,
),
duration: 5000,
});
await navigate(`${formatTemplatesPath(team.url)}/${id}/edit`);
} catch {
toast({
title: _(msg`Something went wrong`),
description: _(msg`Please try again later.`),
variant: 'destructive',
});
} finally {
setIsLoading(false);
}
};
const onFileDropRejected = (fileRejections: FileRejection[]) => {
if (!fileRejections.length) {
return;
}
// Since users can only upload only one file (no multi-upload), we only handle the first file rejection
const { file, errors } = fileRejections[0];
if (!errors.length) {
return;
}
const errorNodes = errors.map((error, index) => (
<span key={index} className="block">
{match(error.code)
.with(ErrorCode.FileTooLarge, () => (
<Trans>File is larger than {APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB</Trans>
))
.with(ErrorCode.FileInvalidType, () => <Trans>Only PDF files are allowed</Trans>)
.with(ErrorCode.FileTooSmall, () => <Trans>File is too small</Trans>)
.with(ErrorCode.TooManyFiles, () => (
<Trans>Only one file can be uploaded at a time</Trans>
))
.otherwise(() => (
<Trans>Unknown error</Trans>
))}
</span>
));
const description = (
<>
<span className="font-medium">
{file.name} <Trans>couldn't be uploaded:</Trans>
</span>
{errorNodes}
</>
);
toast({
title: _(msg`Upload failed`),
description,
duration: 5000,
variant: 'destructive',
});
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({
accept: {
'application/pdf': ['.pdf'],
},
//disabled: isUploadDisabled,
multiple: false,
maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT),
onDrop: ([acceptedFile]) => {
if (acceptedFile) {
void onFileDrop(acceptedFile);
}
},
onDropRejected: (fileRejections) => {
onFileDropRejected(fileRejections);
},
noClick: true,
noDragEventsBubbling: true,
});
return (
<div {...getRootProps()} className={cn('relative min-h-screen', className)}>
<input {...getInputProps()} />
{children}
{isDragActive && (
<div className="bg-muted/60 fixed left-0 top-0 z-[9999] h-full w-full backdrop-blur-[4px]">
<div className="pointer-events-none flex h-full w-full flex-col items-center justify-center">
<h2 className="text-foreground text-2xl font-semibold">
<Trans>Upload Template</Trans>
</h2>
<p className="text-muted-foreground text-md mt-4">
<Trans>Drag and drop your PDF file here</Trans>
</p>
</div>
</div>
)}
{isLoading && (
<div className="bg-muted/30 absolute inset-0 z-50 backdrop-blur-[2px]">
<div className="pointer-events-none flex h-1/2 w-full flex-col items-center justify-center">
<Loader className="text-primary h-12 w-12 animate-spin" />
<p className="text-foreground mt-8 font-medium">
<Trans>Uploading template...</Trans>
</p>
</div>
</div>
)}
</div>
);
};

View File

@ -313,10 +313,8 @@ export const TemplateEditForm = ({
> >
<CardContent className="p-2"> <CardContent className="p-2">
<PDFViewer <PDFViewer
key={template.envelopeItems[0].id} key={templateDocumentData.id}
envelopeItem={template.envelopeItems[0]} documentData={templateDocumentData}
token={undefined}
version="signed"
onDocumentLoad={() => setIsDocumentPdfLoaded(true)} onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
/> />
</CardContent> </CardContent>

View File

@ -7,13 +7,11 @@ import type { User } from '@prisma/client';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted'; import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
export type TemplatePageViewInformationProps = { export type TemplatePageViewInformationProps = {
userId: number; userId: number;
template: { template: {
userId: number; userId: number;
secondaryId: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
user: Pick<User, 'id' | 'name' | 'email'>; user: Pick<User, 'id' | 'name' | 'email'>;
@ -45,10 +43,6 @@ export const TemplatePageViewInformation = ({
.setLocale(i18n.locales?.[0] || i18n.locale) .setLocale(i18n.locales?.[0] || i18n.locale)
.toRelative(), .toRelative(),
}, },
{
description: msg`Template ID (Legacy)`,
value: mapSecondaryIdToTemplateId(template.secondaryId),
},
]; ];
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMounted, template, userId]); }, [isMounted, template, userId]);

View File

@ -2,49 +2,40 @@ import { useEffect, useMemo, useState, useTransition } from 'react';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { ChevronDownIcon, ChevronUpIcon, ChevronsUpDown, Loader } from 'lucide-react'; import { ChevronDownIcon, ChevronUpIcon, ChevronsUpDown, Loader } from 'lucide-react';
import { Link } from 'react-router';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import type { DateRange } from '@documenso/lib/types/search-params';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table'; import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table'; import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
export type OrganisationOverview = { export type SigningVolume = {
id: string; id: number;
name: string; name: string;
signingVolume: number; signingVolume: number;
createdAt: Date; createdAt: Date;
customerId: string; planId: string;
subscriptionStatus?: string;
isActive?: boolean;
teamCount?: number;
memberCount?: number;
}; };
type OrganisationOverviewTableProps = { type LeaderboardTableProps = {
organisations: OrganisationOverview[]; signingVolume: SigningVolume[];
totalPages: number; totalPages: number;
perPage: number; perPage: number;
page: number; page: number;
sortBy: 'name' | 'createdAt' | 'signingVolume'; sortBy: 'name' | 'createdAt' | 'signingVolume';
sortOrder: 'asc' | 'desc'; sortOrder: 'asc' | 'desc';
dateRange: DateRange;
}; };
export const AdminOrganisationOverviewTable = ({ export const AdminLeaderboardTable = ({
organisations, signingVolume,
totalPages, totalPages,
perPage, perPage,
page, page,
sortBy, sortBy,
sortOrder, sortOrder,
dateRange, }: LeaderboardTableProps) => {
}: OrganisationOverviewTableProps) => {
const { _, i18n } = useLingui(); const { _, i18n } = useLingui();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
@ -76,16 +67,17 @@ export const AdminOrganisationOverviewTable = ({
cell: ({ row }) => { cell: ({ row }) => {
return ( return (
<div> <div>
<Link <a
className="hover:underline" className="text-primary underline"
to={`/admin/organisation-insights/${row.original.id}?dateRange=${dateRange}`} href={`https://dashboard.stripe.com/subscriptions/${row.original.planId}`}
target="_blank"
> >
{row.getValue('name')} {row.getValue('name')}
</Link> </a>
</div> </div>
); );
}, },
size: 240, size: 250,
}, },
{ {
header: () => ( header: () => (
@ -93,9 +85,7 @@ export const AdminOrganisationOverviewTable = ({
className="flex cursor-pointer items-center" className="flex cursor-pointer items-center"
onClick={() => handleColumnSort('signingVolume')} onClick={() => handleColumnSort('signingVolume')}
> >
<span className="whitespace-nowrap"> {_(msg`Signing Volume`)}
<Trans>Document Volume</Trans>
</span>
{sortBy === 'signingVolume' ? ( {sortBy === 'signingVolume' ? (
sortOrder === 'asc' ? ( sortOrder === 'asc' ? (
<ChevronUpIcon className="ml-2 h-4 w-4" /> <ChevronUpIcon className="ml-2 h-4 w-4" />
@ -109,23 +99,6 @@ export const AdminOrganisationOverviewTable = ({
), ),
accessorKey: 'signingVolume', accessorKey: 'signingVolume',
cell: ({ row }) => <div>{Number(row.getValue('signingVolume'))}</div>, cell: ({ row }) => <div>{Number(row.getValue('signingVolume'))}</div>,
size: 160,
},
{
header: () => {
return <Trans>Teams</Trans>;
},
accessorKey: 'teamCount',
cell: ({ row }) => <div>{Number(row.original.teamCount) || 0}</div>,
size: 120,
},
{
header: () => {
return <Trans>Members</Trans>;
},
accessorKey: 'memberCount',
cell: ({ row }) => <div>{Number(row.original.memberCount) || 0}</div>,
size: 160,
}, },
{ {
header: () => { header: () => {
@ -134,9 +107,7 @@ export const AdminOrganisationOverviewTable = ({
className="flex cursor-pointer items-center" className="flex cursor-pointer items-center"
onClick={() => handleColumnSort('createdAt')} onClick={() => handleColumnSort('createdAt')}
> >
<span className="whitespace-nowrap"> {_(msg`Created`)}
<Trans>Created</Trans>
</span>
{sortBy === 'createdAt' ? ( {sortBy === 'createdAt' ? (
sortOrder === 'asc' ? ( sortOrder === 'asc' ? (
<ChevronUpIcon className="ml-2 h-4 w-4" /> <ChevronUpIcon className="ml-2 h-4 w-4" />
@ -150,11 +121,10 @@ export const AdminOrganisationOverviewTable = ({
); );
}, },
accessorKey: 'createdAt', accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(new Date(row.original.createdAt)), cell: ({ row }) => i18n.date(row.original.createdAt),
size: 120,
}, },
] satisfies DataTableColumnDef<OrganisationOverview>[]; ] satisfies DataTableColumnDef<SigningVolume>[];
}, [sortOrder, sortBy, dateRange]); }, [sortOrder, sortBy]);
useEffect(() => { useEffect(() => {
startTransition(() => { startTransition(() => {
@ -199,13 +169,13 @@ export const AdminOrganisationOverviewTable = ({
<Input <Input
className="my-6 flex flex-row gap-4" className="my-6 flex flex-row gap-4"
type="text" type="text"
placeholder={_(msg`Search by organisation name`)} placeholder={_(msg`Search by name or email`)}
value={searchString} value={searchString}
onChange={handleChange} onChange={handleChange}
/> />
<DataTable <DataTable
columns={columns} columns={columns}
data={organisations} data={signingVolume}
perPage={perPage} perPage={perPage}
currentPage={page} currentPage={page}
totalPages={totalPages} totalPages={totalPages}

View File

@ -93,31 +93,13 @@ export const AdminOrganisationsTable = ({
), ),
}, },
{ {
id: 'role', header: t`Status`,
header: t`Role`,
cell: ({ row }) => ( cell: ({ row }) => (
<Badge variant="neutral"> <Badge variant="neutral">
{row.original.owner.id === memberUserId ? t`Owner` : t`Member`} {row.original.owner.id === memberUserId ? t`Owner` : t`Member`}
</Badge> </Badge>
), ),
}, },
{
id: 'billingStatus',
header: t`Status`,
cell: ({ row }) => {
const subscription = row.original.subscription;
const isPaid = subscription && subscription.status === 'ACTIVE';
return (
<div
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
isPaid ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
}`}
>
{isPaid ? t`Paid` : t`Free`}
</div>
);
},
},
{ {
header: t`Subscription`, header: t`Subscription`,
cell: ({ row }) => cell: ({ row }) =>
@ -186,7 +168,7 @@ export const AdminOrganisationsTable = ({
onPaginationChange={onPaginationChange} onPaginationChange={onPaginationChange}
columnVisibility={{ columnVisibility={{
owner: showOwnerColumn, owner: showOwnerColumn,
role: memberUserId !== undefined, status: memberUserId !== undefined,
}} }}
error={{ error={{
enable: isLoadingError, enable: isLoadingError,

View File

@ -1,25 +1,30 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client'; import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
import { CheckCircle, Download, Edit, EyeIcon, Pencil } from 'lucide-react'; import { CheckCircle, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/document'; import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/document';
import { isDocumentCompleted } from '@documenso/lib/utils/document'; import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team'; import { useCurrentTeam } from '~/providers/team';
import { EnvelopeDownloadDialog } from '../dialogs/envelope-download-dialog';
export type DocumentsTableActionButtonProps = { export type DocumentsTableActionButtonProps = {
row: TDocumentRow; row: TDocumentRow;
}; };
export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonProps) => { export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonProps) => {
const { user } = useSession(); const { user } = useSession();
const { toast } = useToast();
const { _ } = useLingui();
const team = useCurrentTeam(); const team = useCurrentTeam();
@ -37,6 +42,39 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
const documentsPath = formatDocumentsPath(team.url); const documentsPath = formatDocumentsPath(team.url);
const formatPath = `${documentsPath}/${row.envelopeId}/edit`; const formatPath = `${documentsPath}/${row.envelopeId}/edit`;
const onDownloadClick = async () => {
try {
const document = !recipient
? await trpcClient.document.get.query(
{
documentId: row.id,
},
{
context: {
teamId: team?.id?.toString(),
},
},
)
: await trpcClient.document.getDocumentByToken.query({
token: recipient.token,
});
const documentData = document?.documentData;
if (!documentData) {
throw Error('No document available');
}
await downloadPDF({ documentData, fileName: row.title });
} catch (err) {
toast({
title: _(msg`Something went wrong`),
description: _(msg`An error occurred while downloading your document.`),
variant: 'destructive',
});
}
};
// TODO: Consider if want to keep this logic for hiding viewing for CC'ers // TODO: Consider if want to keep this logic for hiding viewing for CC'ers
if (recipient?.role === RecipientRole.CC && isComplete === false) { if (recipient?.role === RecipientRole.CC && isComplete === false) {
return null; return null;
@ -50,7 +88,6 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
isComplete, isComplete,
isSigned, isSigned,
isCurrentTeamDocument, isCurrentTeamDocument,
internalVersion: row.internalVersion,
}) })
.with( .with(
isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true }, isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true },
@ -95,17 +132,10 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
</Button> </Button>
)) ))
.with({ isComplete: true }, () => ( .with({ isComplete: true }, () => (
<EnvelopeDownloadDialog <Button className="w-32" onClick={onDownloadClick}>
envelopeId={row.envelopeId} <Download className="-ml-1 mr-2 inline h-4 w-4" />
envelopeStatus={row.status} <Trans>Download</Trans>
token={recipient?.token} </Button>
trigger={
<Button className="w-32">
<Download className="-ml-1 mr-2 inline h-4 w-4" />
<Trans>Download</Trans>
</Button>
}
/>
)) ))
.otherwise(() => <div></div>); .otherwise(() => <div></div>);
}; };

View File

@ -10,6 +10,7 @@ import {
Download, Download,
Edit, Edit,
EyeIcon, EyeIcon,
FileDown,
FolderInput, FolderInput,
Loader, Loader,
MoreHorizontal, MoreHorizontal,
@ -19,10 +20,12 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/document'; import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/document';
import { isDocumentCompleted } from '@documenso/lib/utils/document'; import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import { import {
DropdownMenu, DropdownMenu,
@ -31,6 +34,7 @@ import {
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu'; } from '@documenso/ui/primitives/dropdown-menu';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialog'; import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialog';
import { DocumentDuplicateDialog } from '~/components/dialogs/document-duplicate-dialog'; import { DocumentDuplicateDialog } from '~/components/dialogs/document-duplicate-dialog';
@ -38,8 +42,6 @@ import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialo
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog'; import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
import { useCurrentTeam } from '~/providers/team'; import { useCurrentTeam } from '~/providers/team';
import { EnvelopeDownloadDialog } from '../dialogs/envelope-download-dialog';
export type DocumentsTableActionDropdownProps = { export type DocumentsTableActionDropdownProps = {
row: TDocumentRow; row: TDocumentRow;
onMoveDocument?: () => void; onMoveDocument?: () => void;
@ -52,6 +54,7 @@ export const DocumentsTableActionDropdown = ({
const { user } = useSession(); const { user } = useSession();
const team = useCurrentTeam(); const team = useCurrentTeam();
const { toast } = useToast();
const { _ } = useLingui(); const { _ } = useLingui();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false); const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
@ -71,6 +74,58 @@ export const DocumentsTableActionDropdown = ({
const documentsPath = formatDocumentsPath(team.url); const documentsPath = formatDocumentsPath(team.url);
const formatPath = `${documentsPath}/${row.envelopeId}/edit`; const formatPath = `${documentsPath}/${row.envelopeId}/edit`;
const onDownloadClick = async () => {
try {
const document = !recipient
? await trpcClient.document.get.query({
documentId: row.id,
})
: await trpcClient.document.getDocumentByToken.query({
token: recipient.token,
});
const documentData = document?.documentData;
if (!documentData) {
return;
}
await downloadPDF({ documentData, fileName: row.title });
} catch (err) {
toast({
title: _(msg`Something went wrong`),
description: _(msg`An error occurred while downloading your document.`),
variant: 'destructive',
});
}
};
const onDownloadOriginalClick = async () => {
try {
const document = !recipient
? await trpcClient.document.get.query({
documentId: row.id,
})
: await trpcClient.document.getDocumentByToken.query({
token: recipient.token,
});
const documentData = document?.documentData;
if (!documentData) {
return;
}
await downloadPDF({ documentData, fileName: row.title, version: 'original' });
} catch (err) {
toast({
title: _(msg`Something went wrong`),
description: _(msg`An error occurred while downloading your document.`),
variant: 'destructive',
});
}
};
const nonSignedRecipients = row.recipients.filter((item) => item.signingStatus !== 'SIGNED'); const nonSignedRecipients = row.recipients.filter((item) => item.signingStatus !== 'SIGNED');
return ( return (
@ -121,19 +176,15 @@ export const DocumentsTableActionDropdown = ({
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<EnvelopeDownloadDialog <DropdownMenuItem disabled={!isComplete} onClick={onDownloadClick}>
envelopeId={row.envelopeId} <Download className="mr-2 h-4 w-4" />
envelopeStatus={row.status} <Trans>Download</Trans>
token={recipient?.token} </DropdownMenuItem>
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}> <DropdownMenuItem onClick={onDownloadOriginalClick}>
<div> <FileDown className="mr-2 h-4 w-4" />
<Download className="mr-2 h-4 w-4" /> <Trans>Download Original</Trans>
<Trans>Download</Trans> </DropdownMenuItem>
</div>
</DropdownMenuItem>
}
/>
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}> <DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
<Copy className="mr-2 h-4 w-4" /> <Copy className="mr-2 h-4 w-4" />
@ -202,8 +253,7 @@ export const DocumentsTableActionDropdown = ({
/> />
<DocumentDuplicateDialog <DocumentDuplicateDialog
id={row.envelopeId} id={row.id}
token={recipient?.token}
open={isDuplicateDialogOpen} open={isDuplicateDialogOpen}
onOpenChange={setDuplicateDialogOpen} onOpenChange={setDuplicateDialogOpen}
/> />

View File

@ -10,9 +10,11 @@ import { DateTime } from 'luxon';
import { Link, useSearchParams } from 'react-router'; import { Link, useSearchParams } from 'react-router';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import { isDocumentCompleted } from '@documenso/lib/utils/document'; import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { TFindInboxResponse } from '@documenso/trpc/server/document-router/find-inbox.types'; import type { TFindInboxResponse } from '@documenso/trpc/server/document-router/find-inbox.types';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@ -26,7 +28,6 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { DocumentStatus } from '~/components/general/document/document-status'; import { DocumentStatus } from '~/components/general/document/document-status';
import { useOptionalCurrentTeam } from '~/providers/team'; import { useOptionalCurrentTeam } from '~/providers/team';
import { EnvelopeDownloadDialog } from '../dialogs/envelope-download-dialog';
import { StackAvatarsWithTooltip } from '../general/stack-avatars-with-tooltip'; import { StackAvatarsWithTooltip } from '../general/stack-avatars-with-tooltip';
export type DocumentsTableProps = { export type DocumentsTableProps = {
@ -198,6 +199,28 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
return null; return null;
} }
const onDownloadClick = async () => {
try {
const document = await trpcClient.document.getDocumentByToken.query({
token: recipient.token,
});
const documentData = document?.documentData;
if (!documentData) {
throw Error('No document available');
}
await downloadPDF({ documentData, fileName: row.title });
} catch (err) {
toast({
title: _(msg`Something went wrong`),
description: _(msg`An error occurred while downloading your document.`),
variant: 'destructive',
});
}
};
// TODO: Consider if want to keep this logic for hiding viewing for CC'ers // TODO: Consider if want to keep this logic for hiding viewing for CC'ers
if (recipient?.role === RecipientRole.CC && isComplete === false) { if (recipient?.role === RecipientRole.CC && isComplete === false) {
return null; return null;
@ -207,7 +230,6 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
isPending, isPending,
isComplete, isComplete,
isSigned, isSigned,
internalVersion: row.internalVersion,
}) })
.with({ isPending: true, isSigned: false }, () => ( .with({ isPending: true, isSigned: false }, () => (
<Button className="w-32" asChild> <Button className="w-32" asChild>
@ -241,17 +263,10 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
</Button> </Button>
)) ))
.with({ isComplete: true }, () => ( .with({ isComplete: true }, () => (
<EnvelopeDownloadDialog <Button className="w-32" onClick={onDownloadClick}>
envelopeId={row.envelopeId} <DownloadIcon className="-ml-1 mr-2 inline h-4 w-4" />
envelopeStatus={row.status} <Trans>Download</Trans>
token={recipient?.token} </Button>
trigger={
<Button className="w-32">
<DownloadIcon className="-ml-1 mr-2 inline h-4 w-4" />
<Trans>Download</Trans>
</Button>
}
/>
)) ))
.otherwise(() => <div></div>); .otherwise(() => <div></div>);
}; };

View File

@ -1,287 +0,0 @@
import { useTransition } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Building2, Loader, TrendingUp, Users } from 'lucide-react';
import { Link } from 'react-router';
import { useNavigation } from 'react-router';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import type { OrganisationDetailedInsights } from '@documenso/lib/server-only/admin/get-organisation-detailed-insights';
import type { DateRange } from '@documenso/lib/types/search-params';
import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { Button } from '@documenso/ui/primitives/button';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { DateRangeFilter } from '~/components/filters/date-range-filter';
import { DocumentStatus } from '~/components/general/document/document-status';
type OrganisationInsightsTableProps = {
insights: OrganisationDetailedInsights;
page: number;
perPage: number;
dateRange: DateRange;
view: 'teams' | 'users' | 'documents';
};
export const OrganisationInsightsTable = ({
insights,
page,
perPage,
dateRange,
view,
}: OrganisationInsightsTableProps) => {
const { _, i18n } = useLingui();
const [isPending, startTransition] = useTransition();
const navigation = useNavigation();
const updateSearchParams = useUpdateSearchParams();
const isLoading = isPending || navigation.state === 'loading';
const onPaginationChange = (newPage: number, newPerPage: number) => {
startTransition(() => {
updateSearchParams({
page: newPage,
perPage: newPerPage,
});
});
};
const handleViewChange = (newView: 'teams' | 'users' | 'documents') => {
startTransition(() => {
updateSearchParams({
view: newView,
page: 1,
});
});
};
const teamsColumns = [
{
header: _(msg`Team Name`),
accessorKey: 'name',
cell: ({ row }) => <span className="block max-w-full truncate">{row.getValue('name')}</span>,
size: 240,
},
{
header: _(msg`Members`),
accessorKey: 'memberCount',
cell: ({ row }) => Number(row.getValue('memberCount')),
size: 120,
},
{
header: _(msg`Documents`),
accessorKey: 'documentCount',
cell: ({ row }) => Number(row.getValue('documentCount')),
size: 140,
},
{
header: _(msg`Created`),
accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(new Date(row.getValue('createdAt'))),
size: 160,
},
] satisfies DataTableColumnDef<(typeof insights.teams)[number]>[];
const usersColumns = [
{
header: () => <span className="whitespace-nowrap">{_(msg`Name`)}</span>,
accessorKey: 'name',
cell: ({ row }) => (
<Link
className="block max-w-full truncate hover:underline"
to={`/admin/users/${row.original.id}`}
>
{(row.getValue('name') as string) || (row.getValue('email') as string)}
</Link>
),
size: 220,
},
{
header: () => <span className="whitespace-nowrap">{_(msg`Email`)}</span>,
accessorKey: 'email',
cell: ({ row }) => <span className="block max-w-full truncate">{row.getValue('email')}</span>,
size: 260,
},
{
header: () => <span className="whitespace-nowrap">{_(msg`Documents Created`)}</span>,
accessorKey: 'documentCount',
cell: ({ row }) => Number(row.getValue('documentCount')),
size: 180,
},
{
header: () => <span className="whitespace-nowrap">{_(msg`Documents Completed`)}</span>,
accessorKey: 'signedDocumentCount',
cell: ({ row }) => Number(row.getValue('signedDocumentCount')),
size: 180,
},
{
header: () => <span className="whitespace-nowrap">{_(msg`Joined`)}</span>,
accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(new Date(row.getValue('createdAt'))),
size: 160,
},
] satisfies DataTableColumnDef<(typeof insights.users)[number]>[];
const documentsColumns = [
{
header: () => <span className="whitespace-nowrap">{_(msg`Title`)}</span>,
accessorKey: 'title',
cell: ({ row }) => (
<Link
className="block max-w-[200px] truncate hover:underline"
to={`/admin/documents/${row.original.id}`}
title={row.getValue('title') as string}
>
{row.getValue('title')}
</Link>
),
size: 200,
},
{
header: () => <span className="whitespace-nowrap">{_(msg`Status`)}</span>,
accessorKey: 'status',
cell: ({ row }) => (
<DocumentStatus status={row.getValue('status') as ExtendedDocumentStatus} />
),
size: 120,
},
{
header: () => <span className="whitespace-nowrap">{_(msg`Team`)}</span>,
accessorKey: 'teamName',
cell: ({ row }) => (
<span className="block max-w-[150px] truncate" title={row.getValue('teamName') as string}>
{row.getValue('teamName')}
</span>
),
size: 150,
},
{
header: () => <span className="whitespace-nowrap">{_(msg`Created`)}</span>,
accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(new Date(row.getValue('createdAt'))),
size: 140,
},
{
header: () => <span className="whitespace-nowrap">{_(msg`Completed`)}</span>,
accessorKey: 'completedAt',
cell: ({ row }) => {
const completedAt = row.getValue('completedAt') as Date | null;
return completedAt ? i18n.date(new Date(completedAt)) : '-';
},
size: 140,
},
] satisfies DataTableColumnDef<(typeof insights.documents)[number]>[];
const getCurrentData = (): unknown[] => {
switch (view) {
case 'teams':
return insights.teams;
case 'users':
return insights.users;
case 'documents':
return insights.documents;
default:
return [];
}
};
const getCurrentColumns = (): DataTableColumnDef<unknown>[] => {
switch (view) {
case 'teams':
return teamsColumns as unknown as DataTableColumnDef<unknown>[];
case 'users':
return usersColumns as unknown as DataTableColumnDef<unknown>[];
case 'documents':
return documentsColumns as unknown as DataTableColumnDef<unknown>[];
default:
return [];
}
};
return (
<div className="relative">
{insights.summary && (
<div className="mb-6 grid grid-cols-2 gap-4 md:grid-cols-3">
<SummaryCard icon={Building2} title={_(msg`Teams`)} value={insights.summary.totalTeams} />
<SummaryCard icon={Users} title={_(msg`Members`)} value={insights.summary.totalMembers} />
<SummaryCard
icon={TrendingUp}
title={_(msg`Documents Completed`)}
value={insights.summary.volumeThisPeriod}
/>
</div>
)}
<div className="mb-6 flex items-center justify-between">
<div className="flex gap-2">
<Button
variant={view === 'teams' ? 'default' : 'outline'}
onClick={() => handleViewChange('teams')}
disabled={isLoading}
>
{_(msg`Teams`)}
</Button>
<Button
variant={view === 'users' ? 'default' : 'outline'}
onClick={() => handleViewChange('users')}
disabled={isLoading}
>
{_(msg`Users`)}
</Button>
<Button
variant={view === 'documents' ? 'default' : 'outline'}
onClick={() => handleViewChange('documents')}
disabled={isLoading}
>
{_(msg`Documents`)}
</Button>
</div>
<DateRangeFilter currentRange={dateRange} />
</div>
<div className={view === 'documents' ? 'overflow-hidden' : undefined}>
<DataTable<unknown, unknown>
columns={getCurrentColumns()}
data={getCurrentData()}
perPage={perPage}
currentPage={page}
totalPages={insights.totalPages}
onPaginationChange={onPaginationChange}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>
</div>
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
<Loader className="h-8 w-8 animate-spin text-gray-500" />
</div>
)}
</div>
);
};
const SummaryCard = ({
icon: Icon,
title,
value,
subtitle,
}: {
icon: React.ComponentType<{ className?: string }>;
title: string;
value: number;
subtitle?: string;
}) => (
<div className="bg-card flex items-start gap-x-2 rounded-lg border px-4 py-3">
<Icon className="text-muted-foreground h-4 w-4 items-start" />
<div className="-mt-0.5 space-y-2">
<p className="text-muted-foreground text-sm font-medium">{title}</p>
<p className="text-2xl font-bold">{value}</p>
{subtitle && <p className="text-muted-foreground text-xs">{subtitle}</p>}
</div>
</div>
);

View File

@ -159,7 +159,6 @@ export const TemplatesTable = ({
return ( return (
<div className="flex items-center gap-x-4"> <div className="flex items-center gap-x-4">
<TemplateUseDialog <TemplateUseDialog
envelopeId={row.original.envelopeId}
templateId={row.original.id} templateId={row.original.id}
templateSigningOrder={row.original.templateMeta?.signingOrder} templateSigningOrder={row.original.templateMeta?.signingOrder}
documentDistributionMethod={row.original.templateMeta?.distributionMethod} documentDistributionMethod={row.original.templateMeta?.distributionMethod}

View File

@ -56,14 +56,7 @@ export const UserOrganisationsTable = () => {
avatarFallback={row.original.name.slice(0, 1).toUpperCase()} avatarFallback={row.original.name.slice(0, 1).toUpperCase()}
primaryText={ primaryText={
<span className="text-foreground/80 font-semibold"> <span className="text-foreground/80 font-semibold">
{isPersonalLayoutMode {isPersonalLayoutMode ? _(msg`Personal`) : row.original.name}
? _(
msg({
message: `Personal`,
context: `Personal organisation (adjective)`,
}),
)
: row.original.name}
</span> </span>
} }
secondaryText={ secondaryText={

View File

@ -88,12 +88,14 @@ export default function Layout({ loaderData, params, matches }: Route.ComponentP
? { ? {
heading: msg`Organisation not found`, heading: msg`Organisation not found`,
subHeading: msg`404 Organisation not found`, subHeading: msg`404 Organisation not found`,
message: msg`The organisation you are looking for may have been removed, renamed or may have never existed.`, message: msg`The organisation you are looking for may have been removed, renamed or may have never
existed.`,
} }
: { : {
heading: msg`Team not found`, heading: msg`Team not found`,
subHeading: msg`404 Team not found`, subHeading: msg`404 Team not found`,
message: msg`The team you are looking for may have been removed, renamed or may have never existed.`, message: msg`The team you are looking for may have been removed, renamed or may have never
existed.`,
}, },
}} }}
primaryButton={ primaryButton={
@ -114,7 +116,7 @@ export default function Layout({ loaderData, params, matches }: Route.ComponentP
{!user.emailVerified && <VerifyEmailBanner email={user.email} />} {!user.emailVerified && <VerifyEmailBanner email={user.email} />}
{banner && !hideHeader && <AppBanner banner={banner} />} {banner && <AppBanner banner={banner} />}
{!hideHeader && <Header />} {!hideHeader && <Header />}

View File

@ -114,13 +114,13 @@ export default function AdminLayout() {
variant="ghost" variant="ghost"
className={cn( className={cn(
'justify-start md:w-full', 'justify-start md:w-full',
pathname?.startsWith('/admin/organisation-insights') && 'bg-secondary', pathname?.startsWith('/admin/leaderboard') && 'bg-secondary',
)} )}
asChild asChild
> >
<Link to="/admin/organisation-insights"> <Link to="/admin/leaderboard">
<Trophy className="mr-2 h-5 w-5" /> <Trophy className="mr-2 h-5 w-5" />
<Trans>Organisation Insights</Trans> <Trans>Leaderboard</Trans>
</Link> </Link>
</Button> </Button>
@ -128,7 +128,7 @@ export default function AdminLayout() {
variant="ghost" variant="ghost"
className={cn( className={cn(
'justify-start md:w-full', 'justify-start md:w-full',
pathname?.startsWith('/admin/site-settings') && 'bg-secondary', pathname?.startsWith('/admin/banner') && 'bg-secondary',
)} )}
asChild asChild
> >

View File

@ -0,0 +1,77 @@
import { Trans } from '@lingui/react/macro';
import { getSigningVolume } from '@documenso/lib/server-only/admin/get-signing-volume';
import {
AdminLeaderboardTable,
type SigningVolume,
} from '~/components/tables/admin-leaderboard-table';
import type { Route } from './+types/leaderboard';
export async function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url);
const rawSortBy = url.searchParams.get('sortBy') || 'signingVolume';
const rawSortOrder = url.searchParams.get('sortOrder') || 'desc';
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const sortOrder = (['asc', 'desc'].includes(rawSortOrder) ? rawSortOrder : 'desc') as
| 'asc'
| 'desc';
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const sortBy = (
['name', 'createdAt', 'signingVolume'].includes(rawSortBy) ? rawSortBy : 'signingVolume'
) as 'name' | 'createdAt' | 'signingVolume';
const page = Number(url.searchParams.get('page')) || 1;
const perPage = Number(url.searchParams.get('perPage')) || 10;
const search = url.searchParams.get('search') || '';
const { leaderboard, totalPages } = await getSigningVolume({
search,
page,
perPage,
sortBy,
sortOrder,
});
const typedSigningVolume: SigningVolume[] = leaderboard.map((item) => ({
...item,
name: item.name || '',
createdAt: item.createdAt || new Date(),
}));
return {
signingVolume: typedSigningVolume,
totalPages,
page,
perPage,
sortBy,
sortOrder,
};
}
export default function Leaderboard({ loaderData }: Route.ComponentProps) {
const { signingVolume, totalPages, page, perPage, sortBy, sortOrder } = loaderData;
return (
<div>
<div className="flex items-center">
<h2 className="text-4xl font-semibold">
<Trans>Signing Volume</Trans>
</h2>
</div>
<div className="mt-8">
<AdminLeaderboardTable
signingVolume={signingVolume}
totalPages={totalPages}
page={page}
perPage={perPage}
sortBy={sortBy}
sortOrder={sortOrder}
/>
</div>
</div>
);
}

View File

@ -1,59 +0,0 @@
import { getOrganisationDetailedInsights } from '@documenso/lib/server-only/admin/get-organisation-detailed-insights';
import type { DateRange } from '@documenso/lib/types/search-params';
import { getAdminOrganisation } from '@documenso/trpc/server/admin-router/get-admin-organisation';
import { OrganisationInsightsTable } from '~/components/tables/organisation-insights-table';
import type { Route } from './+types/organisation-insights.$id';
export async function loader({ params, request }: Route.LoaderArgs) {
const { id } = params;
const url = new URL(request.url);
const page = Number(url.searchParams.get('page')) || 1;
const perPage = Number(url.searchParams.get('perPage')) || 10;
const dateRange = (url.searchParams.get('dateRange') || 'last30days') as DateRange;
const view = (url.searchParams.get('view') || 'teams') as 'teams' | 'users' | 'documents';
const [insights, organisation] = await Promise.all([
getOrganisationDetailedInsights({
organisationId: id,
page,
perPage,
dateRange,
view,
}),
getAdminOrganisation({ organisationId: id }),
]);
return {
organisationId: id,
organisationName: organisation.name,
insights,
page,
perPage,
dateRange,
view,
};
}
export default function OrganisationInsights({ loaderData }: Route.ComponentProps) {
const { insights, page, perPage, dateRange, view, organisationName } = loaderData;
return (
<div>
<div className="flex items-center justify-between">
<h2 className="text-4xl font-semibold">{organisationName}</h2>
</div>
<div className="mt-8">
<OrganisationInsightsTable
insights={insights}
page={page}
perPage={perPage}
dateRange={dateRange}
view={view}
/>
</div>
</div>
);
}

View File

@ -1,91 +0,0 @@
import { Trans } from '@lingui/react/macro';
import { getOrganisationInsights } from '@documenso/lib/server-only/admin/get-signing-volume';
import type { DateRange } from '@documenso/lib/types/search-params';
import { DateRangeFilter } from '~/components/filters/date-range-filter';
import {
AdminOrganisationOverviewTable,
type OrganisationOverview,
} from '~/components/tables/admin-organisation-overview-table';
import type { Route } from './+types/organisation-insights._index';
export async function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url);
const rawSortBy = url.searchParams.get('sortBy') || 'signingVolume';
const rawSortOrder = url.searchParams.get('sortOrder') || 'desc';
const isSortOrder = (value: string): value is 'asc' | 'desc' =>
value === 'asc' || value === 'desc';
const isSortBy = (value: string): value is 'name' | 'createdAt' | 'signingVolume' =>
value === 'name' || value === 'createdAt' || value === 'signingVolume';
const sortOrder: 'asc' | 'desc' = isSortOrder(rawSortOrder) ? rawSortOrder : 'desc';
const sortBy: 'name' | 'createdAt' | 'signingVolume' = isSortBy(rawSortBy)
? rawSortBy
: 'signingVolume';
const page = Number(url.searchParams.get('page')) || 1;
const perPage = Number(url.searchParams.get('perPage')) || 10;
const search = url.searchParams.get('search') || '';
const dateRange = (url.searchParams.get('dateRange') || 'last30days') as DateRange;
const { organisations, totalPages } = await getOrganisationInsights({
search,
page,
perPage,
sortBy,
sortOrder,
dateRange,
});
const typedOrganisations: OrganisationOverview[] = organisations.map((item) => ({
id: String(item.id),
name: item.name || '',
signingVolume: item.signingVolume,
createdAt: item.createdAt || new Date(),
customerId: item.customerId || '',
subscriptionStatus: item.subscriptionStatus,
teamCount: item.teamCount || 0,
memberCount: item.memberCount || 0,
}));
return {
organisations: typedOrganisations,
totalPages,
page,
perPage,
sortBy,
sortOrder,
dateRange,
};
}
export default function Organisations({ loaderData }: Route.ComponentProps) {
const { organisations, totalPages, page, perPage, sortBy, sortOrder, dateRange } = loaderData;
return (
<div>
<div className="flex items-center justify-between">
<h2 className="text-4xl font-semibold">
<Trans>Organisation Insights</Trans>
</h2>
<DateRangeFilter currentRange={dateRange} />
</div>
<div className="mt-8">
<AdminOrganisationOverviewTable
organisations={organisations}
totalPages={totalPages}
page={page}
perPage={perPage}
sortBy={sortBy}
sortOrder={sortOrder}
dateRange={dateRange}
/>
</div>
</div>
);
}

View File

@ -34,7 +34,6 @@ import { Input } from '@documenso/ui/primitives/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { AdminOrganisationMemberUpdateDialog } from '~/components/dialogs/admin-organisation-member-update-dialog';
import { GenericErrorLayout } from '~/components/general/generic-error-layout'; import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { SettingsHeader } from '~/components/general/settings-header'; import { SettingsHeader } from '~/components/general/settings-header';
@ -72,6 +71,23 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
}, },
}); });
const { mutateAsync: promoteToOwner, isPending: isPromotingToOwner } =
trpc.admin.organisationMember.promoteToOwner.useMutation({
onSuccess: () => {
toast({
title: t`Success`,
description: t`Member promoted to owner successfully`,
});
},
onError: () => {
toast({
title: t`Error`,
description: t`We couldn't promote the member to owner. Please try again.`,
variant: 'destructive',
});
},
});
const teamsColumns = useMemo(() => { const teamsColumns = useMemo(() => {
return [ return [
{ {
@ -104,24 +120,23 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
}, },
{ {
header: t`Actions`, header: t`Actions`,
cell: ({ row }) => { cell: ({ row }) => (
const isOwner = row.original.userId === organisation?.ownerUserId; <div className="flex justify-end space-x-2">
<Button
return ( variant="outline"
<div className="flex justify-end space-x-2"> disabled={row.original.userId === organisation?.ownerUserId}
<AdminOrganisationMemberUpdateDialog loading={isPromotingToOwner}
trigger={ onClick={async () =>
<Button variant="outline"> promoteToOwner({
<Trans>Update role</Trans> organisationId,
</Button> userId: row.original.userId,
} })
organisationId={organisationId} }
organisationMember={row.original} >
isOwner={isOwner} <Trans>Promote to owner</Trans>
/> </Button>
</div> </div>
); ),
},
}, },
] satisfies DataTableColumnDef<TGetAdminOrganisationResponse['members'][number]>[]; ] satisfies DataTableColumnDef<TGetAdminOrganisationResponse['members'][number]>[];
}, [organisation]); }, [organisation]);
@ -142,7 +157,8 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
404: { 404: {
heading: msg`Organisation not found`, heading: msg`Organisation not found`,
subHeading: msg`404 Organisation not found`, subHeading: msg`404 Organisation not found`,
message: msg`The organisation you are looking for may have been removed, renamed or may have never existed.`, message: msg`The organisation you are looking for may have been removed, renamed or may have never
existed.`,
}, },
}} }}
primaryButton={ primaryButton={
@ -388,7 +404,6 @@ const OrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) =
claims: { claims: {
teamCount: organisation.organisationClaim.teamCount, teamCount: organisation.organisationClaim.teamCount,
memberCount: organisation.organisationClaim.memberCount, memberCount: organisation.organisationClaim.memberCount,
envelopeItemCount: organisation.organisationClaim.envelopeItemCount,
flags: organisation.organisationClaim.flags, flags: organisation.organisationClaim.flags,
}, },
originalSubscriptionClaimId: organisation.organisationClaim.originalSubscriptionClaimId || '', originalSubscriptionClaimId: organisation.organisationClaim.originalSubscriptionClaimId || '',
@ -546,30 +561,6 @@ const OrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) =
)} )}
/> />
<FormField
control={form.control}
name="claims.envelopeItemCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Envelope Item Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={1}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Maximum number of uploaded files per envelope allowed</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div> <div>
<FormLabel> <FormLabel>
<Trans>Feature Flags</Trans> <Trans>Feature Flags</Trans>

Some files were not shown because too many files have changed in this diff Show More