mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 08:13:56 +10:00
Compare commits
21 Commits
archive/1.
...
7e38d06ef5
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e38d06ef5 | |||
| d2a009d52e | |||
| 4e2443396c | |||
| 2e2980f04f | |||
| 3efe0de52f | |||
| efbd133f0e | |||
| 4993e8a306 | |||
| f93d34c38e | |||
| 8c228f965a | |||
| 9020bbc753 | |||
| 9350c53c7d | |||
| ffce7a2c81 | |||
| 353bdce86b | |||
| f6bdb34b56 | |||
| e13b9f7c84 | |||
| 9908580bf1 | |||
| b0b07106b4 | |||
| 35250fa308 | |||
| 5cdd7f8623 | |||
| 47bdcd833f | |||
| 03eb6af69a |
@ -29,6 +29,10 @@ 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.
|
||||||
|
|||||||
692
CODE_STYLE.md
Normal file
692
CODE_STYLE.md
Normal file
@ -0,0 +1,692 @@
|
|||||||
|
# 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
|
||||||
@ -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",
|
"start": "next start -p 3003",
|
||||||
"lint:fix": "next lint --fix",
|
"lint:fix": "next lint --fix",
|
||||||
"clean": "rimraf .next && rimraf node_modules"
|
"clean": "rimraf .next && rimraf node_modules"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -27,9 +27,45 @@
|
|||||||
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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,218 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -15,17 +15,16 @@ 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,
|
||||||
@ -61,8 +60,10 @@ 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: Field[];
|
fields: Pick<Field, 'type' | 'recipientId'>[];
|
||||||
};
|
};
|
||||||
|
onDistribute?: () => Promise<void>;
|
||||||
|
documentRootPath: string;
|
||||||
trigger?: React.ReactNode;
|
trigger?: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -84,13 +85,19 @@ export const ZEnvelopeDistributeFormSchema = z.object({
|
|||||||
|
|
||||||
export type TEnvelopeDistributeFormSchema = z.infer<typeof ZEnvelopeDistributeFormSchema>;
|
export type TEnvelopeDistributeFormSchema = z.infer<typeof ZEnvelopeDistributeFormSchema>;
|
||||||
|
|
||||||
export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistributeDialogProps) => {
|
export const EnvelopeDistributeDialog = ({
|
||||||
|
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);
|
||||||
|
|
||||||
@ -127,22 +134,44 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu
|
|||||||
|
|
||||||
const distributionMethod = watch('meta.distributionMethod');
|
const distributionMethod = watch('meta.distributionMethod');
|
||||||
|
|
||||||
const everySignerHasSignature = useMemo(
|
const recipientsMissingSignatureFields = useMemo(
|
||||||
() =>
|
() =>
|
||||||
envelope.recipients
|
envelope.recipients.filter(
|
||||||
.filter((recipient) => recipient.role === RecipientRole.SIGNER)
|
(recipient) =>
|
||||||
.every((recipient) =>
|
recipient.role === RecipientRole.SIGNER &&
|
||||||
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.`,
|
||||||
@ -178,7 +207,8 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu
|
|||||||
<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}>
|
||||||
@ -200,7 +230,11 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<div className="min-h-72">
|
<div
|
||||||
|
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
|
||||||
@ -335,71 +369,18 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu
|
|||||||
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"
|
||||||
>
|
>
|
||||||
{envelope.status === DocumentStatus.DRAFT ? (
|
<div className="text-muted-foreground py-24 text-center text-sm">
|
||||||
<div className="text-muted-foreground py-24 text-center text-sm">
|
<p>
|
||||||
<p>
|
<Trans>We won't send anything to notify recipients.</Trans>
|
||||||
<Trans>We won't send anything to notify recipients.</Trans>
|
</p>
|
||||||
</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>
|
||||||
@ -426,12 +407,24 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Alert variant="warning">
|
<Alert variant="warning">
|
||||||
<AlertDescription>
|
{match(invalidEnvelopeCode)
|
||||||
<Trans>
|
.with('MISSING_RECIPIENTS', () => (
|
||||||
Some signers have not been assigned a signature field. Please assign at least 1
|
<AlertDescription>
|
||||||
signature field to each signer before proceeding.
|
<Trans>You need at least one recipient to send a document</Trans>
|
||||||
</Trans>
|
</AlertDescription>
|
||||||
</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>
|
||||||
|
|||||||
220
apps/remix/app/components/dialogs/envelope-download-dialog.tsx
Normal file
220
apps/remix/app/components/dialogs/envelope-download-dialog.tsx
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { useLingui } from '@lingui/react/macro';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { type DocumentData, DocumentStatus, type EnvelopeItem } from '@prisma/client';
|
||||||
|
import { DownloadIcon, FileTextIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { downloadFile } from '@documenso/lib/client-only/download-file';
|
||||||
|
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||||
|
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' | 'title' | 'order'> & {
|
||||||
|
documentData: DocumentData;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 ? { envelopeItems: initialEnvelopeItems } : undefined,
|
||||||
|
enabled: open,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const envelopeItems = envelopeItemsPayload?.envelopeItems || [];
|
||||||
|
|
||||||
|
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 {
|
||||||
|
const data = await getFile({
|
||||||
|
type: envelopeItem.documentData.type,
|
||||||
|
data:
|
||||||
|
version === 'signed'
|
||||||
|
? envelopeItem.documentData.data
|
||||||
|
: envelopeItem.documentData.initialData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const blob = new Blob([data], {
|
||||||
|
type: 'application/pdf',
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseTitle = envelopeItem.title.replace(/\.pdf$/, '');
|
||||||
|
const suffix = version === 'signed' ? '_signed.pdf' : '.pdf';
|
||||||
|
const filename = `${baseTitle}${suffix}`;
|
||||||
|
|
||||||
|
downloadFile({
|
||||||
|
filename,
|
||||||
|
data: blob,
|
||||||
|
});
|
||||||
|
|
||||||
|
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: 2 }).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">
|
||||||
|
<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>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>Signed</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -185,10 +185,6 @@ export const OrganisationMemberInviteDialog = ({
|
|||||||
return 'form';
|
return 'form';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fullOrganisation.members.length < fullOrganisation.organisationClaim.memberCount) {
|
|
||||||
return 'form';
|
|
||||||
}
|
|
||||||
|
|
||||||
// This is probably going to screw us over in the future.
|
// This is probably going to screw us over in the future.
|
||||||
if (fullOrganisation.organisationClaim.originalSubscriptionClaimId !== INTERNAL_CLAIM_ID.TEAM) {
|
if (fullOrganisation.organisationClaim.originalSubscriptionClaimId !== INTERNAL_CLAIM_ID.TEAM) {
|
||||||
return 'alert';
|
return 'alert';
|
||||||
|
|||||||
186
apps/remix/app/components/dialogs/sign-field-checkbox-dialog.tsx
Normal file
186
apps/remix/app/components/dialogs/sign-field-checkbox-dialog.tsx
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@ -1,40 +1,15 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
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 {
|
||||||
Dialog,
|
CommandDialog,
|
||||||
DialogContent,
|
CommandEmpty,
|
||||||
DialogDescription,
|
CommandGroup,
|
||||||
DialogFooter,
|
CommandInput,
|
||||||
DialogHeader,
|
CommandItem,
|
||||||
DialogTitle,
|
CommandList,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/command';
|
||||||
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;
|
||||||
@ -46,72 +21,25 @@ 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 (
|
||||||
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
<CommandDialog
|
||||||
<DialogContent position="center">
|
position="start"
|
||||||
<DialogHeader>
|
dialogContentClassName="mt-4"
|
||||||
<DialogTitle>
|
open={true}
|
||||||
<Trans>Sign Dropdown Field</Trans>
|
onOpenChange={(value) => (!value ? call.end(null) : null)}
|
||||||
</DialogTitle>
|
>
|
||||||
|
<CommandInput placeholder={t`Select an option`} />
|
||||||
<DialogDescription className="mt-4">
|
<CommandList>
|
||||||
<Trans>Select a value to sign into the field</Trans>
|
<CommandEmpty>No results found.</CommandEmpty>
|
||||||
</DialogDescription>
|
<CommandGroup heading={t`Options`}>
|
||||||
</DialogHeader>
|
{values.map((value, i) => (
|
||||||
|
<CommandItem onSelect={() => call.end(value)} key={i} value={value}>
|
||||||
<Form {...form}>
|
{value}
|
||||||
<form onSubmit={form.handleSubmit((data) => call.end(data.dropdown))}>
|
</CommandItem>
|
||||||
<fieldset
|
))}
|
||||||
className="flex h-full flex-col space-y-4"
|
</CommandGroup>
|
||||||
disabled={form.formState.isSubmitting}
|
</CommandList>
|
||||||
>
|
</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>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@ -29,20 +29,22 @@ const ZSignFieldEmailFormSchema = z.object({
|
|||||||
|
|
||||||
type TSignFieldEmailFormSchema = z.infer<typeof ZSignFieldEmailFormSchema>;
|
type TSignFieldEmailFormSchema = z.infer<typeof ZSignFieldEmailFormSchema>;
|
||||||
|
|
||||||
export type SignFieldEmailDialogProps = Record<string, never>;
|
export type SignFieldEmailDialogProps = {
|
||||||
|
placeholderEmail: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export const SignFieldEmailDialog = createCallable<SignFieldEmailDialogProps, string | null>(
|
export const SignFieldEmailDialog = createCallable<SignFieldEmailDialogProps, string | null>(
|
||||||
({ call }) => {
|
({ call, placeholderEmail }) => {
|
||||||
const form = useForm<TSignFieldEmailFormSchema>({
|
const form = useForm<TSignFieldEmailFormSchema>({
|
||||||
resolver: zodResolver(ZSignFieldEmailFormSchema),
|
resolver: zodResolver(ZSignFieldEmailFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
email: '',
|
email: placeholderEmail || '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
||||||
<DialogContent position="center">
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<Trans>Sign Email</Trans>
|
<Trans>Sign Email</Trans>
|
||||||
|
|||||||
@ -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 position="center">
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<Trans>Sign Initials</Trans>
|
<Trans>Sign Initials</Trans>
|
||||||
|
|||||||
@ -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 position="center">
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<Trans>Sign Name</Trans>
|
<Trans>Sign Name</Trans>
|
||||||
|
|||||||
@ -30,7 +30,7 @@ import { Input } from '@documenso/ui/primitives/input';
|
|||||||
|
|
||||||
const createNumberFieldSchema = (fieldMeta: TNumberFieldMeta) => {
|
const createNumberFieldSchema = (fieldMeta: TNumberFieldMeta) => {
|
||||||
let schema = z.coerce.number({
|
let schema = z.coerce.number({
|
||||||
invalid_type_error: msg`Please enter a valid number`.id, // Todo: Envelopes - Check that this works
|
invalid_type_error: msg`Please enter a valid number`.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { numberFormat, minValue, maxValue } = fieldMeta;
|
const { numberFormat, minValue, maxValue } = fieldMeta;
|
||||||
@ -55,9 +55,7 @@ const createNumberFieldSchema = (fieldMeta: TNumberFieldMeta) => {
|
|||||||
return foundRegex.test(value.toString());
|
return foundRegex.test(value.toString());
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
message: `Number needs to be formatted as ${numberFormat}`,
|
message: msg`Number needs to be formatted as ${numberFormat}`.id,
|
||||||
// Todo: Envelopes
|
|
||||||
// message: msg`Number needs to be formatted as ${numberFormat}`.id,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -86,7 +84,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 position="center">
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<Trans>Sign Number Field</Trans>
|
<Trans>Sign Number Field</Trans>
|
||||||
|
|||||||
@ -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 position="center">
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<Trans>Sign Text Field</Trans>
|
<Trans>Sign Text Field</Trans>
|
||||||
|
|||||||
@ -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 { InfoIcon, Plus, Upload, X } from 'lucide-react';
|
import { FileTextIcon, InfoIcon, Plus, UploadCloudIcon, 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,6 +16,10 @@ 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';
|
||||||
@ -41,6 +45,7 @@ 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';
|
||||||
@ -49,8 +54,13 @@ const ZAddRecipientsForNewDocumentSchema = z.object({
|
|||||||
distributeDocument: z.boolean(),
|
distributeDocument: z.boolean(),
|
||||||
useCustomDocument: z.boolean().default(false),
|
useCustomDocument: z.boolean().default(false),
|
||||||
customDocumentData: z
|
customDocumentData: z
|
||||||
.any()
|
.array(
|
||||||
.refine((data) => data instanceof File || data === undefined)
|
z.object({
|
||||||
|
title: z.string(),
|
||||||
|
data: z.instanceof(File).optional(),
|
||||||
|
envelopeItemId: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
recipients: z.array(
|
recipients: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
@ -65,6 +75,7 @@ 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[];
|
||||||
@ -77,6 +88,7 @@ export function TemplateUseDialog({
|
|||||||
recipients,
|
recipients,
|
||||||
documentDistributionMethod = DocumentDistributionMethod.EMAIL,
|
documentDistributionMethod = DocumentDistributionMethod.EMAIL,
|
||||||
documentRootPath,
|
documentRootPath,
|
||||||
|
envelopeId,
|
||||||
templateId,
|
templateId,
|
||||||
templateSigningOrder,
|
templateSigningOrder,
|
||||||
trigger,
|
trigger,
|
||||||
@ -93,7 +105,7 @@ export function TemplateUseDialog({
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
distributeDocument: false,
|
distributeDocument: false,
|
||||||
useCustomDocument: false,
|
useCustomDocument: false,
|
||||||
customDocumentData: undefined,
|
customDocumentData: [],
|
||||||
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) => {
|
||||||
@ -115,23 +127,50 @@ 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?.envelopeItems ?? [];
|
||||||
|
|
||||||
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 {
|
||||||
let customDocumentDataId: string | undefined = undefined;
|
const customFilesToUpload = (data.customDocumentData || []).filter(
|
||||||
|
(item): item is { data: File; envelopeItemId: string; title: string } =>
|
||||||
|
item.data !== undefined && item.envelopeItemId !== undefined && item.title !== undefined,
|
||||||
|
);
|
||||||
|
|
||||||
if (data.useCustomDocument && data.customDocumentData) {
|
const customDocumentData = await Promise.all(
|
||||||
const customDocumentData = await putPdfFile(data.customDocumentData);
|
customFilesToUpload.map(async (item) => {
|
||||||
customDocumentDataId = customDocumentData.id;
|
const customDocumentData = await putPdfFile(item.data);
|
||||||
}
|
|
||||||
|
|
||||||
const { id } = await createDocumentFromTemplate({
|
return {
|
||||||
|
documentDataId: customDocumentData.id,
|
||||||
|
envelopeItemId: item.envelopeItemId,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { envelopeId } = await createDocumentFromTemplate({
|
||||||
templateId,
|
templateId,
|
||||||
recipients: data.recipients,
|
recipients: data.recipients,
|
||||||
distributeDocument: data.distributeDocument,
|
distributeDocument: data.distributeDocument,
|
||||||
customDocumentDataId,
|
customDocumentData,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@ -140,7 +179,7 @@ export function TemplateUseDialog({
|
|||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
let documentPath = `${documentRootPath}/${id}`;
|
let documentPath = `${documentRootPath}/${envelopeId}`;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
data.distributeDocument &&
|
data.distributeDocument &&
|
||||||
@ -180,6 +219,18 @@ 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>
|
||||||
@ -384,7 +435,6 @@ 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">
|
||||||
@ -406,116 +456,133 @@ export function TemplateUseDialog({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{form.watch('useCustomDocument') && (
|
{form.watch('useCustomDocument') && (
|
||||||
<div className="my-4">
|
<div className="my-4 space-y-2">
|
||||||
<FormField
|
{isLoadingEnvelopeItems ? (
|
||||||
control={form.control}
|
<SpinnerBox className="py-16" />
|
||||||
name="customDocumentData"
|
) : (
|
||||||
render={({ field }) => (
|
localCustomDocumentData.map((item, i) => (
|
||||||
<FormItem>
|
<FormField
|
||||||
<FormControl>
|
key={item.id}
|
||||||
<div className="w-full space-y-4">
|
control={form.control}
|
||||||
<label
|
name={`customDocumentData.${i}.data`}
|
||||||
className={cn(
|
render={({ field }) => (
|
||||||
'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',
|
<FormItem>
|
||||||
{
|
<FormControl>
|
||||||
'border-destructive hover:border-destructive':
|
<div
|
||||||
form.formState.errors.customDocumentData,
|
key={item.id}
|
||||||
},
|
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="text-center">
|
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg">
|
||||||
{!field.value && (
|
<FileTextIcon className="text-primary h-5 w-5" />
|
||||||
<>
|
|
||||||
<Upload className="text-muted-foreground/50 mx-auto h-10 w-10" />
|
|
||||||
<div className="mt-4 flex text-sm leading-6">
|
|
||||||
<span className="text-muted-foreground relative">
|
|
||||||
<Trans>
|
|
||||||
<span className="text-primary font-semibold">
|
|
||||||
Click to upload
|
|
||||||
</span>{' '}
|
|
||||||
or drag and drop
|
|
||||||
</Trans>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-muted-foreground/80 text-xs">
|
|
||||||
PDF files only
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{field.value && (
|
|
||||||
<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
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<input
|
|
||||||
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>
|
</div>
|
||||||
)}
|
|
||||||
</label>
|
<div className="min-w-0 flex-1">
|
||||||
</div>
|
<h4 className="text-foreground truncate text-sm font-medium">
|
||||||
</FormControl>
|
{item.title}
|
||||||
<FormMessage />
|
</h4>
|
||||||
</FormItem>
|
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||||
)}
|
{field.value ? (
|
||||||
/>
|
<div>
|
||||||
|
<Trans>
|
||||||
|
Custom {(field.value.size / (1024 * 1024)).toFixed(2)}{' '}
|
||||||
|
MB file
|
||||||
|
</Trans>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs"
|
||||||
|
onClick={() => {
|
||||||
|
const fileInput = document.getElementById(
|
||||||
|
`template-use-dialog-file-input-${item.envelopeItemId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (fileInput instanceof HTMLInputElement) {
|
||||||
|
fileInput.click();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UploadCloudIcon className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Upload</Trans>
|
||||||
|
</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>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -9,7 +9,6 @@ export type EmbedAuthenticationRequiredProps = {
|
|||||||
email?: string;
|
email?: string;
|
||||||
returnTo: string;
|
returnTo: string;
|
||||||
isGoogleSSOEnabled?: boolean;
|
isGoogleSSOEnabled?: boolean;
|
||||||
isMicrosoftSSOEnabled?: boolean;
|
|
||||||
isOIDCSSOEnabled?: boolean;
|
isOIDCSSOEnabled?: boolean;
|
||||||
oidcProviderLabel?: string;
|
oidcProviderLabel?: string;
|
||||||
};
|
};
|
||||||
@ -18,7 +17,6 @@ export const EmbedAuthenticationRequired = ({
|
|||||||
email,
|
email,
|
||||||
returnTo,
|
returnTo,
|
||||||
// isGoogleSSOEnabled,
|
// isGoogleSSOEnabled,
|
||||||
// isMicrosoftSSOEnabled,
|
|
||||||
// isOIDCSSOEnabled,
|
// isOIDCSSOEnabled,
|
||||||
// oidcProviderLabel,
|
// oidcProviderLabel,
|
||||||
}: EmbedAuthenticationRequiredProps) => {
|
}: EmbedAuthenticationRequiredProps) => {
|
||||||
@ -39,7 +37,6 @@ export const EmbedAuthenticationRequired = ({
|
|||||||
<SignInForm
|
<SignInForm
|
||||||
// Embed currently not supported.
|
// Embed currently not supported.
|
||||||
// isGoogleSSOEnabled={isGoogleSSOEnabled}
|
// isGoogleSSOEnabled={isGoogleSSOEnabled}
|
||||||
// isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
|
|
||||||
// isOIDCSSOEnabled={isOIDCSSOEnabled}
|
// isOIDCSSOEnabled={isOIDCSSOEnabled}
|
||||||
// oidcProviderLabel={oidcProviderLabel}
|
// oidcProviderLabel={oidcProviderLabel}
|
||||||
className="mt-4"
|
className="mt-4"
|
||||||
|
|||||||
@ -1,31 +0,0 @@
|
|||||||
// 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: '<=',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useMemo } 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,11 +7,19 @@ 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,
|
||||||
@ -30,8 +38,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';
|
||||||
@ -44,6 +52,7 @@ 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(),
|
||||||
@ -90,6 +99,7 @@ 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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -99,13 +109,17 @@ export const EditorFieldCheckboxForm = ({
|
|||||||
control,
|
control,
|
||||||
});
|
});
|
||||||
|
|
||||||
const addValue = () => {
|
const addValue = (numberOfValues: number = 1) => {
|
||||||
const currentValues = form.getValues('values') || [];
|
const currentValues = form.getValues('values') || [];
|
||||||
const newId =
|
const currentMaxId = Math.max(...currentValues.map((val) => val.id));
|
||||||
currentValues.length > 0 ? Math.max(...currentValues.map((val) => val.id)) + 1 : 1;
|
|
||||||
|
|
||||||
const newValues = [...currentValues, { id: newId, checked: false, value: '' }];
|
const newValues = Array.from({ length: numberOfValues }, (_, index) => ({
|
||||||
form.setValue('values', newValues);
|
id: currentMaxId + index + 1,
|
||||||
|
checked: false,
|
||||||
|
value: '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
form.setValue('values', [...currentValues, ...newValues]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeValue = (index: number) => {
|
const removeValue = (index: number) => {
|
||||||
@ -132,10 +146,34 @@ 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"
|
||||||
@ -202,7 +240,25 @@ export const EditorFieldCheckboxForm = ({
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Select
|
<Select
|
||||||
value={field.value ? String(field.value) : ''}
|
value={field.value ? String(field.value) : ''}
|
||||||
onValueChange={field.onChange}
|
onValueChange={(value) => {
|
||||||
|
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`} />
|
||||||
@ -239,7 +295,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>
|
||||||
@ -285,6 +341,16 @@ 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>
|
||||||
|
|||||||
@ -8,7 +8,10 @@ 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 { type TDropdownFieldMeta as DropdownFieldMeta } from '@documenso/lib/types/field-meta';
|
import {
|
||||||
|
DEFAULT_FIELD_FONT_SIZE,
|
||||||
|
type TDropdownFieldMeta as DropdownFieldMeta,
|
||||||
|
} from '@documenso/lib/types/field-meta';
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@ -28,56 +31,50 @@ 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
|
const ZDropdownFieldFormSchema = z.object({
|
||||||
.object({
|
defaultValue: z.string().optional(),
|
||||||
defaultValue: z.string().optional(),
|
values: z
|
||||||
values: z
|
.object({
|
||||||
.object({
|
value: z.string().min(1, {
|
||||||
value: z.string().min(1, {
|
message: msg`Option value cannot be empty`.id,
|
||||||
message: msg`Option value cannot be empty`.id,
|
}),
|
||||||
}),
|
})
|
||||||
})
|
.array()
|
||||||
.array()
|
.min(1, {
|
||||||
.min(1, {
|
message: msg`Dropdown must have at least one option`.id,
|
||||||
message: msg`Dropdown must have at least one option`.id,
|
})
|
||||||
})
|
.superRefine((values, ctx) => {
|
||||||
.refine(
|
const seen = new Map<string, number[]>(); // value → indices
|
||||||
(data) => {
|
|
||||||
// Todo: Envelopes - This doesn't work.
|
|
||||||
console.log({
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (data) {
|
values.forEach((item, index) => {
|
||||||
const values = data.map((item) => item.value);
|
const key = item.value;
|
||||||
return new Set(values).size === values.length;
|
if (!seen.has(key)) {
|
||||||
|
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(),
|
||||||
message: 'Default value must be one of the available options',
|
fontSize: z.number().optional(),
|
||||||
path: ['defaultValue'],
|
});
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
type TDropdownFieldFormSchema = z.infer<typeof ZDropdownFieldFormSchema>;
|
type TDropdownFieldFormSchema = z.infer<typeof ZDropdownFieldFormSchema>;
|
||||||
|
|
||||||
@ -102,6 +99,7 @@ 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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -111,7 +109,20 @@ 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);
|
||||||
};
|
};
|
||||||
@ -127,6 +138,10 @@ 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(() => {
|
||||||
@ -140,19 +155,13 @@ 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"
|
||||||
@ -163,20 +172,25 @@ 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}
|
value={field.value ?? '-1'}
|
||||||
onValueChange={(val) => field.onChange(val)}
|
onValueChange={(value) => field.onChange(value === '-1' ? undefined : value)}
|
||||||
>
|
>
|
||||||
<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 || []).map((item, index) => (
|
{(formValues.values || [])
|
||||||
<SelectItem key={index} value={item.value || ''}>
|
.filter((item) => item.value)
|
||||||
{item.value}
|
.map((item, index) => (
|
||||||
</SelectItem>
|
<SelectItem key={index} value={item.value || ''}>
|
||||||
))}
|
{item.value}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<SelectItem value={'-1'}>
|
||||||
|
<Trans>Default Value</Trans>
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@ -130,6 +130,12 @@ export const EditorFieldNumberForm = ({
|
|||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form>
|
<form>
|
||||||
<fieldset className="flex flex-col gap-2">
|
<fieldset className="flex flex-col gap-2">
|
||||||
|
<div className="flex w-full flex-row gap-x-4">
|
||||||
|
<EditorGenericFontSizeField className="w-full" formControl={form.control} />
|
||||||
|
|
||||||
|
<EditorGenericTextAlignField className="w-full" formControl={form.control} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<EditorGenericLabelField formControl={form.control} />
|
<EditorGenericLabelField formControl={form.control} />
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
@ -198,12 +204,6 @@ export const EditorFieldNumberForm = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex w-full flex-row gap-x-4">
|
|
||||||
<EditorGenericFontSizeField className="w-full" formControl={form.control} />
|
|
||||||
|
|
||||||
<EditorGenericTextAlignField className="w-full" formControl={form.control} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<EditorGenericRequiredField formControl={form.control} />
|
<EditorGenericRequiredField formControl={form.control} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,47 +1,62 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans, useLingui } 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 { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
import { type TRadioFieldMeta as RadioFieldMeta } from '@documenso/lib/types/field-meta';
|
import {
|
||||||
|
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 { Form, FormControl, FormField, FormItem } from '@documenso/ui/primitives/form/form';
|
import {
|
||||||
|
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 = z
|
const ZRadioFieldFormSchema = ZRadioFieldMeta.pick({
|
||||||
.object({
|
label: true,
|
||||||
label: z.string().optional(),
|
direction: true,
|
||||||
values: z
|
values: true,
|
||||||
.object({ id: z.number(), checked: z.boolean(), value: z.string() })
|
required: true,
|
||||||
.array()
|
readOnly: true,
|
||||||
.min(1)
|
fontSize: true,
|
||||||
.optional(),
|
}).refine(
|
||||||
required: z.boolean().optional(),
|
(data) => {
|
||||||
readOnly: z.boolean().optional(),
|
// There cannot be more than one checked option
|
||||||
})
|
if (data.values) {
|
||||||
.refine(
|
const checkedValues = data.values.filter((option) => option.checked);
|
||||||
(data) => {
|
return checkedValues.length <= 1;
|
||||||
// There cannot be more than one checked option
|
}
|
||||||
if (data.values) {
|
return true;
|
||||||
const checkedValues = data.values.filter((option) => option.checked);
|
},
|
||||||
return checkedValues.length <= 1;
|
{
|
||||||
}
|
message: 'There cannot be more than one checked option',
|
||||||
return true;
|
path: ['values'],
|
||||||
},
|
},
|
||||||
{
|
);
|
||||||
message: 'There cannot be more than one checked option',
|
|
||||||
path: ['values'],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
type TRadioFieldFormSchema = z.infer<typeof ZRadioFieldFormSchema>;
|
type TRadioFieldFormSchema = z.infer<typeof ZRadioFieldFormSchema>;
|
||||||
|
|
||||||
@ -53,9 +68,12 @@ 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',
|
||||||
@ -64,6 +82,8 @@ 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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -107,7 +127,37 @@ export const EditorFieldRadioForm = ({
|
|||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form>
|
<form>
|
||||||
<fieldset className="flex flex-col gap-2 pb-2">
|
<fieldset className="flex flex-col gap-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} />
|
||||||
|
|||||||
@ -0,0 +1,74 @@
|
|||||||
|
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_FIELD_FONT_SIZE,
|
||||||
|
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_FIELD_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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -5,7 +5,10 @@ import { Trans, useLingui } from '@lingui/react/macro';
|
|||||||
import { useForm, useWatch } from 'react-hook-form';
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { type TTextFieldMeta as TextFieldMeta } from '@documenso/lib/types/field-meta';
|
import {
|
||||||
|
DEFAULT_FIELD_FONT_SIZE,
|
||||||
|
type TTextFieldMeta as TextFieldMeta,
|
||||||
|
} from '@documenso/lib/types/field-meta';
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@ -69,7 +72,7 @@ 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 || 14,
|
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
||||||
textAlign: value.textAlign || 'left',
|
textAlign: value.textAlign || 'left',
|
||||||
required: value.required || false,
|
required: value.required || false,
|
||||||
readOnly: value.readOnly || false,
|
readOnly: value.readOnly || false,
|
||||||
@ -98,6 +101,12 @@ export const EditorFieldTextForm = ({
|
|||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form>
|
<form>
|
||||||
<fieldset className="flex flex-col gap-2">
|
<fieldset className="flex flex-col gap-2">
|
||||||
|
<div className="flex w-full flex-row gap-x-4">
|
||||||
|
<EditorGenericFontSizeField className="w-full" formControl={form.control} />
|
||||||
|
|
||||||
|
<EditorGenericTextAlignField className="w-full" formControl={form.control} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="label"
|
name="label"
|
||||||
@ -173,12 +182,6 @@ export const EditorFieldTextForm = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex w-full flex-row gap-x-4">
|
|
||||||
<EditorGenericFontSizeField className="w-full" formControl={form.control} />
|
|
||||||
|
|
||||||
<EditorGenericTextAlignField className="w-full" formControl={form.control} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<EditorGenericRequiredField formControl={form.control} />
|
<EditorGenericRequiredField formControl={form.control} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -92,7 +92,6 @@ export const SignInForm = ({
|
|||||||
|
|
||||||
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
|
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const [isEmbeddedRedirect, setIsEmbeddedRedirect] = useState(false);
|
|
||||||
|
|
||||||
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
|
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
|
||||||
'totp' | 'backup'
|
'totp' | 'backup'
|
||||||
@ -318,8 +317,6 @@ export const SignInForm = ({
|
|||||||
if (email) {
|
if (email) {
|
||||||
form.setValue('email', email);
|
form.setValue('email', email);
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsEmbeddedRedirect(params.get('embedded') === 'true');
|
|
||||||
}, [form]);
|
}, [form]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -386,64 +383,56 @@ export const SignInForm = ({
|
|||||||
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
|
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{!isEmbeddedRedirect && (
|
{hasSocialAuthEnabled && (
|
||||||
<>
|
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
||||||
{hasSocialAuthEnabled && (
|
<div className="bg-border h-px flex-1" />
|
||||||
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
<span className="text-muted-foreground bg-transparent">
|
||||||
<div className="bg-border h-px flex-1" />
|
<Trans>Or continue with</Trans>
|
||||||
<span className="text-muted-foreground bg-transparent">
|
</span>
|
||||||
<Trans>Or continue with</Trans>
|
<div className="bg-border h-px flex-1" />
|
||||||
</span>
|
</div>
|
||||||
<div className="bg-border h-px flex-1" />
|
)}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isGoogleSSOEnabled && (
|
{isGoogleSSOEnabled && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="lg"
|
size="lg"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="bg-background text-muted-foreground border"
|
className="bg-background text-muted-foreground border"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
onClick={onSignInWithGoogleClick}
|
onClick={onSignInWithGoogleClick}
|
||||||
>
|
>
|
||||||
<FcGoogle className="mr-2 h-5 w-5" />
|
<FcGoogle className="mr-2 h-5 w-5" />
|
||||||
Google
|
Google
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isMicrosoftSSOEnabled && (
|
{isMicrosoftSSOEnabled && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="lg"
|
size="lg"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="bg-background text-muted-foreground border"
|
className="bg-background text-muted-foreground border"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
onClick={onSignInWithMicrosoftClick}
|
onClick={onSignInWithMicrosoftClick}
|
||||||
>
|
>
|
||||||
<img
|
<img className="mr-2 h-4 w-4" alt="Microsoft Logo" src={'/static/microsoft.svg'} />
|
||||||
className="mr-2 h-4 w-4"
|
Microsoft
|
||||||
alt="Microsoft Logo"
|
</Button>
|
||||||
src={'/static/microsoft.svg'}
|
)}
|
||||||
/>
|
|
||||||
Microsoft
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isOIDCSSOEnabled && (
|
{isOIDCSSOEnabled && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="lg"
|
size="lg"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="bg-background text-muted-foreground border"
|
className="bg-background text-muted-foreground border"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
onClick={onSignInWithOIDCClick}
|
onClick={onSignInWithOIDCClick}
|
||||||
>
|
>
|
||||||
<FaIdCardClip className="mr-2 h-5 w-5" />
|
<FaIdCardClip className="mr-2 h-5 w-5" />
|
||||||
{oidcProviderLabel || 'OIDC'}
|
{oidcProviderLabel || 'OIDC'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -68,7 +68,6 @@ export type SignUpFormProps = {
|
|||||||
isGoogleSSOEnabled?: boolean;
|
isGoogleSSOEnabled?: boolean;
|
||||||
isMicrosoftSSOEnabled?: boolean;
|
isMicrosoftSSOEnabled?: boolean;
|
||||||
isOIDCSSOEnabled?: boolean;
|
isOIDCSSOEnabled?: boolean;
|
||||||
returnTo?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SignUpForm = ({
|
export const SignUpForm = ({
|
||||||
@ -77,7 +76,6 @@ export const SignUpForm = ({
|
|||||||
isGoogleSSOEnabled,
|
isGoogleSSOEnabled,
|
||||||
isMicrosoftSSOEnabled,
|
isMicrosoftSSOEnabled,
|
||||||
isOIDCSSOEnabled,
|
isOIDCSSOEnabled,
|
||||||
returnTo,
|
|
||||||
}: SignUpFormProps) => {
|
}: SignUpFormProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@ -112,7 +110,7 @@ export const SignUpForm = ({
|
|||||||
signature,
|
signature,
|
||||||
});
|
});
|
||||||
|
|
||||||
await navigate(returnTo ? returnTo : '/unverified-account');
|
await navigate(`/unverified-account`);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Registration Successful`),
|
title: _(msg`Registration Successful`),
|
||||||
|
|||||||
@ -39,6 +39,7 @@ 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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -111,6 +112,30 @@ 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>
|
||||||
|
|||||||
17
apps/remix/app/components/general/branding-logo-icon.tsx
Normal file
17
apps/remix/app/components/general/branding-logo-icon.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -89,7 +89,10 @@ export const DirectTemplatePageView = ({
|
|||||||
setStep('sign');
|
setStep('sign');
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSignDirectTemplateSubmit = async (fields: DirectTemplateLocalField[]) => {
|
const onSignDirectTemplateSubmit = async (
|
||||||
|
fields: DirectTemplateLocalField[],
|
||||||
|
nextSigner?: { name: string; email: string },
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
let directTemplateExternalId = searchParams?.get('externalId') || undefined;
|
let directTemplateExternalId = searchParams?.get('externalId') || undefined;
|
||||||
|
|
||||||
@ -98,6 +101,7 @@ export const DirectTemplatePageView = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { token } = await createDocumentFromDirectTemplate({
|
const { token } = await createDocumentFromDirectTemplate({
|
||||||
|
nextSigner,
|
||||||
directTemplateToken,
|
directTemplateToken,
|
||||||
directTemplateExternalId,
|
directTemplateExternalId,
|
||||||
directRecipientName: fullName,
|
directRecipientName: fullName,
|
||||||
|
|||||||
@ -55,10 +55,13 @@ 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'>;
|
directRecipient: Pick<Recipient, 'authOptions' | 'email' | 'role' | 'name' | 'token' | 'id'>;
|
||||||
directRecipientFields: Field[];
|
directRecipientFields: Field[];
|
||||||
template: Omit<TTemplate, 'user'>;
|
template: Omit<TTemplate, 'user'>;
|
||||||
onSubmit: (_data: DirectTemplateLocalField[]) => Promise<void>;
|
onSubmit: (
|
||||||
|
_data: DirectTemplateLocalField[],
|
||||||
|
_nextSigner?: { name: string; email: string },
|
||||||
|
) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DirectTemplateLocalField = Field & {
|
export type DirectTemplateLocalField = Field & {
|
||||||
@ -149,7 +152,7 @@ export const DirectTemplateSigningForm = ({
|
|||||||
validateFieldsInserted(fieldsRequiringValidation);
|
validateFieldsInserted(fieldsRequiringValidation);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async (nextSigner?: { name: string; email: string }) => {
|
||||||
setValidateUninsertedFields(true);
|
setValidateUninsertedFields(true);
|
||||||
|
|
||||||
const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation);
|
const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation);
|
||||||
@ -161,7 +164,7 @@ export const DirectTemplateSigningForm = ({
|
|||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await onSubmit(localFields);
|
await onSubmit(localFields, nextSigner);
|
||||||
} catch {
|
} catch {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@ -218,6 +221,30 @@ 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} />
|
||||||
@ -417,11 +444,15 @@ export const DirectTemplateSigningForm = ({
|
|||||||
|
|
||||||
<DocumentSigningCompleteDialog
|
<DocumentSigningCompleteDialog
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
onSignatureComplete={async () => handleSubmit()}
|
onSignatureComplete={async (nextSigner) => handleSubmit(nextSigner)}
|
||||||
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>
|
||||||
|
|||||||
@ -8,11 +8,13 @@ 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,
|
||||||
@ -26,15 +28,17 @@ export const DocumentSigningAttachmentsPopover = ({
|
|||||||
return (
|
return (
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="outline" className="gap-2">
|
{trigger ?? (
|
||||||
<PaperclipIcon className="h-4 w-4" />
|
<Button variant="outline" className="gap-2">
|
||||||
<span>
|
<PaperclipIcon className="h-4 w-4" />
|
||||||
<Trans>Attachments</Trans>{' '}
|
<span>
|
||||||
{attachments && attachments.data.length > 0 && (
|
<Trans>Attachments</Trans>{' '}
|
||||||
<span className="ml-1">({attachments.data.length})</span>
|
{attachments && attachments.data.length > 0 && (
|
||||||
)}
|
<span className="ml-1">({attachments.data.length})</span>
|
||||||
</span>
|
)}
|
||||||
</Button>
|
</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|
||||||
<PopoverContent className="w-96" align="start">
|
<PopoverContent className="w-96" align="start">
|
||||||
|
|||||||
@ -22,7 +22,7 @@ export const DocumentSigningAuthAccount = ({
|
|||||||
actionVerb = 'sign',
|
actionVerb = 'sign',
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: DocumentSigningAuthAccountProps) => {
|
}: DocumentSigningAuthAccountProps) => {
|
||||||
const { recipient, isDirectTemplate } = useRequiredDocumentSigningAuthContext();
|
const { recipient } = useRequiredDocumentSigningAuthContext();
|
||||||
|
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
@ -34,10 +34,8 @@ export const DocumentSigningAuthAccount = ({
|
|||||||
try {
|
try {
|
||||||
setIsSigningOut(true);
|
setIsSigningOut(true);
|
||||||
|
|
||||||
const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
|
||||||
|
|
||||||
await authClient.signOut({
|
await authClient.signOut({
|
||||||
redirectPath: `/signin?returnTo=${encodeURIComponent(currentPath)}#embedded=true&email=${isDirectTemplate ? '' : email}`,
|
redirectPath: `/signin#email=${email}`,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
setIsSigningOut(false);
|
setIsSigningOut(false);
|
||||||
@ -57,28 +55,16 @@ export const DocumentSigningAuthAccount = ({
|
|||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
{actionTarget === 'DOCUMENT' && recipient.role === RecipientRole.VIEWER ? (
|
{actionTarget === 'DOCUMENT' && recipient.role === RecipientRole.VIEWER ? (
|
||||||
<span>
|
<span>
|
||||||
{isDirectTemplate ? (
|
<Trans>
|
||||||
<Trans>To mark this document as viewed, you need to be logged in.</Trans>
|
To mark this document as viewed, you need to be logged in as{' '}
|
||||||
) : (
|
<strong>{recipient.email}</strong>
|
||||||
<Trans>
|
</Trans>
|
||||||
To mark this document as viewed, you need to be logged in as{' '}
|
|
||||||
<strong>{recipient.email}</strong>
|
|
||||||
</Trans>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span>
|
<span>
|
||||||
{isDirectTemplate ? (
|
{/* Todo: Translate */}
|
||||||
<Trans>
|
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be logged
|
||||||
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be
|
in as <strong>{recipient.email}</strong>
|
||||||
logged in.
|
|
||||||
</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>
|
|
||||||
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be
|
|
||||||
logged in as <strong>{recipient.email}</strong>
|
|
||||||
</Trans>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
|
|||||||
@ -47,8 +47,7 @@ export const DocumentSigningAuthDialog = ({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
onReauthFormSubmit,
|
onReauthFormSubmit,
|
||||||
}: DocumentSigningAuthDialogProps) => {
|
}: DocumentSigningAuthDialogProps) => {
|
||||||
const { recipient, user, isCurrentlyAuthenticating, isDirectTemplate } =
|
const { recipient, user, isCurrentlyAuthenticating } = useRequiredDocumentSigningAuthContext();
|
||||||
useRequiredDocumentSigningAuthContext();
|
|
||||||
|
|
||||||
// Filter out EXPLICIT_NONE from available auth types for the chooser
|
// Filter out EXPLICIT_NONE from available auth types for the chooser
|
||||||
const validAuthTypes = availableAuthTypes.filter(
|
const validAuthTypes = availableAuthTypes.filter(
|
||||||
@ -169,11 +168,7 @@ export const DocumentSigningAuthDialog = ({
|
|||||||
match({ documentAuthType: selectedAuthType, user })
|
match({ documentAuthType: selectedAuthType, user })
|
||||||
.with(
|
.with(
|
||||||
{ documentAuthType: DocumentAuth.ACCOUNT },
|
{ documentAuthType: DocumentAuth.ACCOUNT },
|
||||||
{
|
{ user: P.when((user) => !user || user.email !== recipient.email) }, // Assume all current auth methods requires them to be logged in.
|
||||||
user: P.when(
|
|
||||||
(user) => !user || (user.email !== recipient.email && !isDirectTemplate),
|
|
||||||
),
|
|
||||||
}, // Assume all current auth methods requires them to be logged in.
|
|
||||||
() => <DocumentSigningAuthAccount onOpenChange={onOpenChange} />,
|
() => <DocumentSigningAuthAccount onOpenChange={onOpenChange} />,
|
||||||
)
|
)
|
||||||
.with({ documentAuthType: DocumentAuth.PASSKEY }, () => (
|
.with({ documentAuthType: DocumentAuth.PASSKEY }, () => (
|
||||||
|
|||||||
@ -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,12 +22,18 @@ 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: emailHasAccount ? `/signin#email=${email}` : `/signup#email=${email}`,
|
redirectPath,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
toast({
|
toast({
|
||||||
@ -49,9 +55,13 @@ export const DocumentSigningAuthPageView = ({
|
|||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
<Trans>
|
{email ? (
|
||||||
You need to be logged in as <strong>{email}</strong> to view this page.
|
<Trans>
|
||||||
</Trans>
|
You need to be logged in as <strong>{email}</strong> to view this page.
|
||||||
|
</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>You need to be logged in to view this page.</Trans>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -24,7 +24,10 @@ type PasskeyData = {
|
|||||||
isError: boolean;
|
isError: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SigningAuthRecipient = Pick<Recipient, 'authOptions' | 'email' | 'role' | 'name' | 'token'>;
|
type SigningAuthRecipient = Pick<
|
||||||
|
Recipient,
|
||||||
|
'authOptions' | 'email' | 'role' | 'name' | 'token' | 'id'
|
||||||
|
>;
|
||||||
|
|
||||||
export type DocumentSigningAuthContextValue = {
|
export type DocumentSigningAuthContextValue = {
|
||||||
executeActionAuthProcedure: (_value: ExecuteActionAuthProcedureOptions) => Promise<void>;
|
executeActionAuthProcedure: (_value: ExecuteActionAuthProcedureOptions) => Promise<void>;
|
||||||
@ -37,7 +40,6 @@ export type DocumentSigningAuthContextValue = {
|
|||||||
derivedRecipientAccessAuth: TRecipientAccessAuthTypes[];
|
derivedRecipientAccessAuth: TRecipientAccessAuthTypes[];
|
||||||
derivedRecipientActionAuth: TRecipientActionAuthTypes[];
|
derivedRecipientActionAuth: TRecipientActionAuthTypes[];
|
||||||
isAuthRedirectRequired: boolean;
|
isAuthRedirectRequired: boolean;
|
||||||
isDirectTemplate?: boolean;
|
|
||||||
isCurrentlyAuthenticating: boolean;
|
isCurrentlyAuthenticating: boolean;
|
||||||
setIsCurrentlyAuthenticating: (_value: boolean) => void;
|
setIsCurrentlyAuthenticating: (_value: boolean) => void;
|
||||||
passkeyData: PasskeyData;
|
passkeyData: PasskeyData;
|
||||||
@ -66,7 +68,6 @@ export const useRequiredDocumentSigningAuthContext = () => {
|
|||||||
export interface DocumentSigningAuthProviderProps {
|
export interface DocumentSigningAuthProviderProps {
|
||||||
documentAuthOptions: Envelope['authOptions'];
|
documentAuthOptions: Envelope['authOptions'];
|
||||||
recipient: SigningAuthRecipient;
|
recipient: SigningAuthRecipient;
|
||||||
isDirectTemplate?: boolean;
|
|
||||||
user?: SessionUser | null;
|
user?: SessionUser | null;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
@ -74,7 +75,6 @@ export interface DocumentSigningAuthProviderProps {
|
|||||||
export const DocumentSigningAuthProvider = ({
|
export const DocumentSigningAuthProvider = ({
|
||||||
documentAuthOptions: initialDocumentAuthOptions,
|
documentAuthOptions: initialDocumentAuthOptions,
|
||||||
recipient: initialRecipient,
|
recipient: initialRecipient,
|
||||||
isDirectTemplate = false,
|
|
||||||
user,
|
user,
|
||||||
children,
|
children,
|
||||||
}: DocumentSigningAuthProviderProps) => {
|
}: DocumentSigningAuthProviderProps) => {
|
||||||
@ -204,7 +204,6 @@ export const DocumentSigningAuthProvider = ({
|
|||||||
derivedRecipientAccessAuth,
|
derivedRecipientAccessAuth,
|
||||||
derivedRecipientActionAuth,
|
derivedRecipientActionAuth,
|
||||||
isAuthRedirectRequired,
|
isAuthRedirectRequired,
|
||||||
isDirectTemplate,
|
|
||||||
isCurrentlyAuthenticating,
|
isCurrentlyAuthenticating,
|
||||||
setIsCurrentlyAuthenticating,
|
setIsCurrentlyAuthenticating,
|
||||||
passkeyData,
|
passkeyData,
|
||||||
|
|||||||
@ -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 } from '@lingui/react/macro';
|
import { Trans, useLingui } 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,7 +18,9 @@ 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';
|
||||||
@ -45,6 +47,7 @@ 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;
|
||||||
@ -53,6 +56,12 @@ 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({
|
||||||
@ -63,6 +72,13 @@ 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,
|
||||||
@ -72,15 +88,19 @@ 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, user } = useRequiredDocumentSigningAuthContext();
|
const { derivedRecipientAccessAuth } = useRequiredDocumentSigningAuthContext();
|
||||||
|
|
||||||
const form = useForm<TNextSignerFormSchema>({
|
const form = useForm<TNextSignerFormSchema>({
|
||||||
resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined,
|
resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined,
|
||||||
@ -90,6 +110,14 @@ 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(
|
||||||
@ -109,12 +137,23 @@ 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);
|
||||||
@ -126,7 +165,7 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
? { name: data.name, email: data.email }
|
? { name: data.name, email: data.email }
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
await onSignatureComplete(nextSigner, data.accessAuthOptions);
|
await onSignatureComplete(nextSigner, data.accessAuthOptions, directRecipient);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = AppError.parseError(error);
|
const err = AppError.parseError(error);
|
||||||
|
|
||||||
@ -152,21 +191,19 @@ 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="lg"
|
size={buttonSize}
|
||||||
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>
|
||||||
@ -176,106 +213,97 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent position={position}>
|
||||||
|
<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}>
|
<>
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
|
||||||
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
|
{directTemplatePayload && !directTemplatePayload.email && (
|
||||||
<DialogTitle>
|
<Form {...directRecipientForm}>
|
||||||
<div className="text-foreground text-xl font-semibold">
|
<div className="mb-4 flex flex-col gap-4">
|
||||||
{match(recipient.role)
|
<div className="flex flex-col gap-4 md:flex-row">
|
||||||
.with(RecipientRole.VIEWER, () => <Trans>Complete Viewing</Trans>)
|
<FormField
|
||||||
.with(RecipientRole.SIGNER, () => <Trans>Complete Signing</Trans>)
|
control={directRecipientForm.control}
|
||||||
.with(RecipientRole.APPROVER, () => <Trans>Complete Approval</Trans>)
|
name="name"
|
||||||
.with(RecipientRole.CC, () => <Trans>Complete Viewing</Trans>)
|
render={({ field }) => (
|
||||||
.with(RecipientRole.ASSISTANT, () => <Trans>Complete Assisting</Trans>)
|
<FormItem className="flex-1">
|
||||||
.exhaustive()}
|
<FormLabel>
|
||||||
|
<Trans>Your Name</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} className="mt-2" placeholder={t`Enter your name`} />
|
||||||
|
</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`}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogTitle>
|
</Form>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="text-muted-foreground max-w-[50ch]">
|
<Form {...form}>
|
||||||
{match(recipient.role)
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
.with(RecipientRole.VIEWER, () => (
|
{allowDictateNextSigner && defaultNextSigner && (
|
||||||
<span>
|
<div className="mb-4 flex flex-col gap-4">
|
||||||
<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}
|
||||||
@ -283,13 +311,13 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex-1">
|
<FormItem className="flex-1">
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
<Trans>Name</Trans>
|
<Trans>Next Recipient Name</Trans>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
{...field}
|
{...field}
|
||||||
className="mt-2"
|
className="mt-2"
|
||||||
placeholder="Enter the next signer's name"
|
placeholder={t`Enter the next signer's name`}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
@ -304,14 +332,14 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex-1">
|
<FormItem className="flex-1">
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
<Trans>Email</Trans>
|
<Trans>Next Recipient Email</Trans>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
{...field}
|
{...field}
|
||||||
type="email"
|
type="email"
|
||||||
className="mt-2"
|
className="mt-2"
|
||||||
placeholder="Enter the next signer's email"
|
placeholder={t`Enter the next signer's email`}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@ -319,17 +347,14 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
<DocumentSigningDisclosure className="mt-4" />
|
<DocumentSigningDisclosure />
|
||||||
|
|
||||||
<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}
|
||||||
@ -339,8 +364,7 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="flex-1"
|
disabled={!isComplete}
|
||||||
disabled={!isComplete || !isNextSignerValid}
|
|
||||||
loading={form.formState.isSubmitting}
|
loading={form.formState.isSubmitting}
|
||||||
>
|
>
|
||||||
{match(recipient.role)
|
{match(recipient.role)
|
||||||
@ -351,11 +375,11 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
.with(RecipientRole.ASSISTANT, () => <Trans>Complete</Trans>)
|
.with(RecipientRole.ASSISTANT, () => <Trans>Complete</Trans>)
|
||||||
.exhaustive()}
|
.exhaustive()}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
</DialogFooter>
|
</form>
|
||||||
</fieldset>
|
</Form>
|
||||||
</form>
|
</fieldset>
|
||||||
</Form>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showTwoFactorForm && (
|
{showTwoFactorForm && (
|
||||||
|
|||||||
@ -0,0 +1,123 @@
|
|||||||
|
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 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 { 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-2xl">
|
||||||
|
<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 />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,16 +1,20 @@
|
|||||||
import { lazy } from 'react';
|
import { lazy, useMemo } 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 } from 'lucide-react';
|
import { ArrowLeftIcon, BanIcon, DownloadCloudIcon, PaperclipIcon } 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 { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||||
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';
|
||||||
@ -23,6 +27,8 @@ import { DocumentSigningAttachmentsPopover } from '../document-signing/document-
|
|||||||
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(
|
||||||
@ -33,15 +39,30 @@ export const DocumentSigningPageViewV2 = () => {
|
|||||||
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
isDirectTemplate,
|
||||||
envelope,
|
envelope,
|
||||||
recipient,
|
recipient,
|
||||||
recipientFields,
|
recipientFields,
|
||||||
recipientFieldsRemaining,
|
recipientFieldsRemaining,
|
||||||
showPendingFieldTooltip,
|
requiredRecipientFields,
|
||||||
|
selectedAssistantRecipientFields,
|
||||||
} = useRequiredEnvelopeSigningContext();
|
} = useRequiredEnvelopeSigningContext();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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="h-screen w-screen bg-gray-50">
|
<div className="dark:bg-background min-h-screen w-screen bg-gray-50">
|
||||||
<SignFieldEmailDialog.Root />
|
<SignFieldEmailDialog.Root />
|
||||||
<SignFieldTextDialog.Root />
|
<SignFieldTextDialog.Root />
|
||||||
<SignFieldNumberDialog.Root />
|
<SignFieldNumberDialog.Root />
|
||||||
@ -49,19 +70,29 @@ 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-73px)] w-screen">
|
<div className="flex h-[calc(100vh-4rem)] w-screen">
|
||||||
{/* Left Section - Step Navigation */}
|
{/* Left Section - Step Navigation */}
|
||||||
<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="bg-background border-border hidden w-80 flex-shrink-0 flex-col overflow-y-auto border-r py-4 lg:flex">
|
||||||
<div className="px-4">
|
<div className="px-4">
|
||||||
<h3 className="flex items-end justify-between text-sm font-semibold text-gray-900">
|
<h3 className="text-foreground flex items-end justify-between text-sm font-semibold">
|
||||||
<Trans>Sign Document</Trans>
|
{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)}
|
||||||
|
|
||||||
<span className="text-muted-foreground ml-2 rounded border bg-gray-50 px-2 py-0.5 text-xs">
|
<span className="text-muted-foreground bg-muted/50 ml-2 rounded border px-2 py-0.5 text-xs">
|
||||||
<Trans>{recipientFieldsRemaining.length} fields remaining</Trans>
|
<Plural
|
||||||
|
value={recipientFieldsRemaining.length}
|
||||||
|
one="1 Field Remaining"
|
||||||
|
other="# Fields Remaining"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
@ -71,7 +102,7 @@ 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 / recipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
|
width: `${100 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -84,31 +115,54 @@ export const DocumentSigningPageViewV2 = () => {
|
|||||||
<Separator className="my-6" />
|
<Separator className="my-6" />
|
||||||
|
|
||||||
{/* Quick Actions. */}
|
{/* Quick Actions. */}
|
||||||
<div className="space-y-3 px-4">
|
{!isDirectTemplate && (
|
||||||
<h4 className="text-sm font-semibold text-gray-900">
|
<div className="space-y-3 px-4">
|
||||||
<Trans>Actions</Trans>
|
<h4 className="text-foreground text-sm font-semibold">
|
||||||
</h4>
|
<Trans>Actions</Trans>
|
||||||
|
</h4>
|
||||||
|
|
||||||
<div className="w-full">
|
<DocumentSigningAttachmentsPopover
|
||||||
<DocumentSigningAttachmentsPopover envelopeId={envelope.id} token={recipient.token} />
|
envelopeId={envelope.id}
|
||||||
|
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 && (
|
||||||
|
<DocumentSigningRejectDialog
|
||||||
|
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
|
||||||
|
token={recipient.token}
|
||||||
|
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>
|
||||||
|
)}
|
||||||
{/* Todo: Allow selecting which document to download and/or the original */}
|
|
||||||
<Button variant="ghost" size="sm" className="w-full justify-start">
|
|
||||||
<DownloadCloudIcon className="mr-2 h-4 w-4" />
|
|
||||||
<Trans>Download Original</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Todo: Envelopes */}
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Footer of left sidebar. */}
|
{/* Footer of left sidebar. */}
|
||||||
<div className="mt-auto px-4">
|
<div className="mt-auto px-4">
|
||||||
@ -121,47 +175,34 @@ export const DocumentSigningPageViewV2 = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Content - Changes based on current step */}
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<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 */}
|
||||||
<div className="flex h-fit space-x-2 overflow-x-auto p-4">
|
{envelopeItems.length > 1 && (
|
||||||
{envelopeItems.map((doc, i) => (
|
<div className="flex h-fit space-x-2 overflow-x-auto p-2 pt-4 sm:p-4">
|
||||||
<EnvelopeItemSelector
|
{envelopeItems.map((doc, i) => (
|
||||||
key={doc.id}
|
<EnvelopeItemSelector
|
||||||
number={i + 1}
|
key={doc.id}
|
||||||
primaryText={doc.title}
|
number={i + 1}
|
||||||
secondaryText={
|
primaryText={doc.title}
|
||||||
<Plural
|
secondaryText={
|
||||||
one="1 Field"
|
<Plural
|
||||||
other="# Fields"
|
one="1 Field"
|
||||||
value={
|
other="# Fields"
|
||||||
recipientFieldsRemaining.filter((field) => field.envelopeItemId === doc.id)
|
value={
|
||||||
.length
|
remainingFields.filter((field) => field.envelopeItemId === doc.id).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="mt-4 flex justify-center p-4">
|
<div className="flex flex-col items-center justify-center p-2 sm:mt-4 sm: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
|
||||||
key={currentEnvelopeItem.id}
|
key={currentEnvelopeItem.id}
|
||||||
@ -175,6 +216,11 @@ export const DocumentSigningPageViewV2 = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Mobile widget - Additional padding to allow users to scroll */}
|
||||||
|
<div className="block pb-16 md:hidden">
|
||||||
|
<DocumentSigningMobileWidget />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -39,12 +39,14 @@ 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();
|
||||||
@ -108,9 +110,11 @@ export function DocumentSigningRejectDialog({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="outline">
|
{trigger ?? (
|
||||||
<Trans>Reject Document</Trans>
|
<Button variant="outline">
|
||||||
</Button>
|
<Trans>Reject Document</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
|||||||
@ -1,21 +1,29 @@
|
|||||||
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 { 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;
|
||||||
@ -32,7 +40,8 @@ export type EnvelopeSigningContextValue = {
|
|||||||
recipient: EnvelopeForSigningResponse['recipient'];
|
recipient: EnvelopeForSigningResponse['recipient'];
|
||||||
recipientFieldsRemaining: Field[];
|
recipientFieldsRemaining: Field[];
|
||||||
recipientFields: Field[];
|
recipientFields: Field[];
|
||||||
selectedRecipientFields: Field[];
|
requiredRecipientFields: 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'>;
|
||||||
@ -85,26 +94,31 @@ export const EnvelopeSigningProvider = ({
|
|||||||
|
|
||||||
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
||||||
|
|
||||||
const {
|
const isDirectTemplate = envelope.type === EnvelopeType.TEMPLATE;
|
||||||
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: newRecipientFields,
|
fields: prev.recipient.fields.map((field) =>
|
||||||
|
field.id === data.signedField.id ? data.signedField : field,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
@ -148,6 +162,49 @@ 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.
|
||||||
*/
|
*/
|
||||||
@ -181,22 +238,8 @@ 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(() => {
|
||||||
* The fields that are still required to be signed by the current recipient.
|
return assistantFields.filter((field) => field.recipientId === selectedAssistantRecipient?.id);
|
||||||
*/
|
|
||||||
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]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -242,7 +285,11 @@ export const EnvelopeSigningProvider = ({
|
|||||||
}, [envelope.documentMeta?.signingOrder, envelope.recipients, recipient.id]);
|
}, [envelope.documentMeta?.signingOrder, envelope.recipients, recipient.id]);
|
||||||
|
|
||||||
const signField = async (fieldId: number, fieldValue: TSignEnvelopeFieldValue) => {
|
const signField = async (fieldId: number, fieldValue: TSignEnvelopeFieldValue) => {
|
||||||
console.log('insertField', fieldId, fieldValue);
|
// Set the field locally for direct templates.
|
||||||
|
if (isDirectTemplate) {
|
||||||
|
handleDirectTemplateFieldInsertion(fieldId, fieldValue);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await signEnvelopeField({
|
await signEnvelopeField({
|
||||||
token: envelopeData.recipient.token,
|
token: envelopeData.recipient.token,
|
||||||
@ -252,9 +299,67 @@ export const EnvelopeSigningProvider = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<EnvelopeSigningContext.Provider
|
<EnvelopeSigningContext.Provider
|
||||||
value={{
|
value={{
|
||||||
|
isDirectTemplate,
|
||||||
fullName,
|
fullName,
|
||||||
setFullName,
|
setFullName,
|
||||||
email,
|
email,
|
||||||
@ -270,6 +375,7 @@ export const EnvelopeSigningProvider = ({
|
|||||||
recipient,
|
recipient,
|
||||||
recipientFieldsRemaining,
|
recipientFieldsRemaining,
|
||||||
recipientFields,
|
recipientFields,
|
||||||
|
requiredRecipientFields,
|
||||||
nextRecipient,
|
nextRecipient,
|
||||||
|
|
||||||
otherRecipientCompletedFields,
|
otherRecipientCompletedFields,
|
||||||
@ -277,7 +383,7 @@ export const EnvelopeSigningProvider = ({
|
|||||||
assistantFields,
|
assistantFields,
|
||||||
setSelectedAssistantRecipientId,
|
setSelectedAssistantRecipientId,
|
||||||
selectedAssistantRecipient,
|
selectedAssistantRecipient,
|
||||||
selectedRecipientFields,
|
selectedAssistantRecipientFields,
|
||||||
|
|
||||||
signField,
|
signField,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ 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,
|
||||||
@ -24,6 +25,8 @@ 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({
|
||||||
@ -33,7 +36,11 @@ const ZAttachmentFormSchema = z.object({
|
|||||||
|
|
||||||
type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>;
|
type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>;
|
||||||
|
|
||||||
export const DocumentAttachmentsPopover = ({ envelopeId }: DocumentAttachmentsPopoverProps) => {
|
export const DocumentAttachmentsPopover = ({
|
||||||
|
envelopeId,
|
||||||
|
buttonClassName,
|
||||||
|
buttonSize,
|
||||||
|
}: DocumentAttachmentsPopoverProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
|
||||||
@ -118,7 +125,7 @@ export const DocumentAttachmentsPopover = ({ envelopeId }: DocumentAttachmentsPo
|
|||||||
return (
|
return (
|
||||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="outline" className="gap-2">
|
<Button variant="outline" className={cn('gap-2', buttonClassName)} size={buttonSize}>
|
||||||
<Paperclip className="h-4 w-4" />
|
<Paperclip className="h-4 w-4" />
|
||||||
|
|
||||||
<span>
|
<span>
|
||||||
@ -215,9 +222,6 @@ export const DocumentAttachmentsPopover = ({ envelopeId }: DocumentAttachmentsPo
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<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"
|
||||||
@ -230,6 +234,9 @@ export const DocumentAttachmentsPopover = ({ envelopeId }: DocumentAttachmentsPo
|
|||||||
>
|
>
|
||||||
<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>
|
||||||
|
|||||||
@ -4,7 +4,10 @@ import { Trans } from '@lingui/react/macro';
|
|||||||
import type { DocumentData, EnvelopeItem } from '@prisma/client';
|
import type { DocumentData, EnvelopeItem } from '@prisma/client';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
import {
|
||||||
|
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';
|
||||||
@ -92,6 +95,60 @@ export const DocumentCertificateQRView = ({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{internalVersion === 2 ? (
|
||||||
|
<EnvelopeRenderProvider envelope={{ envelopeItems }}>
|
||||||
|
<DocumentCertificateQrV2
|
||||||
|
title={title}
|
||||||
|
recipientCount={recipientCount}
|
||||||
|
formattedDate={formattedDate}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<ShareDocumentDownloadButton
|
||||||
|
title={title}
|
||||||
|
documentData={envelopeItems[0].documentData}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12 w-full">
|
||||||
|
<PDFViewer key={envelopeItems[0].id} documentData={envelopeItems[0].documentData} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type DocumentCertificateQrV2Props = {
|
||||||
|
title: string;
|
||||||
|
recipientCount: number;
|
||||||
|
formattedDate: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DocumentCertificateQrV2 = ({
|
||||||
|
title,
|
||||||
|
recipientCount,
|
||||||
|
formattedDate,
|
||||||
|
}: DocumentCertificateQrV2Props) => {
|
||||||
|
const { currentEnvelopeItem } = 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>
|
||||||
@ -106,21 +163,18 @@ export const DocumentCertificateQRView = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ShareDocumentDownloadButton title={title} documentData={envelopeItems[0].documentData} />
|
{currentEnvelopeItem && (
|
||||||
|
<ShareDocumentDownloadButton
|
||||||
|
title={title}
|
||||||
|
documentData={currentEnvelopeItem.documentData}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-12 w-full">
|
<div className="mt-12 w-full">
|
||||||
{internalVersion === 2 ? (
|
<EnvelopeRendererFileSelector className="mb-4 p-0" fields={[]} secondaryOverride={''} />
|
||||||
<EnvelopeRenderProvider envelope={{ envelopeItems }}>
|
|
||||||
<EnvelopeRendererFileSelector className="mb-4 p-0" fields={[]} secondaryOverride={''} />
|
|
||||||
|
|
||||||
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
|
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
|
||||||
</EnvelopeRenderProvider>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<PDFViewer key={envelopeItems[0].id} documentData={envelopeItems[0].documentData} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -95,6 +95,10 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
|
|||||||
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({
|
||||||
|
|||||||
@ -14,6 +14,8 @@ 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 { 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;
|
||||||
};
|
};
|
||||||
@ -59,6 +61,7 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps
|
|||||||
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>
|
||||||
@ -92,6 +95,20 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
))
|
))
|
||||||
|
.with({ isComplete: true, internalVersion: 2 }, () => (
|
||||||
|
<EnvelopeDownloadDialog
|
||||||
|
envelopeId={envelope.id}
|
||||||
|
envelopeStatus={envelope.status}
|
||||||
|
envelopeItems={envelope.envelopeItems}
|
||||||
|
token={recipient?.token}
|
||||||
|
trigger={
|
||||||
|
<Button className="w-full">
|
||||||
|
<Download className="-ml-1 mr-2 inline h-4 w-4" />
|
||||||
|
<Trans>Download</Trans>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))
|
||||||
.with({ isComplete: true }, () => (
|
.with({ isComplete: true }, () => (
|
||||||
<Button className="w-full" onClick={onDownloadClick}>
|
<Button className="w-full" onClick={onDownloadClick}>
|
||||||
<Download className="-ml-1 mr-2 inline h-4 w-4" />
|
<Download className="-ml-1 mr-2 inline h-4 w-4" />
|
||||||
|
|||||||
@ -36,6 +36,7 @@ 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';
|
||||||
|
|
||||||
@ -146,17 +147,36 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isComplete && (
|
{envelope.internalVersion === 2 ? (
|
||||||
<DropdownMenuItem onClick={onDownloadClick}>
|
<EnvelopeDownloadDialog
|
||||||
<Download className="mr-2 h-4 w-4" />
|
envelopeId={envelope.id}
|
||||||
<Trans>Download</Trans>
|
envelopeStatus={envelope.status}
|
||||||
</DropdownMenuItem>
|
token={recipient?.token}
|
||||||
)}
|
envelopeItems={envelope.envelopeItems}
|
||||||
|
trigger={
|
||||||
|
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||||
|
<div>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Download</Trans>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{isComplete && (
|
||||||
|
<DropdownMenuItem onClick={onDownloadClick}>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Download</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
<DropdownMenuItem onClick={onDownloadOriginalClick}>
|
<DropdownMenuItem onClick={onDownloadOriginalClick}>
|
||||||
<Download className="mr-2 h-4 w-4" />
|
<Download className="mr-2 h-4 w-4" />
|
||||||
<Trans>Download Original</Trans>
|
<Trans>Download Original</Trans>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link to={`${documentsPath}/${envelope.id}/logs`}>
|
<Link to={`${documentsPath}/${envelope.id}/logs`}>
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
|
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,
|
||||||
@ -12,7 +15,7 @@ import {
|
|||||||
PlusIcon,
|
PlusIcon,
|
||||||
UserIcon,
|
UserIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Link } from 'react-router';
|
import { Link, useSearchParams } 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';
|
||||||
@ -24,6 +27,12 @@ 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 = {
|
||||||
@ -37,8 +46,24 @@ 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">
|
||||||
@ -69,7 +94,7 @@ export const DocumentPageViewRecipients = ({
|
|||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{recipients.map((recipient) => (
|
{recipients.map((recipient, i) => (
|
||||||
<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()}
|
||||||
@ -159,15 +184,33 @@ 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 && (
|
||||||
<CopyTextButton
|
<TooltipProvider>
|
||||||
value={formatSigningLink(recipient.token)}
|
<Tooltip open={shouldHighlightCopyButtons && i === 0}>
|
||||||
onCopySuccess={() => {
|
<TooltipTrigger asChild>
|
||||||
toast({
|
<div
|
||||||
title: _(msg`Copied to clipboard`),
|
className={shouldHighlightCopyButtons ? 'animate-pulse' : ''}
|
||||||
description: _(msg`The signing link has been copied to your clipboard.`),
|
onClick={() => setShouldHighlightCopyButtons(false)}
|
||||||
});
|
>
|
||||||
}}
|
<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>
|
||||||
|
|||||||
@ -108,6 +108,10 @@ export const DocumentUploadButton = ({ className }: DocumentUploadButtonProps) =
|
|||||||
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({
|
||||||
|
|||||||
@ -4,6 +4,7 @@ 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';
|
||||||
|
|
||||||
@ -51,7 +52,7 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
|||||||
(timezone) => timezone === Intl.DateTimeFormat().resolvedOptions().timeZone,
|
(timezone) => timezone === Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { quota, remaining, refreshLimits } = useLimits();
|
const { quota, remaining, refreshLimits, maximumEnvelopeItemCount } = useLimits();
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
@ -69,6 +70,7 @@ 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]);
|
||||||
|
|
||||||
@ -138,6 +140,10 @@ 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({
|
||||||
@ -151,12 +157,23 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFileDropRejected = () => {
|
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({
|
toast({
|
||||||
title:
|
title: t`Upload failed`,
|
||||||
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',
|
||||||
@ -176,6 +193,7 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
|||||||
onDrop={onFileDrop}
|
onDrop={onFileDrop}
|
||||||
onDropRejected={onFileDropRejected}
|
onDropRejected={onFileDropRejected}
|
||||||
type="envelope"
|
type="envelope"
|
||||||
|
maxFiles={maximumEnvelopeItemCount}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|||||||
@ -96,7 +96,7 @@ export const EnvelopeEditorFieldDragDrop = ({
|
|||||||
selectedRecipientId,
|
selectedRecipientId,
|
||||||
selectedEnvelopeItemId,
|
selectedEnvelopeItemId,
|
||||||
}: EnvelopeEditorFieldDragDropProps) => {
|
}: EnvelopeEditorFieldDragDropProps) => {
|
||||||
const { envelope, editorFields, isTemplate } = useCurrentEnvelopeEditor();
|
const { envelope, editorFields, isTemplate, getRecipientColorKey } = useCurrentEnvelopeEditor();
|
||||||
|
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
@ -262,6 +262,10 @@ 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">
|
||||||
@ -273,12 +277,23 @@ 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="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"
|
className={cn(
|
||||||
|
'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 group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
|
'text-muted-foreground font-noto 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" />}
|
||||||
@ -291,9 +306,9 @@ export const EnvelopeEditorFieldDragDrop = ({
|
|||||||
{selectedField && (
|
{selectedField && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'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]',
|
'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]',
|
||||||
// selectedSignerStyles?.base,
|
RECIPIENT_COLOR_STYLES[selectedRecipientColor].base,
|
||||||
RECIPIENT_COLOR_STYLES.yellow.base, // Todo: Envelopes
|
selectedField === FieldType.SIGNATURE && 'font-signature',
|
||||||
{
|
{
|
||||||
'-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,
|
||||||
|
|||||||
@ -3,15 +3,12 @@ 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';
|
||||||
@ -21,32 +18,16 @@ 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 pageContext = usePageContext();
|
const { t, i18n } = useLingui();
|
||||||
|
|
||||||
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 { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
|
||||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
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[]>([]);
|
||||||
@ -54,10 +35,17 @@ 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 viewport = useMemo(
|
const {
|
||||||
() => page.getViewport({ scale, rotation: rotate }),
|
stage,
|
||||||
[page, rotate, scale],
|
pageLayer,
|
||||||
);
|
canvasElement,
|
||||||
|
konvaContainer,
|
||||||
|
pageContext,
|
||||||
|
scaledViewport,
|
||||||
|
unscaledViewport,
|
||||||
|
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer));
|
||||||
|
|
||||||
|
const { _className, scale } = pageContext;
|
||||||
|
|
||||||
const localPageFields = useMemo(
|
const localPageFields = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@ -68,47 +56,7 @@ 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) {
|
||||||
@ -120,6 +68,7 @@ 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,
|
||||||
@ -130,7 +79,8 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
skipShadow: true,
|
skipShadow: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { height: pageHeight, width: pageWidth } = getBoundingClientRect(container);
|
const pageHeight = scaledViewport.height;
|
||||||
|
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;
|
||||||
@ -165,8 +115,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderFieldOnLayer = (field: TLocalField) => {
|
const renderFieldOnLayer = (field: TLocalField) => {
|
||||||
if (!pageLayer.current || !interactiveTransformer.current) {
|
if (!pageLayer.current) {
|
||||||
console.error('Layer not loaded yet');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -174,7 +123,8 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
const isFieldEditable =
|
const isFieldEditable =
|
||||||
recipient !== undefined && canRecipientFieldsBeModified(recipient, envelope.fields);
|
recipient !== undefined && canRecipientFieldsBeModified(recipient, envelope.fields);
|
||||||
|
|
||||||
const { fieldGroup, isFirstRender } = renderField({
|
const { fieldGroup } = renderField({
|
||||||
|
scale,
|
||||||
pageLayer: pageLayer.current,
|
pageLayer: pageLayer.current,
|
||||||
field: {
|
field: {
|
||||||
renderId: field.formId,
|
renderId: field.formId,
|
||||||
@ -183,8 +133,9 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
inserted: false,
|
inserted: false,
|
||||||
fieldMeta: field.fieldMeta,
|
fieldMeta: field.fieldMeta,
|
||||||
},
|
},
|
||||||
pageWidth: viewport.width,
|
translations: getClientSideFieldTranslations(i18n),
|
||||||
pageHeight: viewport.height,
|
pageWidth: unscaledViewport.width,
|
||||||
|
pageHeight: unscaledViewport.height,
|
||||||
color: getRecipientColorKey(field.recipientId),
|
color: getRecipientColorKey(field.recipientId),
|
||||||
editable: isFieldEditable,
|
editable: isFieldEditable,
|
||||||
mode: 'edit',
|
mode: 'edit',
|
||||||
@ -210,24 +161,14 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create the initial Konva page canvas and initialize all fields and interactions.
|
* Initialize the Konva page canvas and all fields and interactions.
|
||||||
*/
|
*/
|
||||||
const createPageCanvas = (container: HTMLDivElement) => {
|
const createPageCanvas = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
|
||||||
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(stage.current, pageLayer.current);
|
interactiveTransformer.current = createInteractiveTransformer(currentStage, currentPageLayer);
|
||||||
|
|
||||||
// Render the fields.
|
// Render the fields.
|
||||||
for (const field of localPageFields) {
|
for (const field of localPageFields) {
|
||||||
@ -235,12 +176,12 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle stage click to deselect.
|
// Handle stage click to deselect.
|
||||||
stage.current?.on('click', (e) => {
|
currentStage.on('mousedown', (e) => {
|
||||||
removePendingField();
|
removePendingField();
|
||||||
|
|
||||||
if (e.target === stage.current) {
|
if (e.target === stage.current) {
|
||||||
setSelectedFields([]);
|
setSelectedFields([]);
|
||||||
pageLayer.current?.batchDraw();
|
currentPageLayer.batchDraw();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -267,12 +208,12 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
setSelectedFields([e.target]);
|
setSelectedFields([e.target]);
|
||||||
};
|
};
|
||||||
|
|
||||||
stage.current?.on('dragstart', onDragStartOrEnd);
|
currentStage.on('dragstart', onDragStartOrEnd);
|
||||||
stage.current?.on('dragend', onDragStartOrEnd);
|
currentStage.on('dragend', onDragStartOrEnd);
|
||||||
stage.current?.on('transformstart', () => setIsFieldChanging(true));
|
currentStage.on('transformstart', () => setIsFieldChanging(true));
|
||||||
stage.current?.on('transformend', () => setIsFieldChanging(false));
|
currentStage.on('transformend', () => setIsFieldChanging(false));
|
||||||
|
|
||||||
pageLayer.current.batchDraw();
|
currentPageLayer.batchDraw();
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -284,7 +225,10 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
* - Selecting multiple fields
|
* - Selecting multiple fields
|
||||||
* - Selecting empty area to create fields
|
* - Selecting empty area to create fields
|
||||||
*/
|
*/
|
||||||
const createInteractiveTransformer = (stage: Konva.Stage, layer: Konva.Layer) => {
|
const createInteractiveTransformer = (
|
||||||
|
currentStage: Konva.Stage,
|
||||||
|
currentPageLayer: Konva.Layer,
|
||||||
|
) => {
|
||||||
const transformer = new Konva.Transformer({
|
const transformer = new Konva.Transformer({
|
||||||
rotateEnabled: false,
|
rotateEnabled: false,
|
||||||
keepRatio: false,
|
keepRatio: false,
|
||||||
@ -301,36 +245,36 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
layer.add(transformer);
|
currentPageLayer.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,
|
||||||
});
|
});
|
||||||
layer.add(selectionRectangle);
|
currentPageLayer.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;
|
||||||
|
|
||||||
stage.on('mousedown touchstart', (e) => {
|
currentStage.on('mousedown touchstart', (e) => {
|
||||||
// do nothing if we mousedown on any shape
|
// do nothing if we mousedown on any shape
|
||||||
if (e.target !== stage) {
|
if (e.target !== currentStage) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pointerPosition = stage.getPointerPosition();
|
const pointerPosition = currentStage.getPointerPosition();
|
||||||
|
|
||||||
if (!pointerPosition) {
|
if (!pointerPosition) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
x1 = pointerPosition.x;
|
x1 = pointerPosition.x / scale;
|
||||||
y1 = pointerPosition.y;
|
y1 = pointerPosition.y / scale;
|
||||||
x2 = pointerPosition.x;
|
x2 = pointerPosition.x / scale;
|
||||||
y2 = pointerPosition.y;
|
y2 = pointerPosition.y / scale;
|
||||||
|
|
||||||
selectionRectangle.setAttrs({
|
selectionRectangle.setAttrs({
|
||||||
x: x1,
|
x: x1,
|
||||||
@ -341,7 +285,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
stage.on('mousemove touchmove', () => {
|
currentStage.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;
|
||||||
@ -349,14 +293,14 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
|
|
||||||
selectionRectangle.moveToTop();
|
selectionRectangle.moveToTop();
|
||||||
|
|
||||||
const pointerPosition = stage.getPointerPosition();
|
const pointerPosition = currentStage.getPointerPosition();
|
||||||
|
|
||||||
if (!pointerPosition) {
|
if (!pointerPosition) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
x2 = pointerPosition.x;
|
x2 = pointerPosition.x / scale;
|
||||||
y2 = pointerPosition.y;
|
y2 = pointerPosition.y / scale;
|
||||||
|
|
||||||
selectionRectangle.setAttrs({
|
selectionRectangle.setAttrs({
|
||||||
x: Math.min(x1, x2),
|
x: Math.min(x1, x2),
|
||||||
@ -366,7 +310,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
stage.on('mouseup touchend', () => {
|
currentStage.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;
|
||||||
@ -377,38 +321,41 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
selectionRectangle.visible(false);
|
selectionRectangle.visible(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
const stageFieldGroups = stage.find('.field-group') || [];
|
const stageFieldGroups = currentStage.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 &&
|
||||||
box.width > MIN_FIELD_WIDTH_PX &&
|
unscaledBoxWidth > MIN_FIELD_WIDTH_PX &&
|
||||||
box.height > MIN_FIELD_HEIGHT_PX &&
|
unscaledBoxHeight > 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,
|
x: box.x / scale,
|
||||||
y: box.y,
|
y: box.y / scale,
|
||||||
width: box.width,
|
width: unscaledBoxWidth,
|
||||||
height: box.height,
|
height: unscaledBoxHeight,
|
||||||
fill: 'rgba(24, 160, 251, 0.3)',
|
fill: 'rgba(24, 160, 251, 0.3)',
|
||||||
});
|
});
|
||||||
|
|
||||||
layer.add(pendingFieldCreation);
|
currentPageLayer.add(pendingFieldCreation);
|
||||||
setPendingFieldCreation(pendingFieldCreation);
|
setPendingFieldCreation(pendingFieldCreation);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clicks should select/deselect shapes
|
// Clicks should select/deselect shapes
|
||||||
stage.on('click tap', function (e) {
|
currentStage.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() &&
|
||||||
@ -419,7 +366,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If empty area clicked, remove all selections
|
// If empty area clicked, remove all selections
|
||||||
if (e.target === stage) {
|
if (e.target === stage.current) {
|
||||||
setSelectedFields([]);
|
setSelectedFields([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -468,20 +415,15 @@ 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();
|
||||||
|
|
||||||
@ -555,15 +497,13 @@ 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,
|
pageWidth: unscaledViewport.width,
|
||||||
pageHeight,
|
pageHeight: unscaledViewport.height,
|
||||||
});
|
});
|
||||||
|
|
||||||
editorFields.addField({
|
editorFields.addField({
|
||||||
@ -597,7 +537,10 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}>
|
<div
|
||||||
|
className="relative w-full"
|
||||||
|
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
|
||||||
|
>
|
||||||
{selectedKonvaFieldGroups.length > 0 &&
|
{selectedKonvaFieldGroups.length > 0 &&
|
||||||
interactiveTransformer.current &&
|
interactiveTransformer.current &&
|
||||||
!isFieldChanging && (
|
!isFieldChanging && (
|
||||||
@ -649,17 +592,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: pendingFieldCreation.y() + pendingFieldCreation.getClientRect().height + 5 + 'px',
|
top:
|
||||||
left: pendingFieldCreation.x() + pendingFieldCreation.getClientRect().width / 2 + 'px',
|
pendingFieldCreation.y() * scale +
|
||||||
|
pendingFieldCreation.getClientRect().height +
|
||||||
|
5 +
|
||||||
|
'px',
|
||||||
|
left:
|
||||||
|
pendingFieldCreation.x() * scale +
|
||||||
|
pendingFieldCreation.getClientRect().width / 2 +
|
||||||
|
'px',
|
||||||
transform: 'translateX(-50%)',
|
transform: 'translateX(-50%)',
|
||||||
zIndex: 50,
|
zIndex: 50,
|
||||||
}}
|
}}
|
||||||
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="text-muted-foreground grid w-max grid-cols-5 gap-x-1 gap-y-0.5 rounded-md border bg-white p-1 shadow-sm"
|
||||||
>
|
>
|
||||||
{fieldButtonList.map((field) => (
|
{fieldButtonList.map((field) => (
|
||||||
<button
|
<button
|
||||||
@ -673,13 +622,15 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="konva-container absolute inset-0 z-10" ref={konvaContainer}></div>
|
{/* 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}
|
||||||
width={viewport.width}
|
height={scaledViewport.height}
|
||||||
|
width={scaledViewport.width}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -5,6 +5,7 @@ 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';
|
||||||
|
|
||||||
@ -20,6 +21,7 @@ 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';
|
||||||
@ -37,6 +39,7 @@ 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';
|
||||||
@ -60,8 +63,8 @@ const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
|
|||||||
[FieldType.DROPDOWN]: msg`Dropdown Settings`,
|
[FieldType.DROPDOWN]: msg`Dropdown Settings`,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EnvelopeEditorPageFields = () => {
|
export const EnvelopeEditorFieldsPage = () => {
|
||||||
const { envelope, editorFields } = useCurrentEnvelopeEditor();
|
const { envelope, editorFields, relativePath } = useCurrentEnvelopeEditor();
|
||||||
|
|
||||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||||
|
|
||||||
@ -104,12 +107,12 @@ export const EnvelopeEditorPageFields = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-full">
|
<div className="relative flex h-full">
|
||||||
<div className="flex w-full flex-col">
|
<div className="flex w-full flex-col overflow-y-auto">
|
||||||
{/* 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 justify-center">
|
<div className="mt-4 flex h-full justify-center p-4">
|
||||||
{currentEnvelopeItem !== null ? (
|
{currentEnvelopeItem !== null ? (
|
||||||
<PDFViewerKonvaLazy customPageRenderer={EnvelopeEditorFieldsPageRenderer} />
|
<PDFViewerKonvaLazy customPageRenderer={EnvelopeEditorFieldsPageRenderer} />
|
||||||
) : (
|
) : (
|
||||||
@ -128,17 +131,23 @@ export const EnvelopeEditorPageFields = () => {
|
|||||||
|
|
||||||
{/* Right Section - Form Fields Panel */}
|
{/* Right Section - Form Fields Panel */}
|
||||||
{currentEnvelopeItem && (
|
{currentEnvelopeItem && (
|
||||||
<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">
|
<div className="bg-background border-border sticky top-0 h-full w-80 flex-shrink-0 overflow-y-auto border-l py-4">
|
||||||
{/* Recipient selector section. */}
|
{/* Recipient selector section. */}
|
||||||
<section className="px-4">
|
<section className="px-4">
|
||||||
<h3 className="mb-2 text-sm font-semibold text-gray-900">
|
<h3 className="text-foreground mb-2 text-sm font-semibold">
|
||||||
<Trans>Selected Recipient</Trans>
|
<Trans>Selected Recipient</Trans>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{envelope.recipients.length === 0 ? (
|
{envelope.recipients.length === 0 ? (
|
||||||
<Alert variant="warning">
|
<Alert variant="warning">
|
||||||
<AlertDescription>
|
<AlertDescription className="flex flex-col gap-2">
|
||||||
<Trans>You need at least one recipient to add fields</Trans>
|
<Trans>You need at least one recipient to add fields</Trans>
|
||||||
|
|
||||||
|
<Link to={`${relativePath.editorPath}`} className="text-sm">
|
||||||
|
<p>
|
||||||
|
<Trans>Click here to add a recipient</Trans>
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : (
|
) : (
|
||||||
@ -170,7 +179,7 @@ export const EnvelopeEditorPageFields = () => {
|
|||||||
|
|
||||||
{/* Add fields section. */}
|
{/* Add fields section. */}
|
||||||
<section className="px-4">
|
<section className="px-4">
|
||||||
<h3 className="mb-2 text-sm font-semibold text-gray-900">
|
<h3 className="text-foreground mb-2 text-sm font-semibold">
|
||||||
<Trans>Add Fields</Trans>
|
<Trans>Add Fields</Trans>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
@ -182,7 +191,7 @@ export const EnvelopeEditorPageFields = () => {
|
|||||||
|
|
||||||
{/* Field details section. */}
|
{/* Field details section. */}
|
||||||
<AnimateGenericFadeInOut key={editorFields.selectedField?.formId}>
|
<AnimateGenericFadeInOut key={editorFields.selectedField?.formId}>
|
||||||
{selectedField && selectedField.type !== FieldType.SIGNATURE && (
|
{selectedField && (
|
||||||
<section>
|
<section>
|
||||||
<Separator className="my-4" />
|
<Separator className="my-4" />
|
||||||
|
|
||||||
@ -192,6 +201,12 @@ export const EnvelopeEditorPageFields = () => {
|
|||||||
</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}
|
||||||
@ -13,7 +13,6 @@ 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';
|
||||||
@ -24,7 +23,6 @@ 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';
|
||||||
@ -32,30 +30,34 @@ import { EnvelopeItemTitleInput } from './envelope-editor-title-input';
|
|||||||
export default function EnvelopeEditorHeader() {
|
export default function EnvelopeEditorHeader() {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
const team = useCurrentTeam();
|
const {
|
||||||
|
envelope,
|
||||||
const { envelope, isDocument, isTemplate, updateEnvelope, autosaveError } =
|
isDocument,
|
||||||
useCurrentEnvelopeEditor();
|
isTemplate,
|
||||||
|
updateEnvelope,
|
||||||
// Todo: Envelopes this probably won't work with embed? Maybe hide the back items when no team?
|
autosaveError,
|
||||||
|
relativePath,
|
||||||
const rootPath = isDocument ? formatDocumentsPath(team.url) : formatTemplatesPath(team.url);
|
editorFields,
|
||||||
|
} = useCurrentEnvelopeEditor();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="w-full border-b border-gray-200 bg-white px-6 py-3">
|
<nav className="bg-background border-border w-full border-b px-4 py-3 md:px-6">
|
||||||
<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({
|
||||||
title,
|
data: {
|
||||||
|
title,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
placeholder={t`Envelope Title`}
|
placeholder={t`Envelope Title`}
|
||||||
@ -132,7 +134,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} />
|
<DocumentAttachmentsPopover envelopeId={envelope.id} buttonSize="sm" />
|
||||||
|
|
||||||
<EnvelopeEditorSettingsDialog
|
<EnvelopeEditorSettingsDialog
|
||||||
trigger={
|
trigger={
|
||||||
@ -145,7 +147,11 @@ 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" />
|
||||||
@ -168,10 +174,11 @@ 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={rootPath}
|
documentRootPath={relativePath.documentRootPath}
|
||||||
trigger={
|
trigger={
|
||||||
<Button size="sm">
|
<Button size="sm">
|
||||||
<Trans>Use Template</Trans>
|
<Trans>Use Template</Trans>
|
||||||
|
|||||||
@ -1,176 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { lazy, useEffect, useState } from 'react';
|
import { lazy, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { FileTextIcon } from 'lucide-react';
|
import { ConstructionIcon, FileTextIcon } from 'lucide-react';
|
||||||
|
|
||||||
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';
|
||||||
@ -13,11 +13,9 @@ import { Separator } from '@documenso/ui/primitives/separator';
|
|||||||
|
|
||||||
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
||||||
|
|
||||||
const EnvelopeEditorPagePreviewRenderer = lazy(
|
const EnvelopeGenericPageRenderer = lazy(async () => import('./envelope-generic-page-renderer'));
|
||||||
async () => import('./envelope-editor-page-preview-renderer'),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const EnvelopeEditorPagePreview = () => {
|
export const EnvelopeEditorPreviewPage = () => {
|
||||||
const { envelope, editorFields } = useCurrentEnvelopeEditor();
|
const { envelope, editorFields } = useCurrentEnvelopeEditor();
|
||||||
|
|
||||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||||
@ -35,7 +33,7 @@ export const EnvelopeEditorPagePreview = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-full">
|
<div className="relative flex h-full">
|
||||||
<div className="flex w-full flex-col">
|
<div className="flex w-full flex-col overflow-y-auto">
|
||||||
{/* Horizontal envelope item selector */}
|
{/* Horizontal envelope item selector */}
|
||||||
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
|
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
|
||||||
|
|
||||||
@ -50,25 +48,41 @@ export const EnvelopeEditorPagePreview = () => {
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
{currentEnvelopeItem !== null ? (
|
{/* Coming soon section */}
|
||||||
<PDFViewerKonvaLazy customPageRenderer={EnvelopeEditorPagePreviewRenderer} />
|
<div className="border-border bg-card hover:bg-accent/10 flex w-full max-w-[800px] items-center gap-4 rounded-lg border p-4 transition-colors">
|
||||||
) : (
|
<div className="flex w-full flex-col items-center justify-center gap-2 py-32">
|
||||||
<div className="flex flex-col items-center justify-center py-32">
|
<ConstructionIcon className="text-muted-foreground h-10 w-10" />
|
||||||
<FileTextIcon className="text-muted-foreground h-10 w-10" />
|
<h3 className="text-foreground text-sm font-semibold">
|
||||||
<p className="text-foreground mt-1 text-sm">
|
<Trans>Coming soon</Trans>
|
||||||
<Trans>No documents found</Trans>
|
</h3>
|
||||||
</p>
|
<p className="text-muted-foreground text-sm">
|
||||||
<p className="text-muted-foreground mt-1 text-sm">
|
<Trans>This feature is coming soon</Trans>
|
||||||
<Trans>Please upload a document to continue</Trans>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
|
{/* Todo: Envelopes - Remove div after preview mode is implemented */}
|
||||||
|
<div className="hidden">
|
||||||
|
{currentEnvelopeItem !== null ? (
|
||||||
|
<PDFViewerKonvaLazy 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Section - Form Fields Panel */}
|
{/* Right Section - Form Fields Panel */}
|
||||||
{currentEnvelopeItem && false && (
|
{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">
|
<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. */}
|
{/* Add fields section. */}
|
||||||
<section className="px-4">
|
<section className="px-4">
|
||||||
{/* <h3 className="mb-2 text-sm font-semibold text-gray-900">
|
{/* <h3 className="mb-2 text-sm font-semibold text-gray-900">
|
||||||
@ -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 { prop, sortBy } from 'remeda';
|
import { isDeepEqual, 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,7 +75,6 @@ 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),
|
||||||
});
|
});
|
||||||
@ -83,7 +82,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 } = useCurrentEnvelopeEditor();
|
const { envelope, setRecipientsDebounced, updateEnvelope } = useCurrentEnvelopeEditor();
|
||||||
|
|
||||||
const organisation = useCurrentOrganisation();
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
@ -149,8 +148,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Always show advanced settings if any recipient has auth options.
|
const recipientHasAuthSettings = useMemo(() => {
|
||||||
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);
|
||||||
|
|
||||||
@ -166,7 +164,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
return recipientHasAuthOptions !== undefined || formHasActionAuth !== undefined;
|
return recipientHasAuthOptions !== undefined || formHasActionAuth !== undefined;
|
||||||
}, [recipients, form]);
|
}, [recipients, form]);
|
||||||
|
|
||||||
const [showAdvancedSettings, setShowAdvancedSettings] = useState(alwaysShowAdvancedSettings);
|
const [showAdvancedSettings, setShowAdvancedSettings] = useState(recipientHasAuthSettings);
|
||||||
const [showSigningOrderConfirmation, setShowSigningOrderConfirmation] = useState(false);
|
const [showSigningOrderConfirmation, setShowSigningOrderConfirmation] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -451,6 +449,8 @@ 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,15 +460,61 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const validatedFormValues = ZEnvelopeRecipientsForm.safeParse(formValues);
|
const formValueSigners = formValues.signers || [];
|
||||||
|
|
||||||
if (validatedFormValues.success) {
|
// Remove the last signer if it's empty.
|
||||||
console.log('validatedFormValues', validatedFormValues);
|
const nonEmptyRecipients = formValueSigners.filter((signer, i) => {
|
||||||
|
if (i === formValueSigners.length - 1 && signer.email === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const validatedFormValues = ZEnvelopeRecipientsForm.safeParse({
|
||||||
|
...formValues,
|
||||||
|
signers: nonEmptyRecipients,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!validatedFormValues.success) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = validatedFormValues;
|
||||||
|
|
||||||
|
const hasSigningOrderChanged = envelope.documentMeta.signingOrder !== data.signingOrder;
|
||||||
|
const hasAllowDictateNextSignerChanged =
|
||||||
|
envelope.documentMeta.allowDictateNextSigner !== data.allowDictateNextSigner;
|
||||||
|
|
||||||
|
const hasSignersChanged =
|
||||||
|
data.signers.length !== recipients.length ||
|
||||||
|
data.signers.some((signer) => {
|
||||||
|
const recipient = recipients.find((recipient) => recipient.id === signer.id);
|
||||||
|
|
||||||
|
if (!recipient) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
signer.email !== recipient.email ||
|
||||||
|
signer.name !== recipient.name ||
|
||||||
|
signer.role !== recipient.role ||
|
||||||
|
signer.signingOrder !== recipient.signingOrder ||
|
||||||
|
!isDeepEqual(signer.actionAuth, recipient.authOptions?.actionAuth)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasSignersChanged) {
|
||||||
setRecipientsDebounced(validatedFormValues.data.signers);
|
setRecipientsDebounced(validatedFormValues.data.signers);
|
||||||
|
}
|
||||||
|
|
||||||
// Todo: Envelopes - Need to save the other data as well
|
if (hasSigningOrderChanged || hasAllowDictateNextSignerChanged) {
|
||||||
// setEnvelope
|
updateEnvelope({
|
||||||
|
meta: {
|
||||||
|
signingOrder: validatedFormValues.data.signingOrder,
|
||||||
|
allowDictateNextSigner: validatedFormValues.data.allowDictateNextSigner,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [formValues]);
|
}, [formValues]);
|
||||||
|
|
||||||
@ -508,18 +554,17 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}>
|
<AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<div className="-mt-2 mb-2 space-y-4 rounded-md bg-gray-50/80 p-4">
|
<div className="bg-accent/50 -mt-2 mb-2 space-y-4 rounded-md p-4">
|
||||||
{!alwaysShowAdvancedSettings && organisation.organisationClaim.flags.cfr21 && (
|
{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="text-muted-foreground ml-2 text-sm"
|
className="ml-2 text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
htmlFor="showAdvancedRecipientSettings"
|
htmlFor="showAdvancedRecipientSettings"
|
||||||
>
|
>
|
||||||
<Trans>Show advanced settings</Trans>
|
<Trans>Show advanced settings</Trans>
|
||||||
@ -678,44 +723,171 @@ 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('grid grid-cols-10 items-end gap-2 pb-2', {
|
className={cn('pb-2', {
|
||||||
'border-b pt-2': showAdvancedSettings,
|
'border-b pb-4':
|
||||||
'grid-cols-12 pr-3': isSigningOrderSequential,
|
showAdvancedSettings && index !== signers.length - 1,
|
||||||
|
'pt-2': showAdvancedSettings && index === 0,
|
||||||
|
'pr-3': isSigningOrderSequential,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{isSigningOrderSequential && (
|
<div className="flex flex-row items-center gap-x-2">
|
||||||
|
{isSigningOrderSequential && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`signers.${index}.signingOrder`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem
|
||||||
|
className={cn(
|
||||||
|
'mt-auto flex items-center gap-x-1 space-y-0',
|
||||||
|
{
|
||||||
|
'mb-6':
|
||||||
|
form.formState.errors.signers?.[index] &&
|
||||||
|
!form.formState.errors.signers[index]?.signingOrder,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" />
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
max={signers.length}
|
||||||
|
data-testid="signing-order-input"
|
||||||
|
className={cn(
|
||||||
|
'w-10 text-center',
|
||||||
|
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
|
||||||
|
)}
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => {
|
||||||
|
field.onChange(e);
|
||||||
|
handleSigningOrderChange(index, e.target.value);
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
field.onBlur();
|
||||||
|
handleSigningOrderChange(index, e.target.value);
|
||||||
|
}}
|
||||||
|
disabled={
|
||||||
|
snapshot.isDragging ||
|
||||||
|
isSubmitting ||
|
||||||
|
!canRecipientBeModified(signer.id)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name={`signers.${index}.signingOrder`}
|
name={`signers.${index}.email`}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem
|
<FormItem
|
||||||
className={cn(
|
className={cn('relative w-full', {
|
||||||
'col-span-1 mt-auto flex items-center gap-x-1 space-y-0',
|
'mb-6':
|
||||||
{
|
form.formState.errors.signers?.[index] &&
|
||||||
'mb-6':
|
!form.formState.errors.signers[index]?.email,
|
||||||
form.formState.errors.signers?.[index] &&
|
})}
|
||||||
!form.formState.errors.signers[index]?.signingOrder,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" />
|
{!showAdvancedSettings && index === 0 && (
|
||||||
|
<FormLabel required>
|
||||||
|
<Trans>Email</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
)}
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<RecipientAutoCompleteInput
|
||||||
type="number"
|
type="email"
|
||||||
max={signers.length}
|
placeholder={t`Email`}
|
||||||
data-testid="signing-order-input"
|
value={field.value}
|
||||||
className={cn(
|
disabled={
|
||||||
'w-full text-center',
|
snapshot.isDragging ||
|
||||||
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
|
isSubmitting ||
|
||||||
)}
|
!canRecipientBeModified(signer.id)
|
||||||
{...field}
|
}
|
||||||
onChange={(e) => {
|
options={recipientSuggestions}
|
||||||
field.onChange(e);
|
onSelect={(suggestion) =>
|
||||||
handleSigningOrderChange(index, e.target.value);
|
handleRecipientAutoCompleteSelect(index, suggestion)
|
||||||
|
}
|
||||||
|
onSearchQueryChange={(query) => {
|
||||||
|
field.onChange(query);
|
||||||
|
setRecipientSearchQuery(query);
|
||||||
}}
|
}}
|
||||||
onBlur={(e) => {
|
loading={isLoading}
|
||||||
field.onBlur();
|
data-testid="signer-email-input"
|
||||||
handleSigningOrderChange(index, e.target.value);
|
maxLength={254}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</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={
|
disabled={
|
||||||
snapshot.isDragging ||
|
snapshot.isDragging ||
|
||||||
@ -724,106 +896,29 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
<FormField
|
<Button
|
||||||
control={form.control}
|
variant="ghost"
|
||||||
name={`signers.${index}.email`}
|
className={cn('mt-auto px-2', {
|
||||||
render={({ field }) => (
|
'mb-6': form.formState.errors.signers?.[index],
|
||||||
<FormItem
|
})}
|
||||||
className={cn('relative', {
|
data-testid="remove-signer-button"
|
||||||
'mb-6':
|
disabled={
|
||||||
form.formState.errors.signers?.[index] &&
|
snapshot.isDragging ||
|
||||||
!form.formState.errors.signers[index]?.email,
|
isSubmitting ||
|
||||||
'col-span-4': !showAdvancedSettings,
|
!canRecipientBeModified(signer.id) ||
|
||||||
'col-span-5': showAdvancedSettings,
|
signers.length === 1
|
||||||
})}
|
}
|
||||||
>
|
onClick={() => onRemoveSigner(index)}
|
||||||
{!showAdvancedSettings && index === 0 && (
|
>
|
||||||
<FormLabel required>
|
<TrashIcon className="h-4 w-4" />
|
||||||
<Trans>Email</Trans>
|
</Button>
|
||||||
</FormLabel>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<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
|
|
||||||
control={form.control}
|
|
||||||
name={`signers.${index}.name`}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem
|
|
||||||
className={cn({
|
|
||||||
'mb-6':
|
|
||||||
form.formState.errors.signers?.[index] &&
|
|
||||||
!form.formState.errors.signers[index]?.name,
|
|
||||||
'col-span-4': !showAdvancedSettings,
|
|
||||||
'col-span-5': showAdvancedSettings,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{!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>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{showAdvancedSettings &&
|
{showAdvancedSettings &&
|
||||||
organisation.organisationClaim.flags.cfr21 && (
|
organisation.organisationClaim.flags.cfr21 && (
|
||||||
@ -832,11 +927,11 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
name={`signers.${index}.actionAuth`}
|
name={`signers.${index}.actionAuth`}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem
|
<FormItem
|
||||||
className={cn('col-span-8', {
|
className={cn('mt-2 w-full', {
|
||||||
'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,
|
||||||
'col-span-10': isSigningOrderSequential,
|
'pl-6': isSigningOrderSequential,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@ -856,60 +951,6 @@ 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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -215,7 +215,6 @@ export const EnvelopeEditorSettingsDialog = ({
|
|||||||
|
|
||||||
const { mutateAsync: updateEnvelope } = trpc.envelope.update.useMutation();
|
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);
|
||||||
@ -243,7 +242,6 @@ export const EnvelopeEditorSettingsDialog = ({
|
|||||||
try {
|
try {
|
||||||
await updateEnvelope({
|
await updateEnvelope({
|
||||||
envelopeId: envelope.id,
|
envelopeId: envelope.id,
|
||||||
envelopeType: envelope.type,
|
|
||||||
data: {
|
data: {
|
||||||
externalId: data.externalId || null,
|
externalId: data.externalId || null,
|
||||||
visibility: data.visibility,
|
visibility: data.visibility,
|
||||||
@ -302,8 +300,6 @@ export const EnvelopeEditorSettingsDialog = ({
|
|||||||
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) {
|
||||||
@ -358,7 +354,7 @@ export const EnvelopeEditorSettingsDialog = ({
|
|||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
<fieldset
|
<fieldset
|
||||||
className="flex min-h-[45rem] w-full flex-col space-y-6 px-6 pt-6"
|
className="flex h-[45rem] max-h-[calc(100vh-14rem)] w-full flex-col space-y-6 overflow-y-auto px-6 pt-6"
|
||||||
disabled={form.formState.isSubmitting}
|
disabled={form.formState.isSubmitting}
|
||||||
key={activeTab}
|
key={activeTab}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -7,12 +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 { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||||
import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope';
|
import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope';
|
||||||
@ -26,9 +30,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';
|
||||||
@ -41,11 +45,13 @@ type LocalFile = {
|
|||||||
isError: boolean;
|
isError: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EnvelopeEditorPageUpload = () => {
|
export const EnvelopeEditorUploadPage = () => {
|
||||||
const team = useCurrentTeam();
|
const organisation = useCurrentOrganisation();
|
||||||
const { t } = useLingui();
|
|
||||||
|
|
||||||
const { envelope, setLocalEnvelope } = useCurrentEnvelopeEditor();
|
const { t } = useLingui();
|
||||||
|
const { envelope, setLocalEnvelope, relativePath } = useCurrentEnvelopeEditor();
|
||||||
|
const { maximumEnvelopeItemCount, remaining } = useLimits();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
const [localFiles, setLocalFiles] = useState<LocalFile[]>(
|
const [localFiles, setLocalFiles] = useState<LocalFile[]>(
|
||||||
envelope.envelopeItems
|
envelope.envelopeItems
|
||||||
@ -136,7 +142,7 @@ export const EnvelopeEditorPageUpload = () => {
|
|||||||
|
|
||||||
const { createdEnvelopeItems } = await createEnvelopeItems({
|
const { createdEnvelopeItems } = await createEnvelopeItems({
|
||||||
envelopeId: envelope.id,
|
envelopeId: envelope.id,
|
||||||
items: envelopeItemsToCreate,
|
data: envelopeItemsToCreate,
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
||||||
@ -220,12 +226,56 @@ export const EnvelopeEditorPageUpload = () => {
|
|||||||
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>Documents</CardTitle>
|
<CardTitle>
|
||||||
<CardDescription>Add and configure multiple documents</CardDescription>
|
<Trans>Documents</Trans>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
<Trans>Add and configure multiple documents</Trans>
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@ -233,9 +283,11 @@ export const EnvelopeEditorPageUpload = () => {
|
|||||||
onDrop={onFileDrop}
|
onDrop={onFileDrop}
|
||||||
allowMultiple
|
allowMultiple
|
||||||
className="pb-4 pt-6"
|
className="pb-4 pt-6"
|
||||||
disabled={!canItemsBeModified}
|
disabled={dropzoneDisabledMessage !== null}
|
||||||
disabledMessage={msg`Cannot upload items after the document has been sent`}
|
disabledMessage={dropzoneDisabledMessage || undefined}
|
||||||
disabledHeading={msg`Upload disabled`}
|
disabledHeading={msg`Upload disabled`}
|
||||||
|
maxFiles={maximumEnvelopeItemCount - localFiles.length}
|
||||||
|
onDropRejected={onFileDropRejected}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Uploaded Files List */}
|
{/* Uploaded Files List */}
|
||||||
@ -256,7 +308,7 @@ export const EnvelopeEditorPageUpload = () => {
|
|||||||
ref={provided.innerRef}
|
ref={provided.innerRef}
|
||||||
{...provided.draggableProps}
|
{...provided.draggableProps}
|
||||||
style={provided.draggableProps.style}
|
style={provided.draggableProps.style}
|
||||||
className={`flex items-center justify-between rounded-lg bg-gray-50 p-3 transition-shadow ${
|
className={`bg-accent/50 flex items-center justify-between rounded-lg p-3 transition-shadow ${
|
||||||
snapshot.isDragging ? 'shadow-md' : ''
|
snapshot.isDragging ? 'shadow-md' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -282,7 +334,7 @@ export const EnvelopeEditorPageUpload = () => {
|
|||||||
<p className="text-sm font-medium">{localFile.title}</p>
|
<p className="text-sm font-medium">{localFile.title}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="text-xs text-gray-500">
|
<div className="text-muted-foreground text-xs">
|
||||||
{localFile.isUploading ? (
|
{localFile.isUploading ? (
|
||||||
<Trans>Uploading</Trans>
|
<Trans>Uploading</Trans>
|
||||||
) : localFile.isError ? (
|
) : localFile.isError ? (
|
||||||
@ -295,7 +347,7 @@ export const EnvelopeEditorPageUpload = () => {
|
|||||||
<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="h-4 w-4 animate-spin text-gray-500" />
|
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -338,7 +390,7 @@ export const EnvelopeEditorPageUpload = () => {
|
|||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link to={`/t/${team.url}/documents/${envelope.id}/edit?step=addFields`}>
|
<Link to={`${relativePath.editorPath}?step=addFields`}>
|
||||||
<Trans>Add Fields</Trans>
|
<Trans>Add Fields</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@ -24,7 +24,6 @@ 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';
|
||||||
@ -32,17 +31,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 { EnvelopeEditorPageFields } from './envelope-editor-page-fields';
|
import { EnvelopeEditorPreviewPage } from './envelope-editor-preview-page';
|
||||||
import { EnvelopeEditorPagePreview } from './envelope-editor-page-preview';
|
import { EnvelopeEditorUploadPage } from './envelope-editor-upload-page';
|
||||||
import { EnvelopeEditorPageUpload } from './envelope-editor-page-upload';
|
|
||||||
|
|
||||||
type EnvelopeEditorStep = 'upload' | 'addFields' | 'preview';
|
type EnvelopeEditorStep = 'upload' | 'addFields' | 'preview';
|
||||||
|
|
||||||
@ -74,10 +73,16 @@ export default function EnvelopeEditor() {
|
|||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const team = useCurrentTeam();
|
|
||||||
|
|
||||||
const { envelope, isDocument, isTemplate, isAutosaving, flushAutosave } =
|
const {
|
||||||
useCurrentEnvelopeEditor();
|
envelope,
|
||||||
|
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);
|
||||||
@ -100,13 +105,10 @@ 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);
|
||||||
|
|
||||||
flushAutosave();
|
void flushAutosave();
|
||||||
|
|
||||||
if (!isStepLoading && isAutosaving) {
|
if (!isStepLoading && isAutosaving) {
|
||||||
setIsStepLoading(true);
|
setIsStepLoading(true);
|
||||||
@ -128,6 +130,18 @@ 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);
|
||||||
@ -138,20 +152,22 @@ export default function EnvelopeEditor() {
|
|||||||
envelopeEditorSteps.find((step) => step.id === currentStep) || envelopeEditorSteps[0];
|
envelopeEditorSteps.find((step) => step.id === currentStep) || envelopeEditorSteps[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen w-screen bg-gray-50">
|
<div className="dark:bg-background h-screen w-screen bg-gray-50">
|
||||||
<EnvelopeEditorHeader />
|
<EnvelopeEditorHeader />
|
||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<div className="flex h-[calc(100vh-73px)] w-screen">
|
<div className="flex h-[calc(100vh-4rem)] w-screen">
|
||||||
{/* Left Section - Step Navigation */}
|
{/* Left Section - Step Navigation */}
|
||||||
<div className="flex w-80 flex-shrink-0 flex-col overflow-y-auto border-r border-gray-200 bg-white py-4">
|
<div className="bg-background border-border flex w-80 flex-shrink-0 flex-col overflow-y-auto border-r py-4">
|
||||||
{/* Left section step selector. */}
|
{/* Left section step selector. */}
|
||||||
<div className="px-4">
|
<div className="px-4">
|
||||||
<h3 className="flex items-end justify-between text-sm font-semibold text-gray-900">
|
<h3 className="text-foreground flex items-end justify-between text-sm font-semibold">
|
||||||
{isDocument ? <Trans>Document Editor</Trans> : <Trans>Template Editor</Trans>}
|
{isDocument ? <Trans>Document Editor</Trans> : <Trans>Template Editor</Trans>}
|
||||||
|
|
||||||
<span className="text-muted-foreground ml-2 rounded border bg-gray-50 px-2 py-0.5 text-xs">
|
<span className="text-muted-foreground bg-muted/50 ml-2 rounded border px-2 py-0.5 text-xs">
|
||||||
Step {currentStepData.order}/{envelopeEditorSteps.length}
|
<Trans context="The step counter">
|
||||||
|
Step {currentStepData.order}/{envelopeEditorSteps.length}
|
||||||
|
</Trans>
|
||||||
</span>
|
</span>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
@ -176,15 +192,17 @@ 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'
|
? 'border border-green-200 bg-green-50 dark:border-green-500/20 dark:bg-green-500/10'
|
||||||
: 'border border-gray-200 hover:bg-gray-50'
|
: 'border border-gray-200 hover:bg-gray-50 dark:border-gray-400/20 dark:hover:bg-gray-400/10'
|
||||||
}`}
|
}`}
|
||||||
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 ? 'border-green-200 bg-green-50' : 'border-gray-100 bg-gray-100'
|
isActive
|
||||||
|
? '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
|
||||||
@ -194,12 +212,14 @@ export default function EnvelopeEditor() {
|
|||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
className={`text-sm font-medium ${
|
className={`text-sm font-medium ${
|
||||||
isActive ? 'text-green-900' : 'text-gray-700'
|
isActive
|
||||||
|
? 'text-green-900 dark:text-green-400'
|
||||||
|
: 'text-foreground dark:text-muted-foreground'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t(step.title)}
|
{t(step.title)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500">{t(step.description)}</div>
|
<div className="text-muted-foreground text-xs">{t(step.description)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -212,12 +232,25 @@ 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-sm font-semibold text-gray-900">
|
<h4 className="text-foreground text-sm font-semibold">
|
||||||
<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" />
|
||||||
@ -239,16 +272,6 @@ 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
|
||||||
@ -283,11 +306,17 @@ export default function EnvelopeEditor() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Todo: Allow selecting which document to download and/or the original */}
|
<EnvelopeDownloadDialog
|
||||||
<Button variant="ghost" size="sm" className="w-full justify-start">
|
envelopeId={envelope.id}
|
||||||
<DownloadCloudIcon className="mr-2 h-4 w-4" />
|
envelopeStatus={envelope.status}
|
||||||
<Trans>Download PDF</Trans>
|
envelopeItems={envelope.envelopeItems}
|
||||||
</Button>
|
trigger={
|
||||||
|
<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"
|
||||||
@ -309,7 +338,7 @@ export default function EnvelopeEditor() {
|
|||||||
open={isDeleteDialogOpen}
|
open={isDeleteDialogOpen}
|
||||||
onOpenChange={setDeleteDialogOpen}
|
onOpenChange={setDeleteDialogOpen}
|
||||||
onDelete={async () => {
|
onDelete={async () => {
|
||||||
await navigate(documentsPath);
|
await navigate(relativePath.documentRootPath);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@ -318,7 +347,7 @@ export default function EnvelopeEditor() {
|
|||||||
open={isDeleteDialogOpen}
|
open={isDeleteDialogOpen}
|
||||||
onOpenChange={setDeleteDialogOpen}
|
onOpenChange={setDeleteDialogOpen}
|
||||||
onDelete={async () => {
|
onDelete={async () => {
|
||||||
await navigate(templatesPath);
|
await navigate(relativePath.templateRootPath);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -326,7 +355,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={isDocument ? documentsPath : templatesPath}>
|
<Link to={relativePath.basePath}>
|
||||||
<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>
|
||||||
@ -339,17 +368,14 @@ export default function EnvelopeEditor() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Content - Changes based on current step */}
|
{/* Main Content - Changes based on current step */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<AnimateGenericFadeInOut className="flex-1 overflow-y-auto" key={currentStep}>
|
||||||
<p>{isAutosaving ? 'Autosaving...' : 'Not autosaving'}</p>
|
{match({ currentStep, isStepLoading })
|
||||||
<AnimateGenericFadeInOut key={currentStep}>
|
.with({ isStepLoading: true }, () => <SpinnerBox className="py-32" />)
|
||||||
{match({ currentStep, isStepLoading })
|
.with({ currentStep: 'upload' }, () => <EnvelopeEditorUploadPage />)
|
||||||
.with({ isStepLoading: true }, () => <SpinnerBox className="py-32" />)
|
.with({ currentStep: 'addFields' }, () => <EnvelopeEditorFieldsPage />)
|
||||||
.with({ currentStep: 'upload' }, () => <EnvelopeEditorPageUpload />)
|
.with({ currentStep: 'preview' }, () => <EnvelopeEditorPreviewPage />)
|
||||||
.with({ currentStep: 'addFields' }, () => <EnvelopeEditorPageFields />)
|
.exhaustive()}
|
||||||
.with({ currentStep: 'preview' }, () => <EnvelopeEditorPagePreview />)
|
</AnimateGenericFadeInOut>
|
||||||
.exhaustive()}
|
|
||||||
</AnimateGenericFadeInOut>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -20,16 +20,17 @@ export const EnvelopeItemSelector = ({
|
|||||||
}: EnvelopeItemSelectorProps) => {
|
}: EnvelopeItemSelectorProps) => {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={`flex min-w-0 cursor-pointer items-center space-x-3 rounded-lg border px-4 py-3 transition-colors ${
|
title={typeof primaryText === 'string' ? primaryText : undefined}
|
||||||
|
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-blue-200 bg-blue-50 text-blue-900'
|
? 'border-green-200 bg-green-50 text-green-900 dark:border-green-400/30 dark:bg-green-400/10 dark:text-green-400'
|
||||||
: 'border-gray-200 bg-gray-50 hover:bg-gray-100'
|
: 'border-border bg-muted/50 hover:bg-muted/70'
|
||||||
}`}
|
}`}
|
||||||
{...buttonProps}
|
{...buttonProps}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`flex h-6 w-6 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-blue-100 text-blue-600' : 'bg-gray-200 text-gray-600'
|
isSelected ? 'bg-green-100 text-green-600' : 'bg-gray-200 text-gray-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{number}
|
{number}
|
||||||
@ -39,8 +40,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 rounded-full', {
|
className={cn('h-2 w-2 flex-shrink-0 rounded-full', {
|
||||||
'bg-blue-500': isSelected,
|
'bg-green-500': isSelected,
|
||||||
})}
|
})}
|
||||||
></div>
|
></div>
|
||||||
</button>
|
</button>
|
||||||
@ -61,7 +62,7 @@ export const EnvelopeRendererFileSelector = ({
|
|||||||
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex h-fit space-x-2 overflow-x-auto p-4', className)}>
|
<div className={cn('flex h-fit flex-shrink-0 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}
|
||||||
|
|||||||
@ -1,41 +1,32 @@
|
|||||||
import { useEffect, useMemo, useRef } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
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';
|
||||||
|
|
||||||
export default function EnvelopeGenericPageRenderer() {
|
export default function EnvelopeGenericPageRenderer() {
|
||||||
const pageContext = usePageContext();
|
const { i18n } = useLingui();
|
||||||
|
|
||||||
if (!pageContext) {
|
const { currentEnvelopeItem, fields, getRecipientColorKey } = useCurrentEnvelopeRender();
|
||||||
throw new Error('Unable to find Page context.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { _className, page, rotate, scale } = pageContext;
|
const {
|
||||||
|
stage,
|
||||||
|
pageLayer,
|
||||||
|
canvasElement,
|
||||||
|
konvaContainer,
|
||||||
|
pageContext,
|
||||||
|
scaledViewport,
|
||||||
|
unscaledViewport,
|
||||||
|
} = usePageRenderer(({ stage, pageLayer }) => {
|
||||||
|
createPageCanvas(stage, pageLayer);
|
||||||
|
});
|
||||||
|
|
||||||
if (!page) {
|
const { _className, scale } = pageContext;
|
||||||
throw new Error('Attempted to render page canvas, but no page was specified.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { t } = useLingui();
|
|
||||||
const { currentEnvelopeItem, fields } = 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(
|
const localPageFields = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@ -46,44 +37,6 @@ export default function EnvelopeGenericPageRenderer() {
|
|||||||
[fields, pageContext.pageNumber],
|
[fields, 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: TEnvelope['fields'][number]) => {
|
const renderFieldOnLayer = (field: TEnvelope['fields'][number]) => {
|
||||||
if (!pageLayer.current) {
|
if (!pageLayer.current) {
|
||||||
console.error('Layer not loaded yet');
|
console.error('Layer not loaded yet');
|
||||||
@ -91,6 +44,7 @@ export default function EnvelopeGenericPageRenderer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderField({
|
renderField({
|
||||||
|
scale,
|
||||||
pageLayer: pageLayer.current,
|
pageLayer: pageLayer.current,
|
||||||
field: {
|
field: {
|
||||||
renderId: field.id.toString(),
|
renderId: field.id.toString(),
|
||||||
@ -103,39 +57,29 @@ export default function EnvelopeGenericPageRenderer() {
|
|||||||
inserted: false,
|
inserted: false,
|
||||||
fieldMeta: field.fieldMeta,
|
fieldMeta: field.fieldMeta,
|
||||||
},
|
},
|
||||||
pageWidth: viewport.width,
|
translations: getClientSideFieldTranslations(i18n),
|
||||||
pageHeight: viewport.height,
|
pageWidth: unscaledViewport.width,
|
||||||
// color: getRecipientColorKey(field.recipientId),
|
pageHeight: unscaledViewport.height,
|
||||||
color: 'purple', // Todo
|
color: getRecipientColorKey(field.recipientId),
|
||||||
editable: false,
|
editable: false,
|
||||||
mode: 'sign',
|
mode: 'sign',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create the initial Konva page canvas and initialize all fields and interactions.
|
* Initialize the Konva page canvas and all fields and interactions.
|
||||||
*/
|
*/
|
||||||
const createPageCanvas = (container: HTMLDivElement) => {
|
const createPageCanvas = (_currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
pageLayer.current.batchDraw();
|
currentPageLayer.batchDraw();
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render fields when they are added or removed from the localFields.
|
* Render fields when they are added or removed
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pageLayer.current || !stage.current) {
|
if (!pageLayer.current || !stage.current) {
|
||||||
@ -148,14 +92,12 @@ 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);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -167,14 +109,19 @@ export default function EnvelopeGenericPageRenderer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}>
|
<div
|
||||||
<div className="konva-container absolute inset-0 z-10" ref={konvaContainer}></div>
|
className="relative w-full"
|
||||||
|
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
|
||||||
|
>
|
||||||
|
{/* 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}
|
||||||
width={viewport.width}
|
height={scaledViewport.height}
|
||||||
|
width={scaledViewport.width}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,17 +1,29 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Plural, Trans } from '@lingui/react/macro';
|
||||||
import { FieldType } from '@prisma/client';
|
import { FieldType, RecipientRole } 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 { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
|
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
|
||||||
|
|
||||||
export default function EnvelopeSignerForm() {
|
export default function EnvelopeSignerForm() {
|
||||||
const { fullName, signature, setFullName, setSignature, envelope, recipientFields } =
|
const {
|
||||||
useRequiredEnvelopeSigningContext();
|
fullName,
|
||||||
|
signature,
|
||||||
|
setFullName,
|
||||||
|
setSignature,
|
||||||
|
envelope,
|
||||||
|
recipientFields,
|
||||||
|
recipient,
|
||||||
|
assistantFields,
|
||||||
|
assistantRecipients,
|
||||||
|
selectedAssistantRecipient,
|
||||||
|
setSelectedAssistantRecipientId,
|
||||||
|
} = useRequiredEnvelopeSigningContext();
|
||||||
|
|
||||||
const hasSignatureField = useMemo(() => {
|
const hasSignatureField = useMemo(() => {
|
||||||
return recipientFields.some((field) => field.type === FieldType.SIGNATURE);
|
return recipientFields.some((field) => field.type === FieldType.SIGNATURE);
|
||||||
@ -19,6 +31,63 @@ 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="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">
|
||||||
|
|||||||
@ -1,131 +1,139 @@
|
|||||||
import { Plural, Trans, useLingui } from '@lingui/react/macro';
|
import { Plural, Trans } from '@lingui/react/macro';
|
||||||
import { Link, useNavigate } from 'react-router';
|
import { EnvelopeType, RecipientRole } from '@prisma/client';
|
||||||
|
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 { BrandingLogo } from '~/components/general/branding-logo';
|
import { BrandingLogo } from '~/components/general/branding-logo';
|
||||||
|
|
||||||
import { DocumentSigningCompleteDialog } from '../document-signing/document-signing-complete-dialog';
|
import { BrandingLogoIcon } from '../branding-logo-icon';
|
||||||
|
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 { t } = useLingui();
|
const { envelopeData, envelope, recipientFieldsRemaining, recipient } =
|
||||||
|
|
||||||
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="w-full border-b border-gray-200 bg-white px-6 py-3">
|
<nav className="bg-background border-border max-w-screen flex flex-row justify-between border-b px-4 py-3 md:px-6">
|
||||||
<div className="flex items-center justify-between">
|
{/* Left side - Logo and title */}
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex min-w-0 flex-1 items-center space-x-2 md:w-auto md:flex-none">
|
||||||
<Link to="/">
|
<Link to="/" className="flex-shrink-0">
|
||||||
<BrandingLogo className="h-6 w-auto" />
|
{envelopeData.settings.brandingEnabled && envelopeData.settings.brandingLogo ? (
|
||||||
</Link>
|
<img
|
||||||
<Separator orientation="vertical" className="h-6" />
|
src={`/api/branding/logo/team/${envelope.teamId}`}
|
||||||
|
alt={`${envelope.team.name}'s Logo`}
|
||||||
<div className="flex items-center space-x-2">
|
className="h-6 w-auto"
|
||||||
<h1 className="whitespace-nowrap text-sm font-medium text-gray-600">
|
|
||||||
{envelope.title}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<Badge variant="secondary">
|
|
||||||
<Trans>Approver</Trans>
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<p className="text-muted-foreground mr-2 flex-shrink-0 text-sm">
|
|
||||||
<Plural
|
|
||||||
one="1 Field Remaining"
|
|
||||||
other="# Fields Remaining"
|
|
||||||
value={recipientFieldsRemaining.length}
|
|
||||||
/>
|
/>
|
||||||
</p>
|
) : (
|
||||||
|
<>
|
||||||
|
<BrandingLogo className="hidden h-6 w-auto md:block" />
|
||||||
|
<BrandingLogoIcon className="h-6 w-auto md:hidden" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
|
||||||
<DocumentSigningCompleteDialog
|
<h1
|
||||||
isSubmitting={isPending}
|
title={envelope.title}
|
||||||
onSignatureComplete={handleOnCompleteClick}
|
className="text-foreground min-w-0 truncate text-base font-semibold md:hidden"
|
||||||
documentTitle={envelope.title}
|
>
|
||||||
fields={recipientFieldsRemaining}
|
{envelope.title}
|
||||||
fieldsValidated={handleOnNextFieldClick}
|
</h1>
|
||||||
recipient={recipient}
|
|
||||||
// Todo: Envelopes
|
<Separator orientation="vertical" className="hidden h-6 md:block" />
|
||||||
allowDictateNextSigner={envelope.documentMeta.allowDictateNextSigner}
|
|
||||||
// defaultNextSigner={
|
<div className="hidden items-center space-x-2 md:flex">
|
||||||
// nextRecipient
|
<h1 className="text-foreground whitespace-nowrap text-sm font-medium">
|
||||||
// ? { name: nextRecipient.name, email: nextRecipient.email }
|
{envelope.title}
|
||||||
// : undefined
|
</h1>
|
||||||
// }
|
|
||||||
// Todo: Envelopes - use
|
<Badge>
|
||||||
// buttonSize="sm"
|
{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>
|
</div>
|
||||||
|
|
||||||
|
{/* Right side - Desktop content */}
|
||||||
|
<div className="hidden items-center space-x-2 md:flex">
|
||||||
|
<p className="text-muted-foreground mr-2 flex-shrink-0 text-sm">
|
||||||
|
<Plural
|
||||||
|
one="1 Field Remaining"
|
||||||
|
other="# Fields Remaining"
|
||||||
|
value={recipientFieldsRemaining.length}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<EnvelopeSignerCompleteDialog />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Actions button */}
|
||||||
|
<div className="flex-shrink-0 md:hidden">
|
||||||
|
<MobileDropdownMenu />
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MobileDropdownMenu = () => {
|
||||||
|
const { envelope, recipient } = useRequiredEnvelopeSigningContext();
|
||||||
|
|
||||||
|
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 && (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@ -1,22 +1,25 @@
|
|||||||
import { useEffect, useMemo, useRef } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import { type Field, FieldType } from '@prisma/client';
|
import { type Field, FieldType, RecipientRole, type Signature } from '@prisma/client';
|
||||||
import Konva from 'konva';
|
import type 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 { 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 { 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 { 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 { 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';
|
||||||
@ -28,24 +31,13 @@ import { handleTextFieldClick } from '~/utils/field-signing/text-field';
|
|||||||
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
|
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
|
||||||
|
|
||||||
export default function EnvelopeSignerPageRenderer() {
|
export default function EnvelopeSignerPageRenderer() {
|
||||||
const pageContext = usePageContext();
|
const { i18n } = useLingui();
|
||||||
|
|
||||||
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 { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||||
|
const { sessionData } = useOptionalSession();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
envelopeData,
|
envelopeData,
|
||||||
|
recipient,
|
||||||
recipientFields,
|
recipientFields,
|
||||||
recipientFieldsRemaining,
|
recipientFieldsRemaining,
|
||||||
showPendingFieldTooltip,
|
showPendingFieldTooltip,
|
||||||
@ -56,71 +48,39 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
setFullName,
|
setFullName,
|
||||||
signature,
|
signature,
|
||||||
setSignature,
|
setSignature,
|
||||||
|
selectedAssistantRecipientFields,
|
||||||
|
selectedAssistantRecipient,
|
||||||
|
isDirectTemplate,
|
||||||
} = useRequiredEnvelopeSigningContext();
|
} = useRequiredEnvelopeSigningContext();
|
||||||
|
|
||||||
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 canvasElement = useRef<HTMLCanvasElement>(null);
|
const localPageFields = useMemo(() => {
|
||||||
const konvaContainer = useRef<HTMLDivElement>(null);
|
let fieldsToRender = recipientFields;
|
||||||
|
|
||||||
const stage = useRef<Konva.Stage | null>(null);
|
if (recipient.role === RecipientRole.ASSISTANT) {
|
||||||
const pageLayer = useRef<Layer | null>(null);
|
fieldsToRender = selectedAssistantRecipientFields;
|
||||||
|
}
|
||||||
|
|
||||||
const viewport = useMemo(
|
return fieldsToRender.filter(
|
||||||
() => page.getViewport({ scale, rotation: rotate }),
|
(field) =>
|
||||||
[page, rotate, scale],
|
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
||||||
);
|
);
|
||||||
|
}, [recipientFields, selectedAssistantRecipientFields, pageContext.pageNumber]);
|
||||||
|
|
||||||
const localPageFields = useMemo(
|
const renderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
|
||||||
() =>
|
|
||||||
recipientFields.filter(
|
|
||||||
(field) =>
|
|
||||||
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
|
||||||
),
|
|
||||||
[recipientFields, 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 = (unparsedField: Field) => {
|
|
||||||
if (!pageLayer.current) {
|
if (!pageLayer.current) {
|
||||||
console.error('Layer not loaded yet');
|
console.error('Layer not loaded yet');
|
||||||
return;
|
return;
|
||||||
@ -137,6 +97,7 @@ 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(),
|
||||||
@ -145,9 +106,11 @@ 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,
|
||||||
},
|
},
|
||||||
pageWidth: viewport.width,
|
translations: getClientSideFieldTranslations(i18n),
|
||||||
pageHeight: viewport.height,
|
pageWidth: unscaledViewport.width,
|
||||||
|
pageHeight: unscaledViewport.height,
|
||||||
color,
|
color,
|
||||||
mode: 'sign',
|
mode: 'sign',
|
||||||
});
|
});
|
||||||
@ -158,19 +121,35 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
|
|
||||||
const { width: fieldWidth, height: fieldHeight } = fieldGroup.getClientRect();
|
const { width: fieldWidth, height: fieldHeight } = fieldGroup.getClientRect();
|
||||||
|
|
||||||
const foundField = recipientFields.find((f) => f.id === unparsedField.id);
|
const foundField = localPageFields.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;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadingSpinnerGroup = createSpinner({
|
let localEmail: string | null = email;
|
||||||
fieldWidth,
|
let localFullName: string | null = fullName;
|
||||||
fieldHeight,
|
let placeholderEmail: string | null = null;
|
||||||
});
|
|
||||||
|
|
||||||
fieldGroup.add(loadingSpinnerGroup);
|
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({
|
||||||
|
fieldWidth: fieldWidth / scale,
|
||||||
|
fieldHeight: fieldHeight / scale,
|
||||||
|
});
|
||||||
|
|
||||||
const parsedFoundField = ZFullFieldSchema.parse(foundField);
|
const parsedFoundField = ZFullFieldSchema.parse(foundField);
|
||||||
|
|
||||||
@ -179,34 +158,39 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
* CHECKBOX FIELD.
|
* CHECKBOX FIELD.
|
||||||
*/
|
*/
|
||||||
.with({ type: FieldType.CHECKBOX }, (field) => {
|
.with({ type: FieldType.CHECKBOX }, (field) => {
|
||||||
const { fieldMeta } = field;
|
const clickedCheckboxIndex = Number(target.getAttr('internalCheckboxIndex'));
|
||||||
|
|
||||||
const { values } = fieldMeta;
|
if (Number.isNaN(clickedCheckboxIndex)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const checkedValues = (values || [])
|
handleCheckboxFieldClick({ field, clickedCheckboxIndex })
|
||||||
.map((v) => ({
|
.then(async (payload) => {
|
||||||
...v,
|
if (payload) {
|
||||||
checked: v.id === target.getAttr('internalCheckboxId') ? !v.checked : v.checked,
|
fieldGroup.add(loadingSpinnerGroup);
|
||||||
}))
|
await signField(field.id, payload);
|
||||||
.filter((v) => v.checked);
|
}
|
||||||
|
})
|
||||||
void signField(field.id, {
|
.finally(() => {
|
||||||
type: FieldType.CHECKBOX,
|
loadingSpinnerGroup.destroy();
|
||||||
value: checkedValues.map((v) => v.id),
|
});
|
||||||
}).finally(() => {
|
|
||||||
loadingSpinnerGroup.destroy();
|
|
||||||
});
|
|
||||||
})
|
})
|
||||||
/**
|
/**
|
||||||
* RADIO FIELD.
|
* RADIO FIELD.
|
||||||
*/
|
*/
|
||||||
.with({ type: FieldType.RADIO }, (field) => {
|
.with({ type: FieldType.RADIO }, (field) => {
|
||||||
const { fieldMeta } = foundField;
|
const selectedRadioIndex = Number(target.getAttr('internalRadioIndex'));
|
||||||
|
const fieldCustomText = Number(field.customText);
|
||||||
|
|
||||||
const checkedValue = target.getAttr('internalRadioValue');
|
if (Number.isNaN(selectedRadioIndex)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldGroup.add(loadingSpinnerGroup);
|
||||||
|
|
||||||
// Uncheck the value if it's already pressed.
|
// Uncheck the value if it's already pressed.
|
||||||
const value = field.inserted && checkedValue === field.customText ? null : checkedValue;
|
const value =
|
||||||
|
field.inserted && selectedRadioIndex === fieldCustomText ? null : selectedRadioIndex;
|
||||||
|
|
||||||
void signField(field.id, {
|
void signField(field.id, {
|
||||||
type: FieldType.RADIO,
|
type: FieldType.RADIO,
|
||||||
@ -222,6 +206,7 @@ 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);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -236,6 +221,7 @@ 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);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -247,9 +233,10 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
* EMAIL FIELD.
|
* EMAIL FIELD.
|
||||||
*/
|
*/
|
||||||
.with({ type: FieldType.EMAIL }, (field) => {
|
.with({ type: FieldType.EMAIL }, (field) => {
|
||||||
handleEmailFieldClick({ field, email })
|
handleEmailFieldClick({ field, email: localEmail, placeholderEmail })
|
||||||
.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); // Todo: Envelopes - Handle errors
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -265,11 +252,12 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
* INITIALS FIELD.
|
* INITIALS FIELD.
|
||||||
*/
|
*/
|
||||||
.with({ type: FieldType.INITIALS }, (field) => {
|
.with({ type: FieldType.INITIALS }, (field) => {
|
||||||
const initials = fullName ? extractInitials(fullName) : null;
|
const initials = localFullName ? extractInitials(localFullName) : 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);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -281,9 +269,10 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
* NAME FIELD.
|
* NAME FIELD.
|
||||||
*/
|
*/
|
||||||
.with({ type: FieldType.NAME }, (field) => {
|
.with({ type: FieldType.NAME }, (field) => {
|
||||||
handleNameFieldClick({ field, name: fullName })
|
handleNameFieldClick({ field, name: localFullName })
|
||||||
.then(async (payload) => {
|
.then(async (payload) => {
|
||||||
if (payload) {
|
if (payload) {
|
||||||
|
fieldGroup.add(loadingSpinnerGroup);
|
||||||
await signField(field.id, payload);
|
await signField(field.id, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -302,6 +291,7 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -315,6 +305,8 @@ 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,
|
||||||
@ -336,6 +328,7 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
})
|
})
|
||||||
.then(async (payload) => {
|
.then(async (payload) => {
|
||||||
if (payload) {
|
if (payload) {
|
||||||
|
fieldGroup.add(loadingSpinnerGroup);
|
||||||
await signField(field.id, payload);
|
await signField(field.id, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -348,38 +341,22 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
.exhaustive();
|
.exhaustive();
|
||||||
|
|
||||||
console.log('Field clicked');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
fieldGroup.off('click');
|
fieldGroup.off('pointerdown');
|
||||||
fieldGroup.on('click', handleFieldGroupClick);
|
fieldGroup.on('pointerdown', handleFieldGroupClick);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create the initial Konva page canvas and initialize all fields and interactions.
|
* Initialize the Konva page canvas and all fields and interactions.
|
||||||
*/
|
*/
|
||||||
const createPageCanvas = (container: HTMLDivElement) => {
|
const createPageCanvas = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
|
||||||
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);
|
|
||||||
|
|
||||||
console.log({
|
|
||||||
localPageFields,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Render the fields.
|
// Render the fields.
|
||||||
for (const field of localPageFields) {
|
for (const field of localPageFields) {
|
||||||
renderFieldOnLayer(field);
|
renderFieldOnLayer(field); // Todo: Envelopes - [CRITICAL] Handle errors which prevent rendering
|
||||||
}
|
}
|
||||||
|
|
||||||
pageLayer.current.batchDraw();
|
currentPageLayer.batchDraw();
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -392,25 +369,61 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
|
|
||||||
localPageFields.forEach((field) => {
|
localPageFields.forEach((field) => {
|
||||||
console.log('Field changed/inserted, rendering on canvas');
|
console.log('Field changed/inserted, rendering on canvas');
|
||||||
renderFieldOnLayer(field);
|
renderFieldOnLayer(field); // Todo: Envelopes - [CRITICAL] Handle errors which prevent rendering
|
||||||
});
|
});
|
||||||
|
|
||||||
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();
|
||||||
|
|
||||||
|
localPageFields.forEach((field) => {
|
||||||
|
renderFieldOnLayer(field); // Todo: Envelopes - [CRITICAL] Handle errors which prevent rendering
|
||||||
|
});
|
||||||
|
|
||||||
|
pageLayer.current.batchDraw();
|
||||||
|
}, [selectedAssistantRecipient]);
|
||||||
|
|
||||||
if (!currentEnvelopeItem) {
|
if (!currentEnvelopeItem) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}>
|
<div
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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}
|
||||||
width={viewport.width}
|
height={scaledViewport.height}
|
||||||
|
width={scaledViewport.width}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -0,0 +1,183 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { useLingui } from '@lingui/react/macro';
|
||||||
|
import { FieldType } from '@prisma/client';
|
||||||
|
import { useNavigate, 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 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 { 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 { toast } = useToast();
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const {
|
||||||
|
isDirectTemplate,
|
||||||
|
envelope,
|
||||||
|
setShowPendingFieldTooltip,
|
||||||
|
recipientFieldsRemaining,
|
||||||
|
recipient,
|
||||||
|
nextRecipient,
|
||||||
|
email,
|
||||||
|
fullName,
|
||||||
|
} = useRequiredEnvelopeSigningContext();
|
||||||
|
|
||||||
|
const { currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||||
|
|
||||||
|
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,
|
||||||
|
) => {
|
||||||
|
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`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { token } = await createDocumentFromDirectTemplate({
|
||||||
|
directTemplateToken: recipient.token, // 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 (redirectUrl) {
|
||||||
|
window.location.href = redirectUrl;
|
||||||
|
} else {
|
||||||
|
await navigate(`/sign/${token}/complete`);
|
||||||
|
}
|
||||||
|
} catch (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',
|
||||||
|
});
|
||||||
|
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -5,6 +5,8 @@ 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 { IS_ENVELOPES_ENABLED } from '@documenso/lib/constants/app';
|
||||||
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';
|
||||||
@ -19,6 +21,8 @@ import { DocumentUploadButton } from '~/components/general/document/document-upl
|
|||||||
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 '../document/envelope-upload-button';
|
||||||
|
|
||||||
export type FolderGridProps = {
|
export type FolderGridProps = {
|
||||||
type: FolderType;
|
type: FolderType;
|
||||||
parentId: string | null;
|
parentId: string | null;
|
||||||
@ -26,6 +30,7 @@ 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);
|
||||||
@ -94,8 +99,9 @@ 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">
|
||||||
{/* Todo: Envelopes - Feature flag */}
|
{(IS_ENVELOPES_ENABLED || organisation.organisationClaim.flags.allowEnvelopes) && (
|
||||||
{/* <EnvelopeUploadButton type={type} folderId={parentId || undefined} /> */}
|
<EnvelopeUploadButton type={type} folderId={parentId || undefined} />
|
||||||
|
)}
|
||||||
|
|
||||||
{type === FolderType.DOCUMENT ? (
|
{type === FolderType.DOCUMENT ? (
|
||||||
<DocumentUploadButton />
|
<DocumentUploadButton />
|
||||||
|
|||||||
@ -15,7 +15,6 @@ export type ShareDocumentDownloadButtonProps = {
|
|||||||
documentData: DocumentData;
|
documentData: DocumentData;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Todo: Envelopes - Support multiple item downloads.
|
|
||||||
export const ShareDocumentDownloadButton = ({
|
export const ShareDocumentDownloadButton = ({
|
||||||
title,
|
title,
|
||||||
documentData,
|
documentData,
|
||||||
|
|||||||
@ -17,6 +17,8 @@ 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;
|
||||||
};
|
};
|
||||||
@ -88,6 +90,7 @@ 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 },
|
||||||
@ -131,6 +134,19 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
|
|||||||
<Trans>View</Trans>
|
<Trans>View</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
))
|
))
|
||||||
|
.with({ isComplete: true, internalVersion: 2 }, () => (
|
||||||
|
<EnvelopeDownloadDialog
|
||||||
|
envelopeId={row.envelopeId}
|
||||||
|
envelopeStatus={row.status}
|
||||||
|
token={recipient?.token}
|
||||||
|
trigger={
|
||||||
|
<Button className="w-32">
|
||||||
|
<Download className="-ml-1 mr-2 inline h-4 w-4" />
|
||||||
|
<Trans>Download</Trans>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))
|
||||||
.with({ isComplete: true }, () => (
|
.with({ isComplete: true }, () => (
|
||||||
<Button className="w-32" onClick={onDownloadClick}>
|
<Button className="w-32" onClick={onDownloadClick}>
|
||||||
<Download className="-ml-1 mr-2 inline h-4 w-4" />
|
<Download className="-ml-1 mr-2 inline h-4 w-4" />
|
||||||
|
|||||||
@ -42,6 +42,8 @@ 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;
|
||||||
@ -176,15 +178,33 @@ export const DocumentsTableActionDropdown = ({
|
|||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem disabled={!isComplete} onClick={onDownloadClick}>
|
{row.internalVersion === 2 ? (
|
||||||
<Download className="mr-2 h-4 w-4" />
|
<EnvelopeDownloadDialog
|
||||||
<Trans>Download</Trans>
|
envelopeId={row.envelopeId}
|
||||||
</DropdownMenuItem>
|
envelopeStatus={row.status}
|
||||||
|
token={recipient?.token}
|
||||||
|
trigger={
|
||||||
|
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||||
|
<div>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Download</Trans>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem disabled={!isComplete} onClick={onDownloadClick}>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Download</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem onClick={onDownloadOriginalClick}>
|
<DropdownMenuItem onClick={onDownloadOriginalClick}>
|
||||||
<FileDown className="mr-2 h-4 w-4" />
|
<FileDown className="mr-2 h-4 w-4" />
|
||||||
<Trans>Download Original</Trans>
|
<Trans>Download Original</Trans>
|
||||||
</DropdownMenuItem>
|
</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" />
|
||||||
|
|||||||
@ -159,6 +159,7 @@ 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}
|
||||||
|
|||||||
@ -116,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 && <AppBanner banner={banner} />}
|
{banner && !hideHeader && <AppBanner banner={banner} />}
|
||||||
|
|
||||||
{!hideHeader && <Header />}
|
{!hideHeader && <Header />}
|
||||||
|
|
||||||
|
|||||||
@ -34,6 +34,7 @@ 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';
|
||||||
|
|
||||||
@ -71,23 +72,6 @@ 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 [
|
||||||
{
|
{
|
||||||
@ -120,23 +104,24 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: t`Actions`,
|
header: t`Actions`,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => {
|
||||||
<div className="flex justify-end space-x-2">
|
const isOwner = row.original.userId === organisation?.ownerUserId;
|
||||||
<Button
|
|
||||||
variant="outline"
|
return (
|
||||||
disabled={row.original.userId === organisation?.ownerUserId}
|
<div className="flex justify-end space-x-2">
|
||||||
loading={isPromotingToOwner}
|
<AdminOrganisationMemberUpdateDialog
|
||||||
onClick={async () =>
|
trigger={
|
||||||
promoteToOwner({
|
<Button variant="outline">
|
||||||
organisationId,
|
<Trans>Update role</Trans>
|
||||||
userId: row.original.userId,
|
</Button>
|
||||||
})
|
}
|
||||||
}
|
organisationId={organisationId}
|
||||||
>
|
organisationMember={row.original}
|
||||||
<Trans>Promote to owner</Trans>
|
isOwner={isOwner}
|
||||||
</Button>
|
/>
|
||||||
</div>
|
</div>
|
||||||
),
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
] satisfies DataTableColumnDef<TGetAdminOrganisationResponse['members'][number]>[];
|
] satisfies DataTableColumnDef<TGetAdminOrganisationResponse['members'][number]>[];
|
||||||
}, [organisation]);
|
}, [organisation]);
|
||||||
@ -404,6 +389,7 @@ 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 || '',
|
||||||
@ -561,6 +547,30 @@ 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>
|
||||||
|
|||||||
@ -5,7 +5,10 @@ import { Trans } from '@lingui/react/macro';
|
|||||||
import { SubscriptionStatus } from '@prisma/client';
|
import { SubscriptionStatus } from '@prisma/client';
|
||||||
import { Link, Outlet } from 'react-router';
|
import { Link, Outlet } from 'react-router';
|
||||||
|
|
||||||
import { PAID_PLAN_LIMITS } from '@documenso/ee/server-only/limits/constants';
|
import {
|
||||||
|
DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
|
||||||
|
PAID_PLAN_LIMITS,
|
||||||
|
} from '@documenso/ee/server-only/limits/constants';
|
||||||
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client';
|
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client';
|
||||||
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { TrpcProvider } from '@documenso/trpc/react';
|
import { TrpcProvider } from '@documenso/trpc/react';
|
||||||
@ -38,12 +41,14 @@ export default function Layout() {
|
|||||||
recipients: 0,
|
recipients: 0,
|
||||||
directTemplates: 0,
|
directTemplates: 0,
|
||||||
},
|
},
|
||||||
|
maximumEnvelopeItemCount: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
quota: PAID_PLAN_LIMITS,
|
quota: PAID_PLAN_LIMITS,
|
||||||
remaining: PAID_PLAN_LIMITS,
|
remaining: PAID_PLAN_LIMITS,
|
||||||
|
maximumEnvelopeItemCount: DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
|
||||||
};
|
};
|
||||||
}, [organisation?.subscription]);
|
}, [organisation?.subscription]);
|
||||||
|
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import {
|
|||||||
mapFieldsWithRecipients,
|
mapFieldsWithRecipients,
|
||||||
} from '@documenso/ui/components/document/document-read-only-fields';
|
} from '@documenso/ui/components/document/document-read-only-fields';
|
||||||
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 { cn } from '@documenso/ui/lib/utils';
|
||||||
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 { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
@ -87,6 +88,8 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
|
|||||||
|
|
||||||
const documentRootPath = formatDocumentsPath(team.url);
|
const documentRootPath = formatDocumentsPath(team.url);
|
||||||
|
|
||||||
|
const isMultiEnvelopeItem = envelope.envelopeItems.length > 1 && envelope.internalVersion === 2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||||
{envelope.status === DocumentStatus.PENDING && (
|
{envelope.status === DocumentStatus.PENDING && (
|
||||||
@ -140,40 +143,52 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 grid w-full grid-cols-12 gap-8">
|
<div className="mt-6 grid w-full grid-cols-12 gap-8">
|
||||||
<Card
|
{envelope.internalVersion === 2 ? (
|
||||||
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
|
<div className="relative col-span-12 lg:col-span-6 xl:col-span-7">
|
||||||
gradient
|
<EnvelopeRenderProvider
|
||||||
>
|
envelope={envelope}
|
||||||
<CardContent className="p-2">
|
fields={envelope.status == DocumentStatus.COMPLETED ? [] : envelope.fields}
|
||||||
{envelope.internalVersion === 2 ? (
|
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
|
||||||
<EnvelopeRenderProvider envelope={envelope} fields={envelope.fields}>
|
>
|
||||||
|
{isMultiEnvelopeItem && (
|
||||||
<EnvelopeRendererFileSelector fields={envelope.fields} className="mb-4 p-0" />
|
<EnvelopeRendererFileSelector fields={envelope.fields} className="mb-4 p-0" />
|
||||||
|
)}
|
||||||
|
|
||||||
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
|
<Card className="rounded-xl before:rounded-xl" gradient>
|
||||||
</EnvelopeRenderProvider>
|
<CardContent className="p-2">
|
||||||
) : (
|
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
|
||||||
<>
|
</CardContent>
|
||||||
{envelope.status !== DocumentStatus.COMPLETED && (
|
</Card>
|
||||||
<DocumentReadOnlyFields
|
</EnvelopeRenderProvider>
|
||||||
fields={mapFieldsWithRecipients(envelope.fields, envelope.recipients)}
|
</div>
|
||||||
documentMeta={envelope.documentMeta || undefined}
|
) : (
|
||||||
showRecipientTooltip={true}
|
<Card
|
||||||
showRecipientColors={true}
|
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
|
||||||
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
|
gradient
|
||||||
/>
|
>
|
||||||
)}
|
<CardContent className="p-2">
|
||||||
|
{envelope.status !== DocumentStatus.COMPLETED && (
|
||||||
<PDFViewer
|
<DocumentReadOnlyFields
|
||||||
document={envelope}
|
fields={mapFieldsWithRecipients(envelope.fields, envelope.recipients)}
|
||||||
key={envelope.envelopeItems[0].id}
|
documentMeta={envelope.documentMeta || undefined}
|
||||||
documentData={envelope.envelopeItems[0].documentData}
|
showRecipientTooltip={true}
|
||||||
|
showRecipientColors={true}
|
||||||
|
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
|
||||||
/>
|
/>
|
||||||
</>
|
)}
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
<PDFViewer
|
||||||
|
document={envelope}
|
||||||
|
key={envelope.envelopeItems[0].id}
|
||||||
|
documentData={envelope.envelopeItems[0].documentData}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn('col-span-12 lg:col-span-6 xl:col-span-5', isMultiEnvelopeItem && 'mt-20')}
|
||||||
|
>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
|
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
|
||||||
<div className="flex flex-row items-center justify-between px-4">
|
<div className="flex flex-row items-center justify-between px-4">
|
||||||
|
|||||||
@ -99,7 +99,11 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<EnvelopeEditorProvider initialEnvelope={envelope}>
|
<EnvelopeEditorProvider initialEnvelope={envelope}>
|
||||||
<EnvelopeRenderProvider envelope={envelope}>
|
<EnvelopeRenderProvider
|
||||||
|
envelope={envelope}
|
||||||
|
fields={envelope.fields}
|
||||||
|
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
|
||||||
|
>
|
||||||
<EnvelopeEditor />
|
<EnvelopeEditor />
|
||||||
</EnvelopeRenderProvider>
|
</EnvelopeRenderProvider>
|
||||||
</EnvelopeEditorProvider>
|
</EnvelopeEditorProvider>
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/t
|
|||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
|
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
|
||||||
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 { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||||
@ -108,6 +109,8 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
|
|||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
const isMultiEnvelopeItem = envelope.envelopeItems.length > 1 && envelope.internalVersion === 2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||||
<Link to={templateRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
<Link to={templateRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||||
@ -163,39 +166,51 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 grid w-full grid-cols-12 gap-8">
|
<div className="mt-6 grid w-full grid-cols-12 gap-8">
|
||||||
<Card
|
{envelope.internalVersion === 2 ? (
|
||||||
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
|
<div className="relative col-span-12 lg:col-span-6 xl:col-span-7">
|
||||||
gradient
|
<EnvelopeRenderProvider
|
||||||
>
|
envelope={envelope}
|
||||||
<CardContent className="p-2">
|
fields={envelope.fields}
|
||||||
{envelope.internalVersion === 2 ? (
|
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
|
||||||
<EnvelopeRenderProvider envelope={envelope} fields={envelope.fields}>
|
>
|
||||||
|
{isMultiEnvelopeItem && (
|
||||||
<EnvelopeRendererFileSelector fields={envelope.fields} className="mb-4 p-0" />
|
<EnvelopeRendererFileSelector fields={envelope.fields} className="mb-4 p-0" />
|
||||||
|
)}
|
||||||
|
|
||||||
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
|
<Card className="rounded-xl before:rounded-xl" gradient>
|
||||||
</EnvelopeRenderProvider>
|
<CardContent className="p-2">
|
||||||
) : (
|
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
|
||||||
<>
|
</CardContent>
|
||||||
<DocumentReadOnlyFields
|
</Card>
|
||||||
fields={readOnlyFields}
|
</EnvelopeRenderProvider>
|
||||||
showFieldStatus={false}
|
</div>
|
||||||
showRecipientTooltip={true}
|
) : (
|
||||||
showRecipientColors={true}
|
<Card
|
||||||
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
|
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
|
||||||
documentMeta={mockedDocumentMeta}
|
gradient
|
||||||
/>
|
>
|
||||||
|
<CardContent className="p-2">
|
||||||
|
<DocumentReadOnlyFields
|
||||||
|
fields={readOnlyFields}
|
||||||
|
showFieldStatus={false}
|
||||||
|
showRecipientTooltip={true}
|
||||||
|
showRecipientColors={true}
|
||||||
|
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
|
||||||
|
documentMeta={mockedDocumentMeta}
|
||||||
|
/>
|
||||||
|
|
||||||
<PDFViewer
|
<PDFViewer
|
||||||
document={envelope}
|
document={envelope}
|
||||||
key={envelope.envelopeItems[0].id}
|
key={envelope.envelopeItems[0].id}
|
||||||
documentData={envelope.envelopeItems[0].documentData}
|
documentData={envelope.envelopeItems[0].documentData}
|
||||||
/>
|
/>
|
||||||
</>
|
</CardContent>
|
||||||
)}
|
</Card>
|
||||||
</CardContent>
|
)}
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
<div
|
||||||
|
className={cn('col-span-12 lg:col-span-6 xl:col-span-5', isMultiEnvelopeItem && 'mt-20')}
|
||||||
|
>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
|
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
|
||||||
<div className="flex flex-row items-center justify-between px-4">
|
<div className="flex flex-row items-center justify-between px-4">
|
||||||
@ -223,6 +238,7 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
|
|||||||
|
|
||||||
<div className="mt-4 border-t px-4 pt-4">
|
<div className="mt-4 border-t px-4 pt-4">
|
||||||
<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}
|
||||||
|
|||||||
@ -22,7 +22,9 @@ export default function RecipientLayout({ matches }: Route.ComponentProps) {
|
|||||||
|
|
||||||
// Hide the header for signing routes.
|
// Hide the header for signing routes.
|
||||||
const hideHeader = matches.some(
|
const hideHeader = matches.some(
|
||||||
(match) => match?.id === 'routes/_recipient+/sign.$token+/_index',
|
(match) =>
|
||||||
|
match?.id === 'routes/_recipient+/sign.$token+/_index' ||
|
||||||
|
match?.id === 'routes/_recipient+/d.$token+/_index',
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -4,20 +4,28 @@ import { redirect } from 'react-router';
|
|||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
|
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||||
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { getEnvelopeForDirectTemplateSigning } from '@documenso/lib/server-only/envelope/get-envelope-for-direct-template-signing';
|
||||||
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
|
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
|
||||||
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
||||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { Header as AuthenticatedHeader } from '~/components/general/app-header';
|
||||||
import { DirectTemplatePageView } from '~/components/general/direct-template/direct-template-page';
|
import { DirectTemplatePageView } from '~/components/general/direct-template/direct-template-page';
|
||||||
import { DirectTemplateAuthPageView } from '~/components/general/direct-template/direct-template-signing-auth-page';
|
import { DirectTemplateAuthPageView } from '~/components/general/direct-template/direct-template-signing-auth-page';
|
||||||
|
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
|
||||||
import { DocumentSigningAuthProvider } from '~/components/general/document-signing/document-signing-auth-provider';
|
import { DocumentSigningAuthProvider } from '~/components/general/document-signing/document-signing-auth-provider';
|
||||||
|
import { DocumentSigningPageViewV2 } from '~/components/general/document-signing/document-signing-page-view-v2';
|
||||||
import { DocumentSigningProvider } from '~/components/general/document-signing/document-signing-provider';
|
import { DocumentSigningProvider } from '~/components/general/document-signing/document-signing-provider';
|
||||||
|
import { EnvelopeSigningProvider } from '~/components/general/document-signing/envelope-signing-provider';
|
||||||
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
||||||
|
|
||||||
import type { Route } from './+types/_index';
|
import type { Route } from './+types/_index';
|
||||||
|
|
||||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
const handleV1Loader = async ({ params, request }: Route.LoaderArgs) => {
|
||||||
const session = await getOptionalSession(request);
|
const session = await getOptionalSession(request);
|
||||||
|
|
||||||
const { token } = params;
|
const { token } = params;
|
||||||
@ -55,27 +63,108 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!isAccessAuthValid) {
|
if (!isAccessAuthValid) {
|
||||||
return superLoaderJson({
|
return {
|
||||||
isAccessAuthValid: false as const,
|
isAccessAuthValid: false as const,
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return superLoaderJson({
|
return {
|
||||||
isAccessAuthValid: true,
|
isAccessAuthValid: true,
|
||||||
template: {
|
template: {
|
||||||
...template,
|
...template,
|
||||||
folder: null,
|
folder: null,
|
||||||
},
|
},
|
||||||
directTemplateRecipient,
|
directTemplateRecipient,
|
||||||
|
} as const;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
|
||||||
|
const session = await getOptionalSession(request);
|
||||||
|
|
||||||
|
const { token } = params;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await getEnvelopeForDirectTemplateSigning({
|
||||||
|
token,
|
||||||
|
userId: session?.user?.id,
|
||||||
|
})
|
||||||
|
.then((envelopeForSigning) => {
|
||||||
|
return {
|
||||||
|
isDocumentAccessValid: true,
|
||||||
|
envelopeForSigning,
|
||||||
|
} as const;
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
const error = AppError.parseError(e);
|
||||||
|
|
||||||
|
if (error.code === AppErrorCode.UNAUTHORIZED) {
|
||||||
|
return {
|
||||||
|
isDocumentAccessValid: false,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Response('Not Found', { status: 404 });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function loader(loaderArgs: Route.LoaderArgs) {
|
||||||
|
const { token } = loaderArgs.params;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
const directEnvelope = await prisma.envelope.findFirst({
|
||||||
|
where: {
|
||||||
|
directLink: {
|
||||||
|
enabled: true,
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
internalVersion: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!directEnvelope) {
|
||||||
|
throw new Response('Not Found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (directEnvelope.internalVersion === 2) {
|
||||||
|
const payloadV2 = await handleV2Loader(loaderArgs);
|
||||||
|
|
||||||
|
return superLoaderJson({
|
||||||
|
version: 2,
|
||||||
|
payload: payloadV2,
|
||||||
|
} as const);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadV1 = await handleV1Loader(loaderArgs);
|
||||||
|
|
||||||
|
return superLoaderJson({
|
||||||
|
version: 1,
|
||||||
|
payload: payloadV1,
|
||||||
} as const);
|
} as const);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DirectTemplatePage() {
|
export default function DirectTemplatePage() {
|
||||||
const { sessionData } = useOptionalSession();
|
|
||||||
const user = sessionData?.user;
|
|
||||||
|
|
||||||
const data = useSuperLoaderData<typeof loader>();
|
const data = useSuperLoaderData<typeof loader>();
|
||||||
|
|
||||||
|
if (data.version === 2) {
|
||||||
|
return <DirectSigningPageV2 data={data.payload} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <DirectSigningPageV1 data={data.payload} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DirectSigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV1Loader>> }) => {
|
||||||
|
const { sessionData } = useOptionalSession();
|
||||||
|
|
||||||
|
const user = sessionData?.user;
|
||||||
|
|
||||||
// Should not be possible for directLink to be null.
|
// Should not be possible for directLink to be null.
|
||||||
if (!data.isAccessAuthValid) {
|
if (!data.isAccessAuthValid) {
|
||||||
return <DirectTemplateAuthPageView />;
|
return <DirectTemplateAuthPageView />;
|
||||||
@ -95,31 +184,71 @@ export default function DirectTemplatePage() {
|
|||||||
<DocumentSigningAuthProvider
|
<DocumentSigningAuthProvider
|
||||||
documentAuthOptions={template.authOptions}
|
documentAuthOptions={template.authOptions}
|
||||||
recipient={directTemplateRecipient}
|
recipient={directTemplateRecipient}
|
||||||
isDirectTemplate={true}
|
|
||||||
user={user}
|
user={user}
|
||||||
>
|
>
|
||||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
<>
|
||||||
<h1
|
{sessionData?.user && <AuthenticatedHeader />}
|
||||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
|
||||||
title={template.title}
|
|
||||||
>
|
|
||||||
{template.title}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div className="text-muted-foreground mb-8 mt-2.5 flex items-center gap-x-2">
|
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||||
<UsersIcon className="h-4 w-4" />
|
<h1
|
||||||
<p className="text-muted-foreground/80">
|
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||||
<Plural value={template.recipients.length} one="# recipient" other="# recipients" />
|
title={template.title}
|
||||||
</p>
|
>
|
||||||
|
{template.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="text-muted-foreground mb-8 mt-2.5 flex items-center gap-x-2">
|
||||||
|
<UsersIcon className="h-4 w-4" />
|
||||||
|
<p className="text-muted-foreground/80">
|
||||||
|
<Plural value={template.recipients.length} one="# recipient" other="# recipients" />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DirectTemplatePageView
|
||||||
|
directTemplateRecipient={directTemplateRecipient}
|
||||||
|
directTemplateToken={template.directLink.token}
|
||||||
|
template={template}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
<DirectTemplatePageView
|
|
||||||
directTemplateRecipient={directTemplateRecipient}
|
|
||||||
directTemplateToken={template.directLink.token}
|
|
||||||
template={template}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</DocumentSigningAuthProvider>
|
</DocumentSigningAuthProvider>
|
||||||
</DocumentSigningProvider>
|
</DocumentSigningProvider>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const DirectSigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV2Loader>> }) => {
|
||||||
|
const { sessionData } = useOptionalSession();
|
||||||
|
|
||||||
|
const user = sessionData?.user;
|
||||||
|
|
||||||
|
if (!data.isDocumentAccessValid) {
|
||||||
|
return <DocumentSigningAuthPageView email={''} emailHasAccount={true} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { envelope, recipient } = data.envelopeForSigning;
|
||||||
|
|
||||||
|
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||||
|
documentAuth: envelope.authOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isEmailForced = derivedRecipientAccessAuth.includes(DocumentAccessAuth.ACCOUNT);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EnvelopeSigningProvider
|
||||||
|
envelopeData={data.envelopeForSigning}
|
||||||
|
email={isEmailForced ? user?.email || '' : ''} // Doing this allows us to let users change the email if they want to for non-auth templates.
|
||||||
|
fullName={user?.name}
|
||||||
|
signature={user?.signature}
|
||||||
|
>
|
||||||
|
<DocumentSigningAuthProvider
|
||||||
|
documentAuthOptions={envelope.authOptions}
|
||||||
|
recipient={recipient}
|
||||||
|
user={user}
|
||||||
|
>
|
||||||
|
<EnvelopeRenderProvider envelope={envelope}>
|
||||||
|
<DocumentSigningPageViewV2 />
|
||||||
|
</EnvelopeRenderProvider>
|
||||||
|
</DocumentSigningAuthProvider>
|
||||||
|
</EnvelopeSigningProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { Link, redirect } from 'react-router';
|
import { Link, redirect } from 'react-router';
|
||||||
|
|
||||||
@ -11,7 +9,6 @@ import {
|
|||||||
OIDC_PROVIDER_LABEL,
|
OIDC_PROVIDER_LABEL,
|
||||||
} from '@documenso/lib/constants/auth';
|
} from '@documenso/lib/constants/auth';
|
||||||
import { env } from '@documenso/lib/utils/env';
|
import { env } from '@documenso/lib/utils/env';
|
||||||
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
|
|
||||||
|
|
||||||
import { SignInForm } from '~/components/forms/signin';
|
import { SignInForm } from '~/components/forms/signin';
|
||||||
import { appMetaTags } from '~/utils/meta';
|
import { appMetaTags } from '~/utils/meta';
|
||||||
@ -31,12 +28,8 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||||||
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
|
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
|
||||||
const oidcProviderLabel = OIDC_PROVIDER_LABEL;
|
const oidcProviderLabel = OIDC_PROVIDER_LABEL;
|
||||||
|
|
||||||
let returnTo = new URL(request.url).searchParams.get('returnTo') ?? undefined;
|
|
||||||
|
|
||||||
returnTo = isValidReturnTo(returnTo) ? normalizeReturnTo(returnTo) : undefined;
|
|
||||||
|
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
throw redirect(returnTo || '/');
|
throw redirect('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -44,28 +37,12 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||||||
isMicrosoftSSOEnabled,
|
isMicrosoftSSOEnabled,
|
||||||
isOIDCSSOEnabled,
|
isOIDCSSOEnabled,
|
||||||
oidcProviderLabel,
|
oidcProviderLabel,
|
||||||
returnTo,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SignIn({ loaderData }: Route.ComponentProps) {
|
export default function SignIn({ loaderData }: Route.ComponentProps) {
|
||||||
const {
|
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } =
|
||||||
isGoogleSSOEnabled,
|
loaderData;
|
||||||
isMicrosoftSSOEnabled,
|
|
||||||
isOIDCSSOEnabled,
|
|
||||||
oidcProviderLabel,
|
|
||||||
returnTo,
|
|
||||||
} = loaderData;
|
|
||||||
|
|
||||||
const [isEmbeddedRedirect, setIsEmbeddedRedirect] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const hash = window.location.hash.slice(1);
|
|
||||||
|
|
||||||
const params = new URLSearchParams(hash);
|
|
||||||
|
|
||||||
setIsEmbeddedRedirect(params.get('embedded') === 'true');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-screen max-w-lg px-4">
|
<div className="w-screen max-w-lg px-4">
|
||||||
@ -84,17 +61,13 @@ export default function SignIn({ loaderData }: Route.ComponentProps) {
|
|||||||
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
|
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
|
||||||
isOIDCSSOEnabled={isOIDCSSOEnabled}
|
isOIDCSSOEnabled={isOIDCSSOEnabled}
|
||||||
oidcProviderLabel={oidcProviderLabel}
|
oidcProviderLabel={oidcProviderLabel}
|
||||||
returnTo={returnTo}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!isEmbeddedRedirect && env('NEXT_PUBLIC_DISABLE_SIGNUP') !== 'true' && (
|
{env('NEXT_PUBLIC_DISABLE_SIGNUP') !== 'true' && (
|
||||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||||
<Trans>
|
<Trans>
|
||||||
Don't have an account?{' '}
|
Don't have an account?{' '}
|
||||||
<Link
|
<Link to="/signup" className="text-documenso-700 duration-200 hover:opacity-70">
|
||||||
to={returnTo ? `/signup?returnTo=${encodeURIComponent(returnTo)}` : '/signup'}
|
|
||||||
className="text-documenso-700 duration-200 hover:opacity-70"
|
|
||||||
>
|
|
||||||
Sign up
|
Sign up
|
||||||
</Link>
|
</Link>
|
||||||
</Trans>
|
</Trans>
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import {
|
|||||||
IS_OIDC_SSO_ENABLED,
|
IS_OIDC_SSO_ENABLED,
|
||||||
} from '@documenso/lib/constants/auth';
|
} from '@documenso/lib/constants/auth';
|
||||||
import { env } from '@documenso/lib/utils/env';
|
import { env } from '@documenso/lib/utils/env';
|
||||||
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
|
|
||||||
|
|
||||||
import { SignUpForm } from '~/components/forms/signup';
|
import { SignUpForm } from '~/components/forms/signup';
|
||||||
import { appMetaTags } from '~/utils/meta';
|
import { appMetaTags } from '~/utils/meta';
|
||||||
@ -17,7 +16,7 @@ export function meta() {
|
|||||||
return appMetaTags('Sign Up');
|
return appMetaTags('Sign Up');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loader({ request }: Route.LoaderArgs) {
|
export function loader() {
|
||||||
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
|
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
|
||||||
|
|
||||||
// SSR env variables.
|
// SSR env variables.
|
||||||
@ -29,20 +28,15 @@ export function loader({ request }: Route.LoaderArgs) {
|
|||||||
throw redirect('/signin');
|
throw redirect('/signin');
|
||||||
}
|
}
|
||||||
|
|
||||||
let returnTo = new URL(request.url).searchParams.get('returnTo') ?? undefined;
|
|
||||||
|
|
||||||
returnTo = isValidReturnTo(returnTo) ? normalizeReturnTo(returnTo) : undefined;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isGoogleSSOEnabled,
|
isGoogleSSOEnabled,
|
||||||
isMicrosoftSSOEnabled,
|
isMicrosoftSSOEnabled,
|
||||||
isOIDCSSOEnabled,
|
isOIDCSSOEnabled,
|
||||||
returnTo,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SignUp({ loaderData }: Route.ComponentProps) {
|
export default function SignUp({ loaderData }: Route.ComponentProps) {
|
||||||
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, returnTo } = loaderData;
|
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled } = loaderData;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SignUpForm
|
<SignUpForm
|
||||||
@ -50,7 +44,6 @@ export default function SignUp({ loaderData }: Route.ComponentProps) {
|
|||||||
isGoogleSSOEnabled={isGoogleSSOEnabled}
|
isGoogleSSOEnabled={isGoogleSSOEnabled}
|
||||||
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
|
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
|
||||||
isOIDCSSOEnabled={isOIDCSSOEnabled}
|
isOIDCSSOEnabled={isOIDCSSOEnabled}
|
||||||
returnTo={returnTo}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import { Outlet, isRouteErrorResponse, useRouteError } from 'react-router';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
IS_GOOGLE_SSO_ENABLED,
|
IS_GOOGLE_SSO_ENABLED,
|
||||||
IS_MICROSOFT_SSO_ENABLED,
|
|
||||||
IS_OIDC_SSO_ENABLED,
|
IS_OIDC_SSO_ENABLED,
|
||||||
OIDC_PROVIDER_LABEL,
|
OIDC_PROVIDER_LABEL,
|
||||||
} from '@documenso/lib/constants/auth';
|
} from '@documenso/lib/constants/auth';
|
||||||
@ -30,13 +29,11 @@ export function headers({ loaderHeaders }: Route.HeadersArgs) {
|
|||||||
export function loader() {
|
export function loader() {
|
||||||
// SSR env variables.
|
// SSR env variables.
|
||||||
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED;
|
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED;
|
||||||
const isMicrosoftSSOEnabled = IS_MICROSOFT_SSO_ENABLED;
|
|
||||||
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
|
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
|
||||||
const oidcProviderLabel = OIDC_PROVIDER_LABEL;
|
const oidcProviderLabel = OIDC_PROVIDER_LABEL;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isGoogleSSOEnabled,
|
isGoogleSSOEnabled,
|
||||||
isMicrosoftSSOEnabled,
|
|
||||||
isOIDCSSOEnabled,
|
isOIDCSSOEnabled,
|
||||||
oidcProviderLabel,
|
oidcProviderLabel,
|
||||||
};
|
};
|
||||||
@ -47,8 +44,7 @@ export default function Layout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorBoundary({ loaderData }: Route.ErrorBoundaryProps) {
|
export function ErrorBoundary({ loaderData }: Route.ErrorBoundaryProps) {
|
||||||
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } =
|
const { isGoogleSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } = loaderData || {};
|
||||||
loaderData || {};
|
|
||||||
|
|
||||||
const error = useRouteError();
|
const error = useRouteError();
|
||||||
|
|
||||||
@ -57,7 +53,6 @@ export function ErrorBoundary({ loaderData }: Route.ErrorBoundaryProps) {
|
|||||||
return (
|
return (
|
||||||
<EmbedAuthenticationRequired
|
<EmbedAuthenticationRequired
|
||||||
isGoogleSSOEnabled={isGoogleSSOEnabled}
|
isGoogleSSOEnabled={isGoogleSSOEnabled}
|
||||||
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
|
|
||||||
isOIDCSSOEnabled={isOIDCSSOEnabled}
|
isOIDCSSOEnabled={isOIDCSSOEnabled}
|
||||||
oidcProviderLabel={oidcProviderLabel}
|
oidcProviderLabel={oidcProviderLabel}
|
||||||
email={error.data.email}
|
email={error.data.email}
|
||||||
|
|||||||
@ -69,6 +69,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
throw data(
|
throw data(
|
||||||
{
|
{
|
||||||
type: 'embed-authentication-required',
|
type: 'embed-authentication-required',
|
||||||
|
email: user?.email,
|
||||||
returnTo: `/embed/direct/${token}`,
|
returnTo: `/embed/direct/${token}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -116,7 +117,6 @@ export default function EmbedDirectTemplatePage() {
|
|||||||
<DocumentSigningAuthProvider
|
<DocumentSigningAuthProvider
|
||||||
documentAuthOptions={template.authOptions}
|
documentAuthOptions={template.authOptions}
|
||||||
recipient={recipient}
|
recipient={recipient}
|
||||||
isDirectTemplate={true}
|
|
||||||
user={user}
|
user={user}
|
||||||
>
|
>
|
||||||
<DocumentSigningRecipientProvider recipient={recipient}>
|
<DocumentSigningRecipientProvider recipient={recipient}>
|
||||||
|
|||||||
74
apps/remix/app/utils/field-signing/checkbox-field.ts
Normal file
74
apps/remix/app/utils/field-signing/checkbox-field.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { FieldType } from '@prisma/client';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import type { TFieldCheckbox } from '@documenso/lib/types/field';
|
||||||
|
import { parseCheckboxCustomText } from '@documenso/lib/utils/fields';
|
||||||
|
import type { TSignEnvelopeFieldValue } from '@documenso/trpc/server/envelope-router/sign-envelope-field.types';
|
||||||
|
import { checkboxValidationSigns } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
|
||||||
|
|
||||||
|
import { SignFieldCheckboxDialog } from '~/components/dialogs/sign-field-checkbox-dialog';
|
||||||
|
|
||||||
|
type HandleCheckboxFieldClickOptions = {
|
||||||
|
field: TFieldCheckbox;
|
||||||
|
clickedCheckboxIndex: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleCheckboxFieldClick = async (
|
||||||
|
options: HandleCheckboxFieldClickOptions,
|
||||||
|
): Promise<Extract<TSignEnvelopeFieldValue, { type: typeof FieldType.CHECKBOX }> | null> => {
|
||||||
|
const { field, clickedCheckboxIndex } = options;
|
||||||
|
|
||||||
|
if (field.type !== FieldType.CHECKBOX) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||||
|
message: 'Invalid field type',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { values = [], validationRule, validationLength } = field.fieldMeta;
|
||||||
|
const { customText } = field;
|
||||||
|
|
||||||
|
const currentCheckedIndices: number[] = customText ? parseCheckboxCustomText(customText) : [];
|
||||||
|
|
||||||
|
const newValues = values.map((_value, i) => {
|
||||||
|
let isChecked = currentCheckedIndices.includes(i);
|
||||||
|
|
||||||
|
if (i === clickedCheckboxIndex) {
|
||||||
|
isChecked = !isChecked;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
index: i,
|
||||||
|
isChecked,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
let checkedValues: number[] | null = newValues.filter((v) => v.isChecked).map((v) => v.index);
|
||||||
|
|
||||||
|
if (validationRule && validationLength) {
|
||||||
|
const checkboxValidationRule = checkboxValidationSigns.find(
|
||||||
|
(sign) => sign.label === validationRule,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!checkboxValidationRule) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||||
|
message: 'Invalid checkbox validation rule',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
checkedValues = await SignFieldCheckboxDialog.call({
|
||||||
|
fieldMeta: field.fieldMeta,
|
||||||
|
validationRule: checkboxValidationRule.value,
|
||||||
|
validationLength,
|
||||||
|
preselectedIndices: currentCheckedIndices,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!checkedValues) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: FieldType.CHECKBOX,
|
||||||
|
value: checkedValues,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -9,12 +9,13 @@ import { SignFieldEmailDialog } from '~/components/dialogs/sign-field-email-dial
|
|||||||
type HandleEmailFieldClickOptions = {
|
type HandleEmailFieldClickOptions = {
|
||||||
field: TFieldEmail;
|
field: TFieldEmail;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
|
placeholderEmail: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleEmailFieldClick = async (
|
export const handleEmailFieldClick = async (
|
||||||
options: HandleEmailFieldClickOptions,
|
options: HandleEmailFieldClickOptions,
|
||||||
): Promise<Extract<TSignEnvelopeFieldValue, { type: typeof FieldType.EMAIL }> | null> => {
|
): Promise<Extract<TSignEnvelopeFieldValue, { type: typeof FieldType.EMAIL }> | null> => {
|
||||||
const { field, email } = options;
|
const { field, email, placeholderEmail } = options;
|
||||||
|
|
||||||
if (field.type !== FieldType.EMAIL) {
|
if (field.type !== FieldType.EMAIL) {
|
||||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||||
@ -32,7 +33,9 @@ export const handleEmailFieldClick = async (
|
|||||||
let emailToInsert = email;
|
let emailToInsert = email;
|
||||||
|
|
||||||
if (!emailToInsert) {
|
if (!emailToInsert) {
|
||||||
emailToInsert = await SignFieldEmailDialog.call({});
|
emailToInsert = await SignFieldEmailDialog.call({
|
||||||
|
placeholderEmail,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!emailToInsert) {
|
if (!emailToInsert) {
|
||||||
|
|||||||
@ -30,7 +30,6 @@ export const handleSignatureFieldClick = async (
|
|||||||
return {
|
return {
|
||||||
type: FieldType.SIGNATURE,
|
type: FieldType.SIGNATURE,
|
||||||
value: null,
|
value: null,
|
||||||
isBase64: false,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -51,6 +50,5 @@ export const handleSignatureFieldClick = async (
|
|||||||
return {
|
return {
|
||||||
type: FieldType.SIGNATURE,
|
type: FieldType.SIGNATURE,
|
||||||
value: signatureToInsert,
|
value: signatureToInsert,
|
||||||
isBase64: signatureToInsert.startsWith('data:image'),
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -14,7 +14,7 @@
|
|||||||
"with:env": "dotenv -e ../../.env -e ../../.env.local --"
|
"with:env": "dotenv -e ../../.env -e ../../.env.local --"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cantoo/pdf-lib": "^2.3.2",
|
"@cantoo/pdf-lib": "^2.5.2",
|
||||||
"@documenso/api": "*",
|
"@documenso/api": "*",
|
||||||
"@documenso/assets": "*",
|
"@documenso/assets": "*",
|
||||||
"@documenso/auth": "*",
|
"@documenso/auth": "*",
|
||||||
@ -103,5 +103,5 @@
|
|||||||
"vite-plugin-babel-macros": "^1.0.6",
|
"vite-plugin-babel-macros": "^1.0.6",
|
||||||
"vite-tsconfig-paths": "^5.1.4"
|
"vite-tsconfig-paths": "^5.1.4"
|
||||||
},
|
},
|
||||||
"version": "1.13.2"
|
"version": "1.13.1"
|
||||||
}
|
}
|
||||||
BIN
apps/remix/public/fonts/noto-sans-chinese.ttf
Normal file
BIN
apps/remix/public/fonts/noto-sans-chinese.ttf
Normal file
Binary file not shown.
BIN
apps/remix/public/fonts/noto-sans-japanese.ttf
Normal file
BIN
apps/remix/public/fonts/noto-sans-japanese.ttf
Normal file
Binary file not shown.
BIN
apps/remix/public/fonts/noto-sans-korean.ttf
Normal file
BIN
apps/remix/public/fonts/noto-sans-korean.ttf
Normal file
Binary file not shown.
@ -30,4 +30,6 @@ server.use(
|
|||||||
|
|
||||||
const handler = handle(build, server);
|
const handler = handle(build, server);
|
||||||
|
|
||||||
serve({ fetch: handler.fetch, port: 3000 });
|
const port = parseInt(process.env.PORT || '3000', 10);
|
||||||
|
|
||||||
|
serve({ fetch: handler.fetch, port });
|
||||||
|
|||||||
@ -21,7 +21,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: parseInt(process.env.PORT || '3000', 10),
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
@ -85,6 +85,7 @@ export default defineConfig({
|
|||||||
'nodemailer',
|
'nodemailer',
|
||||||
/playwright/,
|
/playwright/,
|
||||||
'@playwright/browser-chromium',
|
'@playwright/browser-chromium',
|
||||||
|
'skia-canvas',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
BIN
assets/field-font-alignment.pdf
Normal file
BIN
assets/field-font-alignment.pdf
Normal file
Binary file not shown.
BIN
assets/field-meta.pdf
Normal file
BIN
assets/field-meta.pdf
Normal file
Binary file not shown.
46
package-lock.json
generated
46
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@documenso/root",
|
"name": "@documenso/root",
|
||||||
"version": "1.13.2",
|
"version": "1.13.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@documenso/root",
|
"name": "@documenso/root",
|
||||||
"version": "1.13.2",
|
"version": "1.13.1",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"apps/*",
|
"apps/*",
|
||||||
"packages/*"
|
"packages/*"
|
||||||
@ -89,9 +89,9 @@
|
|||||||
},
|
},
|
||||||
"apps/remix": {
|
"apps/remix": {
|
||||||
"name": "@documenso/remix",
|
"name": "@documenso/remix",
|
||||||
"version": "1.13.2",
|
"version": "1.13.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cantoo/pdf-lib": "^2.3.2",
|
"@cantoo/pdf-lib": "^2.5.2",
|
||||||
"@documenso/api": "*",
|
"@documenso/api": "*",
|
||||||
"@documenso/assets": "*",
|
"@documenso/assets": "*",
|
||||||
"@documenso/auth": "*",
|
"@documenso/auth": "*",
|
||||||
@ -12557,6 +12557,16 @@
|
|||||||
"@types/pg": "*"
|
"@types/pg": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/pngjs": {
|
||||||
|
"version": "6.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/pngjs/-/pngjs-6.0.5.tgz",
|
||||||
|
"integrity": "sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/prop-types": {
|
"node_modules/@types/prop-types": {
|
||||||
"version": "15.7.14",
|
"version": "15.7.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz",
|
||||||
@ -27544,6 +27554,19 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pixelmatch": {
|
||||||
|
"version": "7.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.1.0.tgz",
|
||||||
|
"integrity": "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"pngjs": "^7.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"pixelmatch": "bin/pixelmatch"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pkg-dir": {
|
"node_modules/pkg-dir": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
|
||||||
@ -27740,6 +27763,16 @@
|
|||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pngjs": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.19.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pofile": {
|
"node_modules/pofile": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/pofile/-/pofile-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/pofile/-/pofile-1.1.4.tgz",
|
||||||
@ -36183,7 +36216,10 @@
|
|||||||
"@documenso/lib": "*",
|
"@documenso/lib": "*",
|
||||||
"@documenso/prisma": "*",
|
"@documenso/prisma": "*",
|
||||||
"@playwright/test": "1.52.0",
|
"@playwright/test": "1.52.0",
|
||||||
"@types/node": "^20"
|
"@types/node": "^20",
|
||||||
|
"@types/pngjs": "^6.0.5",
|
||||||
|
"pixelmatch": "^7.1.0",
|
||||||
|
"pngjs": "^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/app-tests/node_modules/@playwright/test": {
|
"packages/app-tests/node_modules/@playwright/test": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.13.2",
|
"version": "1.13.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"dev": "turbo run dev --filter=@documenso/remix",
|
"dev": "turbo run dev --filter=@documenso/remix",
|
||||||
|
|||||||
@ -20,12 +20,12 @@ import {
|
|||||||
getEnvelopeWhereInput,
|
getEnvelopeWhereInput,
|
||||||
} from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
} from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||||
import { deleteDocumentField } from '@documenso/lib/server-only/field/delete-document-field';
|
import { deleteDocumentField } from '@documenso/lib/server-only/field/delete-document-field';
|
||||||
import { updateDocumentFields } from '@documenso/lib/server-only/field/update-document-fields';
|
import { updateEnvelopeFields } from '@documenso/lib/server-only/field/update-envelope-fields';
|
||||||
import { insertFormValuesInPdf } from '@documenso/lib/server-only/pdf/insert-form-values-in-pdf';
|
import { insertFormValuesInPdf } from '@documenso/lib/server-only/pdf/insert-form-values-in-pdf';
|
||||||
import { deleteDocumentRecipient } from '@documenso/lib/server-only/recipient/delete-document-recipient';
|
import { deleteEnvelopeRecipient } from '@documenso/lib/server-only/recipient/delete-envelope-recipient';
|
||||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||||
import { setDocumentRecipients } from '@documenso/lib/server-only/recipient/set-document-recipients';
|
import { setDocumentRecipients } from '@documenso/lib/server-only/recipient/set-document-recipients';
|
||||||
import { updateDocumentRecipients } from '@documenso/lib/server-only/recipient/update-document-recipients';
|
import { updateEnvelopeRecipients } from '@documenso/lib/server-only/recipient/update-envelope-recipients';
|
||||||
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
|
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
|
||||||
import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template';
|
import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template';
|
||||||
import { findTemplates } from '@documenso/lib/server-only/template/find-templates';
|
import { findTemplates } from '@documenso/lib/server-only/template/find-templates';
|
||||||
@ -1285,7 +1285,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedRecipient = await updateDocumentRecipients({
|
const updatedRecipient = await updateEnvelopeRecipients({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
id: {
|
id: {
|
||||||
@ -1336,7 +1336,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const deletedRecipient = await deleteDocumentRecipient({
|
const deletedRecipient = await deleteEnvelopeRecipient({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
recipientId: Number(recipientId),
|
recipientId: Number(recipientId),
|
||||||
@ -1634,10 +1634,13 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const { fields } = await updateDocumentFields({
|
const { fields } = await updateEnvelopeFields({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
documentId: legacyDocumentId,
|
id: {
|
||||||
|
type: 'documentId',
|
||||||
|
id: legacyDocumentId,
|
||||||
|
},
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
id: Number(fieldId),
|
id: Number(fieldId),
|
||||||
|
|||||||
498
packages/app-tests/constants/field-alignment-pdf.ts
Normal file
498
packages/app-tests/constants/field-alignment-pdf.ts
Normal file
@ -0,0 +1,498 @@
|
|||||||
|
import { FieldType } from '@prisma/client';
|
||||||
|
|
||||||
|
import type { TFieldAndMeta } from '@documenso/lib/types/field-meta';
|
||||||
|
import { toCheckboxCustomText } from '@documenso/lib/utils/fields';
|
||||||
|
|
||||||
|
export type FieldTestData = TFieldAndMeta & {
|
||||||
|
page: number;
|
||||||
|
positionX: number;
|
||||||
|
positionY: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
customText: string;
|
||||||
|
signature?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const columnWidth = 19.125;
|
||||||
|
const rowHeight = 6.7;
|
||||||
|
|
||||||
|
const alignmentGridStartX = 31;
|
||||||
|
const alignmentGridStartY = 19.02;
|
||||||
|
|
||||||
|
export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
|
||||||
|
/**
|
||||||
|
* Row 1 EMAIL
|
||||||
|
*/
|
||||||
|
{
|
||||||
|
type: FieldType.EMAIL,
|
||||||
|
fieldMeta: {
|
||||||
|
fontSize: 10,
|
||||||
|
textAlign: 'left',
|
||||||
|
type: 'email',
|
||||||
|
},
|
||||||
|
page: 1,
|
||||||
|
height: rowHeight,
|
||||||
|
width: columnWidth,
|
||||||
|
positionX: 0,
|
||||||
|
positionY: 0,
|
||||||
|
customText: 'admin@documenso.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.EMAIL,
|
||||||
|
fieldMeta: {
|
||||||
|
textAlign: 'center',
|
||||||
|
type: 'email',
|
||||||
|
},
|
||||||
|
page: 1,
|
||||||
|
height: rowHeight,
|
||||||
|
width: columnWidth,
|
||||||
|
positionX: 0,
|
||||||
|
positionY: 0,
|
||||||
|
customText: 'admin@documenso.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.EMAIL,
|
||||||
|
fieldMeta: {
|
||||||
|
fontSize: 20,
|
||||||
|
textAlign: 'right',
|
||||||
|
type: 'email',
|
||||||
|
},
|
||||||
|
page: 1,
|
||||||
|
height: rowHeight,
|
||||||
|
width: columnWidth,
|
||||||
|
positionX: 0,
|
||||||
|
positionY: 0,
|
||||||
|
customText: 'admin@documenso.com',
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Row 2 NAME
|
||||||
|
*/
|
||||||
|
{
|
||||||
|
type: FieldType.NAME,
|
||||||
|
fieldMeta: {
|
||||||
|
fontSize: 10,
|
||||||
|
textAlign: 'left',
|
||||||
|
type: 'name',
|
||||||
|
},
|
||||||
|
page: 1,
|
||||||
|
height: rowHeight,
|
||||||
|
width: columnWidth,
|
||||||
|
positionX: 0,
|
||||||
|
positionY: 0,
|
||||||
|
customText: 'John Doe',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.NAME,
|
||||||
|
fieldMeta: {
|
||||||
|
textAlign: 'center',
|
||||||
|
type: 'name',
|
||||||
|
},
|
||||||
|
page: 1,
|
||||||
|
height: rowHeight,
|
||||||
|
width: columnWidth,
|
||||||
|
positionX: 0,
|
||||||
|
positionY: 0,
|
||||||
|
customText: 'John Doe',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.NAME,
|
||||||
|
fieldMeta: {
|
||||||
|
fontSize: 20,
|
||||||
|
textAlign: 'right',
|
||||||
|
type: 'name',
|
||||||
|
},
|
||||||
|
page: 1,
|
||||||
|
height: rowHeight,
|
||||||
|
width: columnWidth,
|
||||||
|
positionX: 0,
|
||||||
|
positionY: 0,
|
||||||
|
customText: 'John Doe',
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Row 3 DATE
|
||||||
|
*/
|
||||||
|
{
|
||||||
|
type: FieldType.DATE,
|
||||||
|
fieldMeta: {
|
||||||
|
fontSize: 10,
|
||||||
|
textAlign: 'left',
|
||||||
|
type: 'date',
|
||||||
|
},
|
||||||
|
page: 1,
|
||||||
|
height: rowHeight,
|
||||||
|
width: columnWidth,
|
||||||
|
positionX: 0,
|
||||||
|
positionY: 0,
|
||||||
|
customText: '123456789',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.DATE,
|
||||||
|
fieldMeta: {
|
||||||
|
textAlign: 'center',
|
||||||
|
type: 'date',
|
||||||
|
},
|
||||||
|
page: 1,
|
||||||
|
height: rowHeight,
|
||||||
|
width: columnWidth,
|
||||||
|
positionX: 0,
|
||||||
|
positionY: 0,
|
||||||
|
customText: '123456789',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.DATE,
|
||||||
|
fieldMeta: {
|
||||||
|
fontSize: 20,
|
||||||
|
textAlign: 'right',
|
||||||
|
type: 'date',
|
||||||
|
},
|
||||||
|
page: 1,
|
||||||
|
height: rowHeight,
|
||||||
|
width: columnWidth,
|
||||||
|
positionX: 0,
|
||||||
|
positionY: 0,
|
||||||
|
customText: '123456789',
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Row 4 TEXT
|
||||||
|
*/
|
||||||
|
{
|
||||||
|
type: FieldType.TEXT,
|
||||||
|
fieldMeta: {
|
||||||
|
fontSize: 10,
|
||||||
|
textAlign: 'left',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
page: 1,
|
||||||
|
height: rowHeight,
|
||||||
|
width: columnWidth,
|
||||||
|
positionX: 0,
|
||||||
|
positionY: 0,
|
||||||
|
customText: '123456789',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.TEXT,
|
||||||
|
fieldMeta: {
|
||||||
|
textAlign: 'center',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
page: 1,
|
||||||
|
height: rowHeight,
|
||||||
|
width: columnWidth,
|
||||||
|
positionX: 0,
|
||||||
|
positionY: 0,
|
||||||
|
customText: '123456789',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.TEXT,
|
||||||
|
fieldMeta: {
|
||||||
|
fontSize: 20,
|
||||||
|
textAlign: 'right',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
page: 1,
|
||||||
|
height: rowHeight,
|
||||||
|
width: columnWidth,
|
||||||
|
positionX: 0,
|
||||||
|
positionY: 0,
|
||||||
|
customText: '123456789',
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Row 5 NUMBER
|
||||||
|
*/
|
||||||
|
{
|
||||||
|
type: FieldType.NUMBER,
|
||||||
|
fieldMeta: {
|
||||||
|
fontSize: 10,
|
||||||
|
textAlign: 'left',
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
page: 1,
|
||||||
|
height: rowHeight,
|
||||||
|
width: columnWidth,
|
||||||
|
positionX: 0,
|
||||||
|
positionY: 0,
|
||||||
|
customText: '123456789',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.NUMBER,
|
||||||
|
fieldMeta: {
|
||||||
|
textAlign: 'center',
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
page: 1,
|
||||||
|
height: rowHeight,
|
||||||
|
width: columnWidth,
|
||||||
|
positionX: 0,
|
||||||
|
positionY: 0,
|
||||||
|
customText: '123456789',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.NUMBER,
|
||||||
|
fieldMeta: {
|
||||||
|
fontSize: 20,
|
||||||
|
textAlign: 'right',
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
page: 1,
|
||||||
|
height: rowHeight,
|
||||||
|
width: columnWidth,
|
||||||
|
positionX: 0,
|
||||||
|
positionY: 0,
|
||||||
|
customText: '123456789',
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Row 6 Initials
|
||||||
|
*/
|
||||||
|
{
|
||||||
|
type: FieldType.INITIALS,
|
||||||
|
fieldMeta: {
|
||||||
|
fontSize: 10,
|
||||||
|
textAlign: 'left',
|
||||||
|
type: 'initials',
|
||||||
|
},
|
||||||
|
page: 1,
|
||||||
|
height: rowHeight,
|
||||||
|
width: columnWidth,
|
||||||
|
positionX: 0,
|
||||||
|
positionY: 0,
|
||||||
|
customText: 'JD',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.INITIALS,
|
||||||
|
fieldMeta: {
|
||||||
|
textAlign: 'center',
|
||||||
|
type: 'initials',
|
||||||
|
},
|
||||||
|
page: 1,
|
||||||
|
height: rowHeight,
|
||||||
|
width: columnWidth,
|
||||||
|
positionX: 0,
|
||||||
|
positionY: 0,
|
||||||
|
customText: 'JD',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.INITIALS,
|
||||||
|
fieldMeta: {
|
||||||
|
fontSize: 20,
|
||||||
|
textAlign: 'right',
|
||||||
|
type: 'initials',
|
||||||
|
},
|
||||||
|
page: 1,
|
||||||
|
height: rowHeight,
|
||||||
|
width: columnWidth,
|
||||||
|
positionX: 0,
|
||||||
|
positionY: 0,
|
||||||
|
customText: 'JD',
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Row 7 Radio
|
||||||
|
*/
|
||||||
|
{
|
||||||
|
type: FieldType.RADIO,
|
||||||
|
fieldMeta: {
|
||||||
|
fontSize: 10,
|
||||||
|
direction: 'vertical',
|
||||||
|
type: 'radio',
|
||||||
|
values: [
|
||||||
|
{ id: 1, checked: true, value: 'Option 1' },
|
||||||
|
{ id: 2, checked: false, value: 'Option 2' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
page: 1,
|
||||||
|
height: rowHeight,
|
||||||
|
width: columnWidth,
|
||||||
|
positionX: 0,
|
||||||
|
positionY: 0,
|
||||||
|
customText: '0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.RADIO,
|
||||||
|
fieldMeta: {
|
||||||
|
direction: 'vertical',
|
||||||
|
type: 'radio',
|
||||||
|
values: [
|
||||||
|
{ id: 1, checked: false, value: 'Option 1' },
|
||||||
|
{ id: 2, checked: true, value: 'Option 2' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
page: 1,
|
||||||
|
height: rowHeight,
|
||||||
|
width: columnWidth,
|
||||||
|
positionX: 0,
|
||||||
|
positionY: 0,
|
||||||
|
customText: '2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.RADIO,
|
||||||
|
fieldMeta: {
|
||||||
|
fontSize: 20,
|
||||||
|
direction: 'horizontal',
|
||||||
|
type: 'radio',
|
||||||
|
values: [
|
||||||
|
{ id: 1, checked: false, value: 'Option 1' },
|
||||||
|
{ id: 2, checked: false, value: 'Option 2' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
page: 1,
|
||||||
|
height: rowHeight,
|
||||||
|
width: columnWidth,
|
||||||
|
positionX: 0,
|
||||||
|
positionY: 0,
|
||||||
|
customText: '',
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Row 8 Checkbox
|
||||||
|
*/
|
||||||
|
{
|
||||||
|
type: FieldType.CHECKBOX,
|
||||||
|
fieldMeta: {
|
||||||
|
fontSize: 10,
|
||||||
|
direction: 'vertical',
|
||||||
|
type: 'checkbox',
|
||||||
|
values: [
|
||||||
|
{ id: 1, checked: true, value: 'Option 1' },
|
||||||
|
{ id: 2, checked: false, value: 'Option 2' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
page: 1,
|
||||||
|
height: rowHeight,
|
||||||
|
width: columnWidth,
|
||||||
|
positionX: 0,
|
||||||
|
positionY: 0,
|
||||||
|
customText: toCheckboxCustomText([0]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.CHECKBOX,
|
||||||
|
fieldMeta: {
|
||||||
|
direction: 'vertical',
|
||||||
|
type: 'checkbox',
|
||||||
|
values: [
|
||||||
|
{ id: 1, checked: false, value: 'Option 1' },
|
||||||
|
{ id: 2, checked: true, value: 'Option 2' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
page: 1,
|
||||||
|
height: rowHeight,
|
||||||
|
width: columnWidth,
|
||||||
|
positionX: 0,
|
||||||
|
positionY: 0,
|
||||||
|
customText: toCheckboxCustomText([1]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.CHECKBOX,
|
||||||
|
fieldMeta: {
|
||||||
|
fontSize: 20,
|
||||||
|
direction: 'horizontal',
|
||||||
|
type: 'checkbox',
|
||||||
|
values: [
|
||||||
|
{ id: 1, checked: false, value: 'Option 1' },
|
||||||
|
{ id: 2, checked: false, value: 'Option 2' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
page: 1,
|
||||||
|
height: rowHeight,
|
||||||
|
width: columnWidth,
|
||||||
|
positionX: 0,
|
||||||
|
positionY: 0,
|
||||||
|
customText: '',
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Row 8 Dropdown
|
||||||
|
*/
|
||||||
|
{
|
||||||
|
type: FieldType.DROPDOWN,
|
||||||
|
fieldMeta: {
|
||||||
|
fontSize: 10,
|
||||||
|
values: [{ value: 'Option 1' }, { value: 'Option 2' }],
|
||||||
|
type: 'dropdown',
|
||||||
|
},
|
||||||
|
page: 1,
|
||||||
|
height: rowHeight,
|
||||||
|
width: columnWidth,
|
||||||
|
positionX: 0,
|
||||||
|
positionY: 0,
|
||||||
|
customText: 'Option 1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.DROPDOWN,
|
||||||
|
fieldMeta: {
|
||||||
|
values: [{ value: 'Option 1' }, { value: 'Option 2' }],
|
||||||
|
type: 'dropdown',
|
||||||
|
},
|
||||||
|
page: 1,
|
||||||
|
height: rowHeight,
|
||||||
|
width: columnWidth,
|
||||||
|
positionX: 0,
|
||||||
|
positionY: 0,
|
||||||
|
customText: 'Option 1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.DROPDOWN,
|
||||||
|
fieldMeta: {
|
||||||
|
fontSize: 20,
|
||||||
|
values: [{ value: 'Option 1' }, { value: 'Option 2' }, { value: 'Option 3' }],
|
||||||
|
type: 'dropdown',
|
||||||
|
},
|
||||||
|
page: 1,
|
||||||
|
height: rowHeight,
|
||||||
|
width: columnWidth,
|
||||||
|
positionX: 0,
|
||||||
|
positionY: 0,
|
||||||
|
customText: 'Option 1',
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Row 9 Signature
|
||||||
|
*/
|
||||||
|
{
|
||||||
|
type: FieldType.SIGNATURE,
|
||||||
|
fieldMeta: {
|
||||||
|
fontSize: 10,
|
||||||
|
type: 'signature',
|
||||||
|
},
|
||||||
|
page: 1,
|
||||||
|
height: rowHeight,
|
||||||
|
width: columnWidth,
|
||||||
|
positionX: 0,
|
||||||
|
positionY: 0,
|
||||||
|
customText: '',
|
||||||
|
signature: 'My Signature',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.SIGNATURE,
|
||||||
|
fieldMeta: {
|
||||||
|
type: 'signature',
|
||||||
|
},
|
||||||
|
page: 1,
|
||||||
|
height: rowHeight,
|
||||||
|
width: columnWidth,
|
||||||
|
positionX: 0,
|
||||||
|
positionY: 0,
|
||||||
|
customText: '',
|
||||||
|
signature: 'My Signature',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.SIGNATURE,
|
||||||
|
fieldMeta: {
|
||||||
|
fontSize: 20,
|
||||||
|
type: 'signature',
|
||||||
|
},
|
||||||
|
page: 1,
|
||||||
|
height: rowHeight,
|
||||||
|
width: columnWidth,
|
||||||
|
positionX: 0,
|
||||||
|
positionY: 0,
|
||||||
|
customText: '',
|
||||||
|
signature: 'My Signature',
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const formatAlignmentTestFields = ALIGNMENT_TEST_FIELDS.map((field, index) => {
|
||||||
|
const row = Math.floor(index / 3);
|
||||||
|
const column = index % 3;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...field,
|
||||||
|
positionX: alignmentGridStartX + column * columnWidth,
|
||||||
|
positionY: alignmentGridStartY + row * rowHeight,
|
||||||
|
};
|
||||||
|
});
|
||||||
482
packages/app-tests/constants/field-meta-pdf.ts
Normal file
482
packages/app-tests/constants/field-meta-pdf.ts
Normal file
@ -0,0 +1,482 @@
|
|||||||
|
import { FieldType } from '@prisma/client';
|
||||||
|
|
||||||
|
import { toCheckboxCustomText } from '@documenso/lib/utils/fields';
|
||||||
|
import {
|
||||||
|
CheckboxValidationRules,
|
||||||
|
numberFormatValues,
|
||||||
|
} from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
|
||||||
|
|
||||||
|
import type { FieldTestData } from './field-alignment-pdf';
|
||||||
|
|
||||||
|
const columnWidth = 20.1;
|
||||||
|
const fullColumnWidth = 75.8;
|
||||||
|
const rowHeight = 9.8;
|
||||||
|
const rowPadding = 1.8;
|
||||||
|
|
||||||
|
const alignmentGridStartX = 11.85;
|
||||||
|
const alignmentGridStartY = 15.07;
|
||||||
|
|
||||||
|
const calculatePosition = (row: number, column: number, width: 'full' | 'column' = 'column') => {
|
||||||
|
return {
|
||||||
|
height: rowHeight,
|
||||||
|
width: width === 'full' ? fullColumnWidth : columnWidth,
|
||||||
|
positionX: alignmentGridStartX + (column ?? 0) * columnWidth,
|
||||||
|
positionY: alignmentGridStartY + row * (rowHeight + rowPadding),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
|
||||||
|
/**
|
||||||
|
* PAGE 2 Signature
|
||||||
|
*/
|
||||||
|
{
|
||||||
|
type: FieldType.SIGNATURE,
|
||||||
|
fieldMeta: {
|
||||||
|
type: 'signature',
|
||||||
|
},
|
||||||
|
page: 2,
|
||||||
|
...calculatePosition(0, 0),
|
||||||
|
customText: '',
|
||||||
|
signature: 'My Signature',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.SIGNATURE,
|
||||||
|
fieldMeta: {
|
||||||
|
type: 'signature',
|
||||||
|
},
|
||||||
|
page: 2,
|
||||||
|
...calculatePosition(1, 0),
|
||||||
|
customText: '',
|
||||||
|
signature: 'My Signature',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.SIGNATURE,
|
||||||
|
fieldMeta: {
|
||||||
|
type: 'signature',
|
||||||
|
},
|
||||||
|
page: 2,
|
||||||
|
...calculatePosition(2, 0),
|
||||||
|
customText: '',
|
||||||
|
signature: 'My Signature',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.SIGNATURE,
|
||||||
|
fieldMeta: {
|
||||||
|
type: 'signature',
|
||||||
|
},
|
||||||
|
page: 2,
|
||||||
|
...calculatePosition(3, 0),
|
||||||
|
customText: '',
|
||||||
|
signature: 'My Signature',
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PAGE 3 TEXT
|
||||||
|
*/
|
||||||
|
{
|
||||||
|
type: FieldType.TEXT,
|
||||||
|
fieldMeta: {
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
page: 3,
|
||||||
|
...calculatePosition(0, 0, 'full'),
|
||||||
|
customText: '123456789',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.TEXT,
|
||||||
|
fieldMeta: {
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
page: 3,
|
||||||
|
...calculatePosition(1, 0),
|
||||||
|
customText: '123456789123456789123456789123456789',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.TEXT,
|
||||||
|
fieldMeta: {
|
||||||
|
type: 'text',
|
||||||
|
characterLimit: 5,
|
||||||
|
},
|
||||||
|
page: 3,
|
||||||
|
...calculatePosition(2, 0),
|
||||||
|
customText: '12345',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.TEXT,
|
||||||
|
fieldMeta: {
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'Demo Placeholder',
|
||||||
|
},
|
||||||
|
page: 3,
|
||||||
|
...calculatePosition(3, 0),
|
||||||
|
customText: '123456789',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.TEXT,
|
||||||
|
fieldMeta: {
|
||||||
|
type: 'text',
|
||||||
|
label: 'Demo Label',
|
||||||
|
},
|
||||||
|
page: 3,
|
||||||
|
...calculatePosition(3, 1),
|
||||||
|
customText: '123456789',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.TEXT,
|
||||||
|
fieldMeta: {
|
||||||
|
type: 'text',
|
||||||
|
text: 'Prefilled text',
|
||||||
|
},
|
||||||
|
page: 3,
|
||||||
|
...calculatePosition(3, 2),
|
||||||
|
customText: '123456789',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.TEXT,
|
||||||
|
fieldMeta: {
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
page: 3,
|
||||||
|
...calculatePosition(4, 0),
|
||||||
|
customText: '123456789',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.TEXT,
|
||||||
|
fieldMeta: {
|
||||||
|
type: 'text',
|
||||||
|
readOnly: true,
|
||||||
|
text: 'Readonly Value',
|
||||||
|
},
|
||||||
|
page: 3,
|
||||||
|
...calculatePosition(4, 1),
|
||||||
|
customText: 'Readonly Value',
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PAGE 4 NUMBER
|
||||||
|
*/
|
||||||
|
{
|
||||||
|
type: FieldType.NUMBER,
|
||||||
|
fieldMeta: {
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
page: 4,
|
||||||
|
...calculatePosition(0, 0, 'full'),
|
||||||
|
customText: '123456789',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.NUMBER,
|
||||||
|
fieldMeta: {
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
page: 4,
|
||||||
|
...calculatePosition(1, 0),
|
||||||
|
customText: '123456789123456789123456789123456789',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.NUMBER,
|
||||||
|
fieldMeta: {
|
||||||
|
type: 'number',
|
||||||
|
minValue: 0,
|
||||||
|
maxValue: 100,
|
||||||
|
},
|
||||||
|
page: 4,
|
||||||
|
...calculatePosition(2, 0),
|
||||||
|
customText: '50',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.NUMBER,
|
||||||
|
fieldMeta: {
|
||||||
|
type: 'number',
|
||||||
|
numberFormat: numberFormatValues[0].value, // Todo: Envelopes - Check this.
|
||||||
|
value: '123,456,789.00',
|
||||||
|
},
|
||||||
|
page: 4,
|
||||||
|
...calculatePosition(2, 1),
|
||||||
|
customText: '123,456,789.00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.NUMBER,
|
||||||
|
fieldMeta: {
|
||||||
|
type: 'number',
|
||||||
|
placeholder: 'Demo Placeholder',
|
||||||
|
},
|
||||||
|
page: 4,
|
||||||
|
...calculatePosition(3, 0),
|
||||||
|
customText: '123456789',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.NUMBER,
|
||||||
|
fieldMeta: {
|
||||||
|
type: 'number',
|
||||||
|
label: 'Demo Label',
|
||||||
|
},
|
||||||
|
page: 4,
|
||||||
|
...calculatePosition(3, 1),
|
||||||
|
customText: '123456789',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.NUMBER,
|
||||||
|
fieldMeta: {
|
||||||
|
type: 'number',
|
||||||
|
value: '123',
|
||||||
|
},
|
||||||
|
page: 4,
|
||||||
|
...calculatePosition(3, 2),
|
||||||
|
customText: '123456789',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.NUMBER,
|
||||||
|
fieldMeta: {
|
||||||
|
type: 'number',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
page: 4,
|
||||||
|
...calculatePosition(4, 0),
|
||||||
|
customText: '123456789',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.NUMBER,
|
||||||
|
fieldMeta: {
|
||||||
|
type: 'number',
|
||||||
|
readOnly: true,
|
||||||
|
},
|
||||||
|
page: 4,
|
||||||
|
...calculatePosition(4, 1),
|
||||||
|
customText: '123456789',
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PAGE 5 RADIO
|
||||||
|
*/
|
||||||
|
{
|
||||||
|
type: FieldType.RADIO,
|
||||||
|
fieldMeta: {
|
||||||
|
direction: 'horizontal',
|
||||||
|
type: 'radio',
|
||||||
|
values: [
|
||||||
|
{ id: 1, checked: true, value: 'Option 1' },
|
||||||
|
{ id: 2, checked: false, value: 'Option 2' },
|
||||||
|
{ id: 3, checked: false, value: 'Option 3' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
page: 5,
|
||||||
|
...calculatePosition(0, 0, 'full'),
|
||||||
|
customText: '0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.RADIO,
|
||||||
|
fieldMeta: {
|
||||||
|
direction: 'vertical',
|
||||||
|
type: 'radio',
|
||||||
|
values: [
|
||||||
|
{ id: 1, checked: false, value: 'Option 1' },
|
||||||
|
{ id: 2, checked: true, value: 'Option 2' },
|
||||||
|
{ id: 3, checked: false, value: 'Option 3' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
page: 5,
|
||||||
|
...calculatePosition(1, 0),
|
||||||
|
customText: '2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.RADIO,
|
||||||
|
fieldMeta: {
|
||||||
|
direction: 'vertical',
|
||||||
|
type: 'radio',
|
||||||
|
values: [
|
||||||
|
{ id: 1, checked: false, value: 'Option 1' },
|
||||||
|
{ id: 2, checked: false, value: 'Option 2' },
|
||||||
|
{ id: 3, checked: false, value: 'Option 3' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
page: 5,
|
||||||
|
...calculatePosition(2, 0),
|
||||||
|
customText: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.RADIO,
|
||||||
|
fieldMeta: {
|
||||||
|
direction: 'vertical',
|
||||||
|
type: 'radio',
|
||||||
|
values: [
|
||||||
|
{ id: 1, checked: false, value: 'Option 1' },
|
||||||
|
{ id: 2, checked: false, value: 'Option 2' },
|
||||||
|
{ id: 3, checked: false, value: 'Option 3' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
page: 5,
|
||||||
|
...calculatePosition(2, 1),
|
||||||
|
customText: '',
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PAGE 6 CHECKBOX
|
||||||
|
*/
|
||||||
|
{
|
||||||
|
type: FieldType.CHECKBOX,
|
||||||
|
fieldMeta: {
|
||||||
|
direction: 'horizontal',
|
||||||
|
type: 'checkbox',
|
||||||
|
values: [
|
||||||
|
{ id: 1, checked: true, value: 'Option 1' },
|
||||||
|
{ id: 2, checked: false, value: 'Option 2' },
|
||||||
|
{ id: 2, checked: false, value: 'Option 3' },
|
||||||
|
{ id: 2, checked: false, value: 'Option 4' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
page: 6,
|
||||||
|
...calculatePosition(0, 0, 'full'),
|
||||||
|
customText: toCheckboxCustomText([0]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.CHECKBOX,
|
||||||
|
fieldMeta: {
|
||||||
|
direction: 'vertical',
|
||||||
|
type: 'checkbox',
|
||||||
|
values: [
|
||||||
|
{ id: 1, checked: false, value: 'Option 1' },
|
||||||
|
{ id: 2, checked: true, value: 'Option 2' },
|
||||||
|
{ id: 2, checked: true, value: 'Option 3' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
page: 6,
|
||||||
|
...calculatePosition(1, 0),
|
||||||
|
customText: toCheckboxCustomText([1]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.CHECKBOX,
|
||||||
|
fieldMeta: {
|
||||||
|
direction: 'vertical',
|
||||||
|
type: 'checkbox',
|
||||||
|
required: true,
|
||||||
|
values: [
|
||||||
|
{ id: 1, checked: false, value: 'Option 1' },
|
||||||
|
{ id: 2, checked: false, value: 'Option 2' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
page: 6,
|
||||||
|
...calculatePosition(2, 0),
|
||||||
|
customText: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.CHECKBOX,
|
||||||
|
fieldMeta: {
|
||||||
|
direction: 'vertical',
|
||||||
|
type: 'checkbox',
|
||||||
|
readOnly: true,
|
||||||
|
values: [
|
||||||
|
{ id: 1, checked: false, value: 'Option 1' },
|
||||||
|
{ id: 2, checked: false, value: 'Option 2' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
page: 6,
|
||||||
|
...calculatePosition(2, 1),
|
||||||
|
customText: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.CHECKBOX,
|
||||||
|
fieldMeta: {
|
||||||
|
direction: 'vertical',
|
||||||
|
type: 'checkbox',
|
||||||
|
validationRule: CheckboxValidationRules.SELECT_AT_LEAST,
|
||||||
|
validationLength: 2,
|
||||||
|
values: [
|
||||||
|
{ id: 1, checked: false, value: 'Option 1' },
|
||||||
|
{ id: 2, checked: false, value: 'Option 2' },
|
||||||
|
{ id: 3, checked: false, value: 'Option 3' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
page: 6,
|
||||||
|
...calculatePosition(3, 0),
|
||||||
|
customText: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.CHECKBOX,
|
||||||
|
fieldMeta: {
|
||||||
|
direction: 'vertical',
|
||||||
|
type: 'checkbox',
|
||||||
|
validationRule: CheckboxValidationRules.SELECT_EXACTLY,
|
||||||
|
validationLength: 2,
|
||||||
|
values: [
|
||||||
|
{ id: 1, checked: false, value: 'Option 1' },
|
||||||
|
{ id: 2, checked: false, value: 'Option 2' },
|
||||||
|
{ id: 3, checked: false, value: 'Option 3' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
page: 6,
|
||||||
|
...calculatePosition(3, 1),
|
||||||
|
customText: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.CHECKBOX,
|
||||||
|
fieldMeta: {
|
||||||
|
direction: 'vertical',
|
||||||
|
type: 'checkbox',
|
||||||
|
validationRule: CheckboxValidationRules.SELECT_AT_MOST,
|
||||||
|
validationLength: 2,
|
||||||
|
values: [
|
||||||
|
{ id: 1, checked: false, value: 'Option 1' },
|
||||||
|
{ id: 2, checked: false, value: 'Option 2' },
|
||||||
|
{ id: 3, checked: false, value: 'Option 3' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
page: 6,
|
||||||
|
...calculatePosition(3, 2),
|
||||||
|
customText: '',
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PAGE 7 DROPDOWN
|
||||||
|
*/
|
||||||
|
{
|
||||||
|
type: FieldType.DROPDOWN,
|
||||||
|
fieldMeta: {
|
||||||
|
values: [{ value: 'Option 1' }, { value: 'Option 2' }],
|
||||||
|
type: 'dropdown',
|
||||||
|
},
|
||||||
|
page: 7,
|
||||||
|
...calculatePosition(0, 0, 'full'),
|
||||||
|
customText: 'Option 1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.DROPDOWN,
|
||||||
|
fieldMeta: {
|
||||||
|
values: [{ value: 'Option 1' }, { value: 'Option 2' }],
|
||||||
|
type: 'dropdown',
|
||||||
|
defaultValue: 'Option 1',
|
||||||
|
},
|
||||||
|
page: 7,
|
||||||
|
...calculatePosition(1, 0),
|
||||||
|
customText: 'Option 1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.DROPDOWN,
|
||||||
|
fieldMeta: {
|
||||||
|
values: [{ value: 'Option 1' }, { value: 'Option 2' }, { value: 'Option 3' }],
|
||||||
|
type: 'dropdown',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
page: 7,
|
||||||
|
...calculatePosition(2, 0),
|
||||||
|
customText: 'Option 1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.DROPDOWN,
|
||||||
|
fieldMeta: {
|
||||||
|
values: [{ value: 'Option 1' }, { value: 'Option 2' }, { value: 'Option 3' }],
|
||||||
|
type: 'dropdown',
|
||||||
|
readOnly: true,
|
||||||
|
},
|
||||||
|
page: 7,
|
||||||
|
...calculatePosition(2, 1),
|
||||||
|
customText: 'Option 1',
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const formatFieldMetaTestFields = FIELD_META_TEST_FIELDS.map((field, index) => {
|
||||||
|
return {
|
||||||
|
...field,
|
||||||
|
};
|
||||||
|
});
|
||||||
@ -68,15 +68,29 @@ test('[ADMIN]: promote member to owner', async ({ page }) => {
|
|||||||
// Test promoting a MEMBER to owner
|
// Test promoting a MEMBER to owner
|
||||||
const memberRow = page.getByRole('row', { name: memberUser.email });
|
const memberRow = page.getByRole('row', { name: memberUser.email });
|
||||||
|
|
||||||
// Find and click the "Promote to owner" button for the member
|
// Find and click the "Update role" button for the member
|
||||||
const promoteButton = memberRow.getByRole('button', { name: 'Promote to owner' });
|
const updateRoleButton = memberRow.getByRole('button', {
|
||||||
await expect(promoteButton).toBeVisible();
|
name: 'Update role',
|
||||||
await expect(promoteButton).not.toBeDisabled();
|
});
|
||||||
|
await expect(updateRoleButton).toBeVisible();
|
||||||
|
await expect(updateRoleButton).not.toBeDisabled();
|
||||||
|
|
||||||
await promoteButton.click();
|
await updateRoleButton.click();
|
||||||
|
|
||||||
// Verify success toast appears
|
// Wait for dialog to open and select Owner role
|
||||||
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible();
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
|
// Find and click the select trigger - it's a button with role="combobox"
|
||||||
|
await page.getByRole('dialog').locator('button[role="combobox"]').click();
|
||||||
|
|
||||||
|
// Select "Owner" from the dropdown options
|
||||||
|
await page.getByRole('option', { name: 'Owner' }).click();
|
||||||
|
|
||||||
|
// Click Update button
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
|
||||||
|
|
||||||
|
// Wait for dialog to close (indicates success)
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
// Reload the page to see the changes
|
// Reload the page to see the changes
|
||||||
await page.reload();
|
await page.reload();
|
||||||
@ -89,12 +103,18 @@ test('[ADMIN]: promote member to owner', async ({ page }) => {
|
|||||||
const previousOwnerRow = page.getByRole('row', { name: ownerUser.email });
|
const previousOwnerRow = page.getByRole('row', { name: ownerUser.email });
|
||||||
await expect(previousOwnerRow.getByRole('status').filter({ hasText: 'Owner' })).not.toBeVisible();
|
await expect(previousOwnerRow.getByRole('status').filter({ hasText: 'Owner' })).not.toBeVisible();
|
||||||
|
|
||||||
// Verify that the promote button is now disabled for the new owner
|
// Verify that the Update role button exists for the new owner and shows Owner as current role
|
||||||
const newOwnerPromoteButton = newOwnerRow.getByRole('button', { name: 'Promote to owner' });
|
const newOwnerUpdateButton = newOwnerRow.getByRole('button', {
|
||||||
await expect(newOwnerPromoteButton).toBeDisabled();
|
name: 'Update role',
|
||||||
|
});
|
||||||
|
await expect(newOwnerUpdateButton).toBeVisible();
|
||||||
|
|
||||||
// Test that we can't promote the current owner (button should be disabled)
|
// Verify clicking it shows the dialog with Owner already selected
|
||||||
await expect(newOwnerPromoteButton).toHaveAttribute('disabled');
|
await newOwnerUpdateButton.click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
|
// Close the dialog without making changes
|
||||||
|
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('[ADMIN]: promote manager to owner', async ({ page }) => {
|
test('[ADMIN]: promote manager to owner', async ({ page }) => {
|
||||||
@ -130,10 +150,26 @@ test('[ADMIN]: promote manager to owner', async ({ page }) => {
|
|||||||
|
|
||||||
// Promote the manager to owner
|
// Promote the manager to owner
|
||||||
const managerRow = page.getByRole('row', { name: managerUser.email });
|
const managerRow = page.getByRole('row', { name: managerUser.email });
|
||||||
const promoteButton = managerRow.getByRole('button', { name: 'Promote to owner' });
|
const updateRoleButton = managerRow.getByRole('button', {
|
||||||
|
name: 'Update role',
|
||||||
|
});
|
||||||
|
|
||||||
await promoteButton.click();
|
await updateRoleButton.click();
|
||||||
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible();
|
|
||||||
|
// Wait for dialog to open and select Owner role
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
|
// Find and click the select trigger - it's a button with role="combobox"
|
||||||
|
await page.getByRole('dialog').locator('button[role="combobox"]').click();
|
||||||
|
|
||||||
|
// Select "Owner" from the dropdown options
|
||||||
|
await page.getByRole('option', { name: 'Owner' }).click();
|
||||||
|
|
||||||
|
// Click Update button
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
|
||||||
|
|
||||||
|
// Wait for dialog to close (indicates success)
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
// Reload and verify the change
|
// Reload and verify the change
|
||||||
await page.reload();
|
await page.reload();
|
||||||
@ -173,14 +209,27 @@ test('[ADMIN]: promote admin member to owner', async ({ page }) => {
|
|||||||
|
|
||||||
// Promote the admin member to owner
|
// Promote the admin member to owner
|
||||||
const adminMemberRow = page.getByRole('row', { name: adminMemberUser.email });
|
const adminMemberRow = page.getByRole('row', { name: adminMemberUser.email });
|
||||||
const promoteButton = adminMemberRow.getByRole('button', { name: 'Promote to owner' });
|
const updateRoleButton = adminMemberRow.getByRole('button', {
|
||||||
|
name: 'Update role',
|
||||||
await promoteButton.click();
|
|
||||||
|
|
||||||
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await updateRoleButton.click();
|
||||||
|
|
||||||
|
// Wait for dialog to open and select Owner role
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
|
// Find and click the select trigger - it's a button with role="combobox"
|
||||||
|
await page.getByRole('dialog').locator('button[role="combobox"]').click();
|
||||||
|
|
||||||
|
// Select "Owner" from the dropdown options
|
||||||
|
await page.getByRole('option', { name: 'Owner' }).click();
|
||||||
|
|
||||||
|
// Click Update button
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
|
||||||
|
|
||||||
|
// Wait for dialog to close (indicates success)
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
// Reload and verify the change
|
// Reload and verify the change
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(adminMemberRow.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
|
await expect(adminMemberRow.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
|
||||||
@ -249,11 +298,25 @@ test('[ADMIN]: verify role hierarchy after promotion', async ({ page }) => {
|
|||||||
await expect(memberRow.getByRole('status').filter({ hasText: 'Owner' })).not.toBeVisible();
|
await expect(memberRow.getByRole('status').filter({ hasText: 'Owner' })).not.toBeVisible();
|
||||||
|
|
||||||
// Promote member to owner
|
// Promote member to owner
|
||||||
const promoteButton = memberRow.getByRole('button', { name: 'Promote to owner' });
|
const updateRoleButton = memberRow.getByRole('button', {
|
||||||
await promoteButton.click();
|
name: 'Update role',
|
||||||
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
});
|
||||||
|
await updateRoleButton.click();
|
||||||
|
|
||||||
|
// Wait for dialog to open and select Owner role
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
|
// Find and click the select trigger - it's a button with role="combobox"
|
||||||
|
await page.getByRole('dialog').locator('button[role="combobox"]').click();
|
||||||
|
|
||||||
|
// Select "Owner" from the dropdown options
|
||||||
|
await page.getByRole('option', { name: 'Owner' }).click();
|
||||||
|
|
||||||
|
// Click Update button
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
|
||||||
|
|
||||||
|
// Wait for dialog to close (indicates success)
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
// Reload page to see updated state
|
// Reload page to see updated state
|
||||||
await page.reload();
|
await page.reload();
|
||||||
@ -262,9 +325,11 @@ test('[ADMIN]: verify role hierarchy after promotion', async ({ page }) => {
|
|||||||
memberRow = page.getByRole('row', { name: memberUser.email });
|
memberRow = page.getByRole('row', { name: memberUser.email });
|
||||||
await expect(memberRow.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
|
await expect(memberRow.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
|
||||||
|
|
||||||
// Verify the promote button is now disabled for the new owner
|
// Verify the Update role button exists and shows Owner as current role
|
||||||
const newOwnerPromoteButton = memberRow.getByRole('button', { name: 'Promote to owner' });
|
const newOwnerUpdateButton = memberRow.getByRole('button', {
|
||||||
await expect(newOwnerPromoteButton).toBeDisabled();
|
name: 'Update role',
|
||||||
|
});
|
||||||
|
await expect(newOwnerUpdateButton).toBeVisible();
|
||||||
|
|
||||||
// Sign in as the newly promoted user to verify they have owner permissions
|
// Sign in as the newly promoted user to verify they have owner permissions
|
||||||
await apiSignin({
|
await apiSignin({
|
||||||
@ -336,28 +401,56 @@ test('[ADMIN]: multiple promotions in sequence', async ({ page }) => {
|
|||||||
|
|
||||||
// First promotion: Member 1 becomes owner
|
// First promotion: Member 1 becomes owner
|
||||||
let member1Row = page.getByRole('row', { name: member1User.email });
|
let member1Row = page.getByRole('row', { name: member1User.email });
|
||||||
let promoteButton1 = member1Row.getByRole('button', { name: 'Promote to owner' });
|
let updateRoleButton1 = member1Row.getByRole('button', {
|
||||||
await promoteButton1.click();
|
name: 'Update role',
|
||||||
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
});
|
||||||
|
await updateRoleButton1.click();
|
||||||
|
|
||||||
|
// Wait for dialog to open and select Owner role
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
|
// Find and click the select trigger - it's a button with role="combobox"
|
||||||
|
await page.getByRole('dialog').locator('button[role="combobox"]').click();
|
||||||
|
|
||||||
|
// Select "Owner" from the dropdown options
|
||||||
|
await page.getByRole('option', { name: 'Owner' }).click();
|
||||||
|
|
||||||
|
// Click Update button
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
|
||||||
|
|
||||||
|
// Wait for dialog to close (indicates success)
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
|
||||||
// Verify Member 1 is now owner and button is disabled
|
// Verify Member 1 is now owner
|
||||||
member1Row = page.getByRole('row', { name: member1User.email });
|
member1Row = page.getByRole('row', { name: member1User.email });
|
||||||
await expect(member1Row.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
|
await expect(member1Row.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
|
||||||
promoteButton1 = member1Row.getByRole('button', { name: 'Promote to owner' });
|
updateRoleButton1 = member1Row.getByRole('button', { name: 'Update role' });
|
||||||
await expect(promoteButton1).toBeDisabled();
|
await expect(updateRoleButton1).toBeVisible();
|
||||||
|
|
||||||
// Second promotion: Member 2 becomes the new owner
|
// Second promotion: Member 2 becomes the new owner
|
||||||
const member2Row = page.getByRole('row', { name: member2User.email });
|
const member2Row = page.getByRole('row', { name: member2User.email });
|
||||||
const promoteButton2 = member2Row.getByRole('button', { name: 'Promote to owner' });
|
const updateRoleButton2 = member2Row.getByRole('button', {
|
||||||
await expect(promoteButton2).not.toBeDisabled();
|
name: 'Update role',
|
||||||
await promoteButton2.click();
|
|
||||||
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
});
|
||||||
|
await expect(updateRoleButton2).toBeVisible();
|
||||||
|
await updateRoleButton2.click();
|
||||||
|
|
||||||
|
// Wait for dialog to open and select Owner role
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
|
// Find and click the select trigger - it's a button with role="combobox"
|
||||||
|
await page.getByRole('dialog').locator('button[role="combobox"]').click();
|
||||||
|
|
||||||
|
// Select "Owner" from the dropdown options
|
||||||
|
await page.getByRole('option', { name: 'Owner' }).click();
|
||||||
|
|
||||||
|
// Click Update button
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
|
||||||
|
|
||||||
|
// Wait for dialog to close (indicates success)
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
|
||||||
@ -365,9 +458,11 @@ test('[ADMIN]: multiple promotions in sequence', async ({ page }) => {
|
|||||||
await expect(member2Row.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
|
await expect(member2Row.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
|
||||||
await expect(member1Row.getByRole('status').filter({ hasText: 'Owner' })).not.toBeVisible();
|
await expect(member1Row.getByRole('status').filter({ hasText: 'Owner' })).not.toBeVisible();
|
||||||
|
|
||||||
// Verify Member 1's promote button is now enabled again
|
// Verify Member 1's Update role button is still visible
|
||||||
const newPromoteButton1 = member1Row.getByRole('button', { name: 'Promote to owner' });
|
const newUpdateButton1 = member1Row.getByRole('button', {
|
||||||
await expect(newPromoteButton1).not.toBeDisabled();
|
name: 'Update role',
|
||||||
|
});
|
||||||
|
await expect(newUpdateButton1).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('[ADMIN]: verify organisation access after ownership change', async ({ page }) => {
|
test('[ADMIN]: verify organisation access after ownership change', async ({ page }) => {
|
||||||
@ -402,11 +497,25 @@ test('[ADMIN]: verify organisation access after ownership change', async ({ page
|
|||||||
});
|
});
|
||||||
|
|
||||||
const memberRow = page.getByRole('row', { name: memberUser.email });
|
const memberRow = page.getByRole('row', { name: memberUser.email });
|
||||||
const promoteButton = memberRow.getByRole('button', { name: 'Promote to owner' });
|
const updateRoleButton = memberRow.getByRole('button', {
|
||||||
await promoteButton.click();
|
name: 'Update role',
|
||||||
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
});
|
||||||
|
await updateRoleButton.click();
|
||||||
|
|
||||||
|
// Wait for dialog to open and select Owner role
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
|
// Find and click the select trigger - it's a button with role="combobox"
|
||||||
|
await page.getByRole('dialog').locator('button[role="combobox"]').click();
|
||||||
|
|
||||||
|
// Select "Owner" from the dropdown options
|
||||||
|
await page.getByRole('option', { name: 'Owner' }).click();
|
||||||
|
|
||||||
|
// Click Update button
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
|
||||||
|
|
||||||
|
// Wait for dialog to close (indicates success)
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
// Test that the new owner can access organisation settings
|
// Test that the new owner can access organisation settings
|
||||||
await apiSignin({
|
await apiSignin({
|
||||||
264
packages/app-tests/e2e/api/v2/envelopes-api.spec.ts
Normal file
264
packages/app-tests/e2e/api/v2/envelopes-api.spec.ts
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import type { Team, User } from '@prisma/client';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
|
import { incrementDocumentId } from '@documenso/lib/server-only/envelope/increment-id';
|
||||||
|
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||||
|
import { prefixedId } from '@documenso/lib/universal/id';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import {
|
||||||
|
DocumentSource,
|
||||||
|
DocumentVisibility,
|
||||||
|
EnvelopeType,
|
||||||
|
RecipientRole,
|
||||||
|
} from '@documenso/prisma/client';
|
||||||
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
import type { TCreateEnvelopeItemsRequest } from '@documenso/trpc/server/envelope-router/create-envelope-items.types';
|
||||||
|
import type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types';
|
||||||
|
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
|
||||||
|
import type { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types';
|
||||||
|
|
||||||
|
import { formatAlignmentTestFields } from '../../../constants/field-alignment-pdf';
|
||||||
|
import { FIELD_META_TEST_FIELDS } from '../../../constants/field-meta-pdf';
|
||||||
|
|
||||||
|
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||||
|
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||||
|
|
||||||
|
test.describe.configure({
|
||||||
|
mode: 'parallel',
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('API V2 Envelopes', () => {
|
||||||
|
let userA: User, teamA: Team, userB: User, teamB: Team, tokenA: string, tokenB: string;
|
||||||
|
|
||||||
|
test.beforeEach(async () => {
|
||||||
|
({ user: userA, team: teamA } = await seedUser());
|
||||||
|
({ token: tokenA } = await createApiToken({
|
||||||
|
userId: userA.id,
|
||||||
|
teamId: teamA.id,
|
||||||
|
tokenName: 'userA',
|
||||||
|
expiresIn: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
({ user: userB, team: teamB } = await seedUser());
|
||||||
|
({ token: tokenB } = await createApiToken({
|
||||||
|
userId: userB.id,
|
||||||
|
teamId: teamB.id,
|
||||||
|
tokenName: 'userB',
|
||||||
|
expiresIn: null,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates envelopes with the two field test PDFs.
|
||||||
|
*/
|
||||||
|
test('Envelope full test', async ({ request }) => {
|
||||||
|
// Step 1: Create initial envelope with Prisma (with first envelope item)
|
||||||
|
const alignmentPdf = fs
|
||||||
|
.readFileSync(path.join(__dirname, '../../../../../assets/field-font-alignment.pdf'))
|
||||||
|
.toString('base64');
|
||||||
|
|
||||||
|
const fieldMetaPdf = fs
|
||||||
|
.readFileSync(path.join(__dirname, '../../../../../assets/field-meta.pdf'))
|
||||||
|
.toString('base64');
|
||||||
|
|
||||||
|
const alignmentDocumentData = await prisma.documentData.create({
|
||||||
|
data: {
|
||||||
|
type: 'BYTES_64',
|
||||||
|
data: alignmentPdf,
|
||||||
|
initialData: alignmentPdf,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const documentId = await incrementDocumentId();
|
||||||
|
const documentMeta = await prisma.documentMeta.create({
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createdEnvelope = await prisma.envelope.create({
|
||||||
|
data: {
|
||||||
|
id: prefixedId('envelope'),
|
||||||
|
secondaryId: documentId.formattedDocumentId,
|
||||||
|
internalVersion: 2,
|
||||||
|
type: EnvelopeType.DOCUMENT,
|
||||||
|
documentMetaId: documentMeta.id,
|
||||||
|
source: DocumentSource.DOCUMENT,
|
||||||
|
title: `Envelope Full Field Test`,
|
||||||
|
status: 'DRAFT',
|
||||||
|
userId: userA.id,
|
||||||
|
teamId: teamA.id,
|
||||||
|
envelopeItems: {
|
||||||
|
create: {
|
||||||
|
id: prefixedId('envelope_item'),
|
||||||
|
title: `Alignment Test`,
|
||||||
|
documentDataId: alignmentDocumentData.id,
|
||||||
|
order: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
envelopeItems: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 2: Create second envelope item via API
|
||||||
|
const fieldMetaDocumentData = await prisma.documentData.create({
|
||||||
|
data: {
|
||||||
|
type: 'BYTES_64',
|
||||||
|
data: fieldMetaPdf,
|
||||||
|
initialData: fieldMetaPdf,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createEnvelopeItemsRequest: TCreateEnvelopeItemsRequest = {
|
||||||
|
envelopeId: createdEnvelope.id,
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
title: 'Field Meta Test',
|
||||||
|
documentDataId: fieldMetaDocumentData.id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const createItemsRes = await request.post(`${baseUrl}/envelope/item/create-many`, {
|
||||||
|
headers: { Authorization: `Bearer ${tokenA}` },
|
||||||
|
data: createEnvelopeItemsRequest,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(createItemsRes.ok()).toBeTruthy();
|
||||||
|
expect(createItemsRes.status()).toBe(200);
|
||||||
|
|
||||||
|
// Step 3: Update envelope via API
|
||||||
|
const updateEnvelopeRequest: TUpdateEnvelopeRequest = {
|
||||||
|
envelopeId: createdEnvelope.id,
|
||||||
|
envelopeType: EnvelopeType.DOCUMENT,
|
||||||
|
data: {
|
||||||
|
title: 'Envelope Full Field Test',
|
||||||
|
visibility: DocumentVisibility.MANAGER_AND_ABOVE,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateRes = await request.post(`${baseUrl}/envelope/update`, {
|
||||||
|
headers: { Authorization: `Bearer ${tokenA}` },
|
||||||
|
data: updateEnvelopeRequest,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updateRes.ok()).toBeTruthy();
|
||||||
|
expect(updateRes.status()).toBe(200);
|
||||||
|
|
||||||
|
// Step 4: Create recipient via API
|
||||||
|
const createRecipientsRequest: TCreateEnvelopeRecipientsRequest = {
|
||||||
|
envelopeId: createdEnvelope.id,
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
email: userA.email,
|
||||||
|
name: userA.name || '',
|
||||||
|
role: RecipientRole.SIGNER,
|
||||||
|
accessAuth: [],
|
||||||
|
actionAuth: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const createRecipientsRes = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
|
||||||
|
headers: { Authorization: `Bearer ${tokenA}` },
|
||||||
|
data: createRecipientsRequest,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(createRecipientsRes.ok()).toBeTruthy();
|
||||||
|
expect(createRecipientsRes.status()).toBe(200);
|
||||||
|
|
||||||
|
// Step 5: Get envelope to retrieve recipients and envelope items
|
||||||
|
const getRes = await request.get(`${baseUrl}/envelope/${createdEnvelope.id}`, {
|
||||||
|
headers: { Authorization: `Bearer ${tokenA}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getRes.ok()).toBeTruthy();
|
||||||
|
expect(getRes.status()).toBe(200);
|
||||||
|
|
||||||
|
const envelopeResponse = (await getRes.json()) as TGetEnvelopeResponse;
|
||||||
|
|
||||||
|
const recipientId = envelopeResponse.recipients[0].id;
|
||||||
|
const alignmentItem = envelopeResponse.envelopeItems.find(
|
||||||
|
(item: { order: number }) => item.order === 1,
|
||||||
|
);
|
||||||
|
const fieldMetaItem = envelopeResponse.envelopeItems.find(
|
||||||
|
(item: { order: number }) => item.order === 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(recipientId).toBeDefined();
|
||||||
|
expect(alignmentItem).toBeDefined();
|
||||||
|
expect(fieldMetaItem).toBeDefined();
|
||||||
|
|
||||||
|
if (!alignmentItem || !fieldMetaItem) {
|
||||||
|
throw new Error('Envelope items not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 6: Create fields for first PDF (alignment fields)
|
||||||
|
const alignmentFieldsRequest = {
|
||||||
|
envelopeId: createdEnvelope.id,
|
||||||
|
data: formatAlignmentTestFields.map((field) => ({
|
||||||
|
recipientId,
|
||||||
|
envelopeItemId: alignmentItem.id,
|
||||||
|
type: field.type,
|
||||||
|
page: field.page,
|
||||||
|
positionX: field.positionX,
|
||||||
|
positionY: field.positionY,
|
||||||
|
width: field.width,
|
||||||
|
height: field.height,
|
||||||
|
fieldMeta: field.fieldMeta,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const createAlignmentFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
|
||||||
|
headers: { Authorization: `Bearer ${tokenA}` },
|
||||||
|
data: alignmentFieldsRequest,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(createAlignmentFieldsRes.ok()).toBeTruthy();
|
||||||
|
expect(createAlignmentFieldsRes.status()).toBe(200);
|
||||||
|
|
||||||
|
// Step 7: Create fields for second PDF (field-meta fields)
|
||||||
|
const fieldMetaFieldsRequest = {
|
||||||
|
envelopeId: createdEnvelope.id,
|
||||||
|
data: FIELD_META_TEST_FIELDS.map((field) => ({
|
||||||
|
recipientId,
|
||||||
|
envelopeItemId: fieldMetaItem.id,
|
||||||
|
type: field.type,
|
||||||
|
page: field.page,
|
||||||
|
positionX: field.positionX,
|
||||||
|
positionY: field.positionY,
|
||||||
|
width: field.width,
|
||||||
|
height: field.height,
|
||||||
|
fieldMeta: field.fieldMeta,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const createFieldMetaFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
|
||||||
|
headers: { Authorization: `Bearer ${tokenA}` },
|
||||||
|
data: fieldMetaFieldsRequest,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(createFieldMetaFieldsRes.ok()).toBeTruthy();
|
||||||
|
expect(createFieldMetaFieldsRes.status()).toBe(200);
|
||||||
|
|
||||||
|
// Step 8: Verify final envelope structure
|
||||||
|
const finalGetRes = await request.get(`${baseUrl}/envelope/${createdEnvelope.id}`, {
|
||||||
|
headers: { Authorization: `Bearer ${tokenA}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(finalGetRes.ok()).toBeTruthy();
|
||||||
|
const finalEnvelope = (await finalGetRes.json()) as TGetEnvelopeResponse;
|
||||||
|
|
||||||
|
// Verify structure
|
||||||
|
expect(finalEnvelope.envelopeItems.length).toBe(2);
|
||||||
|
expect(finalEnvelope.recipients.length).toBe(1);
|
||||||
|
expect(finalEnvelope.fields.length).toBe(
|
||||||
|
formatAlignmentTestFields.length + FIELD_META_TEST_FIELDS.length,
|
||||||
|
);
|
||||||
|
expect(finalEnvelope.title).toBe('Envelope Full Field Test');
|
||||||
|
expect(finalEnvelope.type).toBe(EnvelopeType.DOCUMENT);
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user