mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 20:42:34 +10:00
Compare commits
70 Commits
fix/duplic
...
v2.0.5
| Author | SHA1 | Date | |
|---|---|---|---|
| 0977c16e33 | |||
| 88d5a636c3 | |||
| 1e6292b1d9 | |||
| d65866156d | |||
| fe8915162f | |||
| 37a2634aca | |||
| eff7d90f43 | |||
| db5524f8ce | |||
| 3d539b20ad | |||
| 48626b9169 | |||
| 88371b665a | |||
| 1650c55b19 | |||
| 60d73e0921 | |||
| 4a779ec81e | |||
| 7f19ec1265 | |||
| d6a2f5a4c9 | |||
| d05bfa9fed | |||
| d2a009d52e | |||
| 9350c53c7d | |||
| ffce7a2c81 | |||
| 353bdce86b | |||
| e13b9f7c84 | |||
| 9908580bf1 | |||
| b0b07106b4 | |||
| 35250fa308 | |||
| 5cdd7f8623 | |||
| 47bdcd833f | |||
| 03eb6af69a | |||
| 88836404d1 | |||
| 2eebc0e439 | |||
| 4a3859ec60 | |||
| 49b792503f | |||
| c3dc76b1b4 | |||
| daab8461c7 | |||
| 1ffc4bd703 | |||
| f15c0778b5 | |||
| 06cb8b1f23 | |||
| 7f09ba72f4 | |||
| 7b17156e56 | |||
| 86e89e137e | |||
| 26f65dbdd7 | |||
| a902bec96d | |||
| 399f91de73 | |||
| 995bc9c362 | |||
| 3467317271 | |||
| a5eaa8ad47 | |||
| 577691214b | |||
| c7d21c6587 | |||
| 2aa391f917 | |||
| 681540b501 | |||
| f3305ac306 | |||
| 68b4305b6a | |||
| 3de1ea0a02 | |||
| b8fc47b719 | |||
| cfceebd78f | |||
| b9b3ddfb98 | |||
| 8590502338 | |||
| 53f29daf50 | |||
| 197d17ed7b | |||
| 3c646d9475 | |||
| ed4dfc9b55 | |||
| 32ce573de4 | |||
| 2ecfdbdde5 | |||
| a3005f8616 | |||
| 2c0d4f8789 | |||
| 7c8e93b53e | |||
| 93a3809f6a | |||
| 4550bca3d3 | |||
| 9ac7b94d9a | |||
| 374f2c45b4 |
@ -13,6 +13,10 @@ NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="DEADBEEF"
|
|||||||
# https://docs.documenso.com/developers/self-hosting/setting-up-oauth-providers#google-oauth-gmail
|
# https://docs.documenso.com/developers/self-hosting/setting-up-oauth-providers#google-oauth-gmail
|
||||||
NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
|
NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
|
||||||
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""
|
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""
|
||||||
|
# Find documentation on setting up Microsoft OAuth here:
|
||||||
|
# https://docs.documenso.com/developers/self-hosting/setting-up-oauth-providers#microsoft-oauth-azure-ad
|
||||||
|
NEXT_PRIVATE_MICROSOFT_CLIENT_ID=""
|
||||||
|
NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET=""
|
||||||
|
|
||||||
NEXT_PRIVATE_OIDC_WELL_KNOWN=""
|
NEXT_PRIVATE_OIDC_WELL_KNOWN=""
|
||||||
NEXT_PRIVATE_OIDC_CLIENT_ID=""
|
NEXT_PRIVATE_OIDC_CLIENT_ID=""
|
||||||
@ -25,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.
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -57,3 +57,6 @@ logs.json
|
|||||||
# claude
|
# claude
|
||||||
.claude
|
.claude
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
|
||||||
|
# agents
|
||||||
|
.specs
|
||||||
|
|||||||
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
|
||||||
19
SIGNING.md
19
SIGNING.md
@ -10,13 +10,24 @@ For the digital signature of your documents you need a signing certificate in .p
|
|||||||
|
|
||||||
`openssl req -new -x509 -key private.key -out certificate.crt -days 365`
|
`openssl req -new -x509 -key private.key -out certificate.crt -days 365`
|
||||||
|
|
||||||
This will prompt you to enter some information, such as the Common Name (CN) for the certificate. Make sure you enter the correct information. The -days parameter sets the number of days for which the certificate is valid.
|
This will prompt you to enter some information, such as the Common Name (CN) for the certificate. Make sure you enter the correct information. The `-days` parameter sets the number of days for which the certificate is valid.
|
||||||
|
|
||||||
3. Combine the private key and the self-signed certificate to create the p12 certificate. You can run the following command to do this:
|
3. Combine the private key and the self-signed certificate to create the p12 certificate. You can run the following commands to do this:
|
||||||
|
|
||||||
`openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt`
|
```bash
|
||||||
|
# Set certificate password securely (won't appear in command history)
|
||||||
|
read -s -p "Enter certificate password: " CERT_PASS
|
||||||
|
echo
|
||||||
|
|
||||||
4. You will be prompted to enter a password for the p12 file. Choose a strong password and remember it, as you will need it to use the certificate (**can be empty for dev certificates**)
|
# Create the p12 certificate using the environment variable
|
||||||
|
openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt \
|
||||||
|
-password env:CERT_PASS \
|
||||||
|
-keypbe PBE-SHA1-3DES \
|
||||||
|
-certpbe PBE-SHA1-3DES \
|
||||||
|
-macalg sha1
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **IMPORTANT**: A certificate password is required to prevent signing failures. Make sure to use a strong password (minimum 4 characters) when prompted. Certificates without passwords will cause "Failed to get private key bags" errors during document signing.
|
||||||
|
|
||||||
5. Place the certificate `/apps/remix/resources/certificate.p12` (If the path does not exist, it needs to be created)
|
5. Place the certificate `/apps/remix/resources/certificate.p12` (If the path does not exist, it needs to be created)
|
||||||
|
|
||||||
|
|||||||
@ -119,16 +119,89 @@ NEXT_PRIVATE_SMTP_USERNAME="<your-username>"
|
|||||||
NEXT_PRIVATE_SMTP_PASSWORD="<your-password>"
|
NEXT_PRIVATE_SMTP_PASSWORD="<your-password>"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Update the Volume Binding
|
### Set Up Your Signing Certificate
|
||||||
|
|
||||||
The `cert.p12` file is required to sign and encrypt documents, so you must provide your key file. Update the volume binding in the `compose.yml` file to point to your key file:
|
<Callout type="warning">
|
||||||
|
This is the most common source of issues for self-hosters. Please follow these steps carefully.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
```yaml
|
The `cert.p12` file is required to sign and encrypt documents. You have three options:
|
||||||
volumes:
|
|
||||||
- /path/to/your/keyfile.p12:/opt/documenso/cert.p12
|
|
||||||
```
|
|
||||||
|
|
||||||
After updating the volume binding, save the `compose.yml` file and run the following command to start the containers:
|
#### Option A: Generate Certificate Inside Container (Recommended)
|
||||||
|
|
||||||
|
This method avoids file permission issues by creating the certificate directly inside the Docker container:
|
||||||
|
|
||||||
|
1. Start your containers:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Set certificate password securely and generate certificate inside the container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set certificate password securely (won't appear in command history)
|
||||||
|
read -s -p "Enter certificate password: " CERT_PASS
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Generate certificate inside container using environment variable
|
||||||
|
docker exec -e CERT_PASS="$CERT_PASS" -it documenso-production-documenso-1 bash -c "
|
||||||
|
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
||||||
|
-keyout /tmp/private.key \
|
||||||
|
-out /tmp/certificate.crt \
|
||||||
|
-subj '/C=US/ST=State/L=City/O=Organization/CN=localhost' && \
|
||||||
|
openssl pkcs12 -export -out /app/certs/cert.p12 \
|
||||||
|
-inkey /tmp/private.key -in /tmp/certificate.crt \
|
||||||
|
-passout env:CERT_PASS && \
|
||||||
|
rm /tmp/private.key /tmp/certificate.crt
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Add the certificate passphrase to your `.env` file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NEXT_PRIVATE_SIGNING_PASSPHRASE="your_password_here"
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Restart the container to apply changes:
|
||||||
|
```bash
|
||||||
|
docker-compose restart documenso
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Option B: Use an Existing Certificate File
|
||||||
|
|
||||||
|
If you have an existing `.p12` certificate file:
|
||||||
|
|
||||||
|
1. **Place your certificate file** in an accessible location on your host system
|
||||||
|
2. **Set proper permissions:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Make sure the certificate is readable
|
||||||
|
chmod 644 /path/to/your/cert.p12
|
||||||
|
|
||||||
|
# For Docker, ensure proper ownership
|
||||||
|
chown 1001:1001 /path/to/your/cert.p12
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Update the volume binding** in the `compose.yml` file:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- /path/to/your/cert.p12:/opt/documenso/cert.p12:ro
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Add certificate configuration** to your `.env` file:
|
||||||
|
```bash
|
||||||
|
NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=/opt/documenso/cert.p12
|
||||||
|
NEXT_PRIVATE_SIGNING_PASSPHRASE=your_certificate_password
|
||||||
|
```
|
||||||
|
|
||||||
|
<Callout type="warning">
|
||||||
|
Your certificate MUST have a password. Certificates without passwords will cause "Failed to get
|
||||||
|
private key bags" errors.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
After setting up your certificate, save the `compose.yml` file and run the following command to start the containers:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose --env-file ./.env up -d
|
docker-compose --env-file ./.env up -d
|
||||||
|
|||||||
@ -27,3 +27,33 @@ NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=<your-client-secret>
|
|||||||
```
|
```
|
||||||
|
|
||||||
Finally verify the signing in with Google works by signing in with your Google account and checking the email address in your profile.
|
Finally verify the signing in with Google works by signing in with your Google account and checking the email address in your profile.
|
||||||
|
|
||||||
|
## Microsoft OAuth (Azure AD)
|
||||||
|
|
||||||
|
To use Microsoft OAuth, you will need to create an Azure AD application registration in the Microsoft Azure portal. This will allow users to sign in with their Microsoft accounts.
|
||||||
|
|
||||||
|
### Create and configure a new Azure AD application
|
||||||
|
|
||||||
|
1. Go to the [Azure Portal](https://portal.azure.com/)
|
||||||
|
2. Navigate to **Azure Active Directory** (or **Microsoft Entra ID** in newer Azure portals)
|
||||||
|
3. In the left sidebar, click **App registrations**
|
||||||
|
4. Click **New registration**
|
||||||
|
5. Enter a name for your application (e.g., "Documenso")
|
||||||
|
6. Under **Supported account types**, select **Accounts in any organizational directory (Any Azure AD directory - Multitenant) and personal Microsoft accounts (e.g. Skype, Xbox)** to allow any Microsoft account to sign in
|
||||||
|
7. Under **Redirect URI**, select **Web** and enter: `https://<documenso-domain>/api/auth/callback/microsoft`
|
||||||
|
8. Click **Register**
|
||||||
|
|
||||||
|
### Configure the application
|
||||||
|
|
||||||
|
1. After registration, you'll be taken to the app's overview page
|
||||||
|
2. Copy the **Application (client) ID** - this will be your `NEXT_PRIVATE_MICROSOFT_CLIENT_ID`
|
||||||
|
3. In the left sidebar, click **Certificates & secrets**
|
||||||
|
4. Under **Client secrets**, click **New client secret**
|
||||||
|
5. Add a description and select an expiration period
|
||||||
|
6. Click **Add** and copy the **Value** (not the Secret ID) - this will be your `NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET`
|
||||||
|
7. In the Documenso environment variables, set the following:
|
||||||
|
|
||||||
|
```
|
||||||
|
NEXT_PRIVATE_MICROSOFT_CLIENT_ID=<your-application-client-id>
|
||||||
|
NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET=<your-client-secret-value>
|
||||||
|
```
|
||||||
|
|||||||
@ -19,13 +19,13 @@ device, and other FDA-regulated industries.
|
|||||||
- [x] User Access Management
|
- [x] User Access Management
|
||||||
- [x] Quality Assurance Documentation
|
- [x] Quality Assurance Documentation
|
||||||
|
|
||||||
## SOC/ SOC II
|
## SOC 2
|
||||||
|
|
||||||
<Callout type="warning" emoji="⏳">
|
<Callout type="info" emoji="✅">
|
||||||
Status: [Planned](https://github.com/documenso/backlog/issues/24)
|
Status: [Compliant](https://documen.so/trust)
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
SOC II is a framework for managing and auditing the security, availability, processing integrity, confidentiality,
|
SOC 2 is a framework for managing and auditing the security, availability, processing integrity, confidentiality,
|
||||||
and data privacy in cloud and IT service organizations, established by the American Institute of Certified
|
and data privacy in cloud and IT service organizations, established by the American Institute of Certified
|
||||||
Public Accountants (AICPA).
|
Public Accountants (AICPA).
|
||||||
|
|
||||||
@ -34,9 +34,9 @@ Public Accountants (AICPA).
|
|||||||
<Callout type="warning" emoji="⏳">
|
<Callout type="warning" emoji="⏳">
|
||||||
Status: [Planned](https://github.com/documenso/backlog/issues/26)
|
Status: [Planned](https://github.com/documenso/backlog/issues/26)
|
||||||
</Callout>
|
</Callout>
|
||||||
ISO 27001 is an international standard for managing information security, specifying requirements for
|
ISO 27001 is an international standard for managing information security, specifying requirements
|
||||||
establishing, implementing, maintaining, and continually improving an information security management
|
for establishing, implementing, maintaining, and continually improving an information security
|
||||||
system (ISMS).
|
management system (ISMS).
|
||||||
|
|
||||||
### HIPAA
|
### HIPAA
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,11 @@ The guide assumes you have a Documenso account. If you don't, you can create a f
|
|||||||
|
|
||||||
Navigate to the [Documenso dashboard](https://app.documenso.com/documents) and click on the "Add a document" button. Select the document you want to upload and wait for the upload to complete.
|
Navigate to the [Documenso dashboard](https://app.documenso.com/documents) and click on the "Add a document" button. Select the document you want to upload and wait for the upload to complete.
|
||||||
|
|
||||||
|
<Callout type="info">
|
||||||
|
The maximum file size for uploaded documents is 150MB in production. In staging, the limit is
|
||||||
|
50MB.
|
||||||
|
</Callout>
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
After the upload is complete, you will be redirected to the document's page. You can configure the document's settings and add recipients and fields here.
|
After the upload is complete, you will be redirected to the document's page. You can configure the document's settings and add recipients and fields here.
|
||||||
|
|||||||
@ -3,5 +3,6 @@
|
|||||||
"members": "Members",
|
"members": "Members",
|
||||||
"groups": "Groups",
|
"groups": "Groups",
|
||||||
"teams": "Teams",
|
"teams": "Teams",
|
||||||
|
"sso": "SSO",
|
||||||
"billing": "Billing"
|
"billing": "Billing"
|
||||||
}
|
}
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"index": "Configuration",
|
||||||
|
"microsoft-entra-id": "Microsoft Entra ID"
|
||||||
|
}
|
||||||
149
apps/documentation/pages/users/organisations/sso/index.mdx
Normal file
149
apps/documentation/pages/users/organisations/sso/index.mdx
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
---
|
||||||
|
title: SSO Portal
|
||||||
|
description: Learn how to set up a custom SSO login portal for your organisation.
|
||||||
|
---
|
||||||
|
|
||||||
|
import Image from 'next/image';
|
||||||
|
|
||||||
|
import { Callout, Steps } from 'nextra/components';
|
||||||
|
|
||||||
|
# Organisation SSO Portal
|
||||||
|
|
||||||
|
The SSO Portal provides a dedicated login URL for your organisation that integrates with any OIDC compliant identity provider. This feature provides:
|
||||||
|
|
||||||
|
- **Single Sign-On**: Access Documenso using your own authentication system
|
||||||
|
- **Automatic onboarding**: New users will be automatically added to your organisation when they sign in through the portal
|
||||||
|
- **Delegated account management**: Your organisation has full control over the users who sign in through the portal
|
||||||
|
|
||||||
|
<Callout type="warning">
|
||||||
|
Anyone who signs in through your portal will be added to your organisation as a member.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
To set up the SSO Portal, you need to be an organisation owner, admin, or manager.
|
||||||
|
|
||||||
|
<Callout type="info">
|
||||||
|
**Enterprise Only**: This feature is only available to Enterprise customers.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
|
||||||
|
### Access Organisation SSO Settings
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Configure SSO Portal
|
||||||
|
|
||||||
|
See the [Microsoft Entra ID](/users/organisations/sso/microsoft-entra-id) guide to find the values for the following fields.
|
||||||
|
|
||||||
|
#### Issuer URL
|
||||||
|
|
||||||
|
Enter the OpenID discovery endpoint URL for your provider. Here are some common examples:
|
||||||
|
|
||||||
|
- **Google Workspace**: `https://accounts.google.com/.well-known/openid-configuration`
|
||||||
|
- **Microsoft Entra ID**: `https://login.microsoftonline.com/{tenant-id}/v2.0/.well-known/openid-configuration`
|
||||||
|
- **Okta**: `https://{your-domain}.okta.com/.well-known/openid-configuration`
|
||||||
|
- **Auth0**: `https://{your-domain}.auth0.com/.well-known/openid-configuration`
|
||||||
|
|
||||||
|
#### Client Credentials
|
||||||
|
|
||||||
|
Enter the client ID and client secret provided by your identity provider:
|
||||||
|
|
||||||
|
- **Client ID**: The unique identifier for your application
|
||||||
|
- **Client Secret**: The secret key for authenticating your application
|
||||||
|
|
||||||
|
#### Default Organisation Role
|
||||||
|
|
||||||
|
Select the default Organisation role that new users will receive when they first sign in through the portal.
|
||||||
|
|
||||||
|
#### Allowed Email Domains
|
||||||
|
|
||||||
|
Specify which email domains are allowed to sign in through your SSO portal. Separate domains with spaces:
|
||||||
|
|
||||||
|
```
|
||||||
|
your-domain.com another-domain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Leave this field empty to allow all domains.
|
||||||
|
|
||||||
|
### Configure Your Identity Provider
|
||||||
|
|
||||||
|
You'll need to configure your identity provider with the following information:
|
||||||
|
|
||||||
|
- Redirect URI
|
||||||
|
- Scopes
|
||||||
|
|
||||||
|
These values are found at the top of the page.
|
||||||
|
|
||||||
|
### Save Configuration
|
||||||
|
|
||||||
|
Toggle the "Enable SSO portal" switch to activate the feature for your organisation.
|
||||||
|
|
||||||
|
Click "Update" to save your SSO portal configuration. The portal will be activated once all required fields are completed.
|
||||||
|
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
## Testing Your SSO Portal
|
||||||
|
|
||||||
|
Once configured, you can test your SSO portal by:
|
||||||
|
|
||||||
|
1. Navigating to your portal URL found at the top of the organisation SSO portal settings page
|
||||||
|
2. Sign in with a test account from your configured domain
|
||||||
|
3. Verifying that the user is properly provisioned with the correct organisation role
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### Reduce Friction
|
||||||
|
|
||||||
|
Create a custom subdomain for your organisation's SSO portal. For example, you can create a subdomain like `documenso.your-organisation.com` which redirects to the portal link.
|
||||||
|
|
||||||
|
### Security Considerations
|
||||||
|
|
||||||
|
Please note that anyone who signs in through your portal will be added to your organisation as a member.
|
||||||
|
|
||||||
|
- **Domain Restrictions**: Use allowed domains to prevent unauthorized access
|
||||||
|
- **Role Assignment**: Carefully consider the default organisation role for new users
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
**"Invalid issuer URL"**
|
||||||
|
|
||||||
|
- Verify the issuer URL is correct and accessible
|
||||||
|
- Ensure the URL follows the OpenID Connect discovery format
|
||||||
|
|
||||||
|
**"Client authentication failed"**
|
||||||
|
|
||||||
|
- Check that your client ID and client secret are correct
|
||||||
|
- Verify that your application is properly registered with your identity provider
|
||||||
|
|
||||||
|
**"User not provisioned"**
|
||||||
|
|
||||||
|
- Check that the user's email domain is in the allowed domains list
|
||||||
|
- Verify the default organisation role is set correctly
|
||||||
|
|
||||||
|
**"Redirect URI mismatch"**
|
||||||
|
|
||||||
|
- Ensure the redirect URI in Documenso matches exactly what's configured in your identity provider
|
||||||
|
- Check for any trailing slashes or protocol mismatches
|
||||||
|
|
||||||
|
### Getting Help
|
||||||
|
|
||||||
|
If you encounter issues with your SSO portal configuration:
|
||||||
|
|
||||||
|
1. Review your identity provider's documentation for OpenID Connect setup
|
||||||
|
2. Check the Documenso logs for detailed error messages
|
||||||
|
3. Contact your identity provider's support for provider-specific issues
|
||||||
|
|
||||||
|
<Callout type="info">
|
||||||
|
For additional support for SSO Portal configuration, contact our support team at
|
||||||
|
support@documenso.com.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
## Identity Provider Guides
|
||||||
|
|
||||||
|
For detailed setup instructions for specific identity providers:
|
||||||
|
|
||||||
|
- [Microsoft Entra ID](/users/organisations/sso/microsoft-entra-id) - Complete guide for Azure AD configuration
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
---
|
||||||
|
title: Microsoft Entra ID
|
||||||
|
description: Learn how to configure Microsoft Entra ID (Azure AD) for your organisation's SSO portal.
|
||||||
|
---
|
||||||
|
|
||||||
|
import Image from 'next/image';
|
||||||
|
|
||||||
|
import { Callout, Steps } from 'nextra/components';
|
||||||
|
|
||||||
|
# Microsoft Entra ID Configuration
|
||||||
|
|
||||||
|
Microsoft Entra ID (formerly Azure Active Directory) is a popular identity provider for enterprise SSO. This guide will walk you through creating an app registration and configuring it for use with your Documenso SSO portal.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Access to Microsoft Entra ID (Azure AD) admin center
|
||||||
|
- Access to your Documenso organisation as an administrator or manager
|
||||||
|
|
||||||
|
<Callout type="warning">Each user in your Azure AD will need an email associated with it.</Callout>
|
||||||
|
|
||||||
|
## Creating an App Registration
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
|
||||||
|
### Access Azure Portal
|
||||||
|
|
||||||
|
1. Navigate to the Azure Portal
|
||||||
|
2. Sign in with your Microsoft Entra ID administrator account
|
||||||
|
3. Search for "Azure Active Directory" or "Microsoft Entra ID" in the search bar
|
||||||
|
4. Click on "Microsoft Entra ID" from the results
|
||||||
|
|
||||||
|
### Create App Registration
|
||||||
|
|
||||||
|
1. In the left sidebar, click on "App registrations"
|
||||||
|
2. Click the "New registration" button
|
||||||
|
|
||||||
|
### Configure App Registration
|
||||||
|
|
||||||
|
Fill in the registration form with the following details:
|
||||||
|
|
||||||
|
- **Name**: Your preferred name (e.g. `Documenso SSO Portal`)
|
||||||
|
- **Supported account types**: Choose based on your needs
|
||||||
|
- **Redirect URI (Web)**: Found in the Documenso SSO portal settings page
|
||||||
|
|
||||||
|
Click "Register" to create the app registration.
|
||||||
|
|
||||||
|
### Get Client ID
|
||||||
|
|
||||||
|
After registration, you'll be taken to the app's overview page. The **Application (client) ID** is displayed prominently - this is your Client ID for Documenso.
|
||||||
|
|
||||||
|
### Create Client Secret
|
||||||
|
|
||||||
|
1. In the left sidebar, click on "Certificates & secrets"
|
||||||
|
2. Click "New client secret"
|
||||||
|
3. Add a description (e.g., "Documenso SSO Secret")
|
||||||
|
4. Choose an expiration period (recommended 12-24 months)
|
||||||
|
5. Click "Add"
|
||||||
|
|
||||||
|
Make sure you copy the "Secret value", not the "Secret ID", you won't be able to access it again after you leave the page.
|
||||||
|
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
## Getting Your OpenID Configuration URL
|
||||||
|
|
||||||
|
1. In the Azure portal, go to "Microsoft Entra ID"
|
||||||
|
2. Click on "Overview" in the left sidebar
|
||||||
|
3. Click the "Endpoints" in the horizontal tab
|
||||||
|
4. Copy the "OpenID Connect metadata document" value
|
||||||
|
|
||||||
|
## Configure Documenso SSO Portal
|
||||||
|
|
||||||
|
Now you have all the information needed to configure your Documenso SSO portal:
|
||||||
|
|
||||||
|
- **Issuer URL**: The "OpenID Connect metadata document" value from the previous step
|
||||||
|
- **Client ID**: The Application (client) ID from your app registration
|
||||||
|
- **Client Secret**: The secret value you copied during creation
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 175 KiB |
@ -1,4 +1,4 @@
|
|||||||
import { DocumentStatus } from '@prisma/client';
|
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
import { kyselyPrisma, sql } from '@documenso/prisma';
|
import { kyselyPrisma, sql } from '@documenso/prisma';
|
||||||
@ -7,18 +7,19 @@ import { addZeroMonth } from '../add-zero-month';
|
|||||||
|
|
||||||
export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative' = 'count') => {
|
export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative' = 'count') => {
|
||||||
const qb = kyselyPrisma.$kysely
|
const qb = kyselyPrisma.$kysely
|
||||||
.selectFrom('Document')
|
.selectFrom('Envelope')
|
||||||
.select(({ fn }) => [
|
.select(({ fn }) => [
|
||||||
fn<Date>('DATE_TRUNC', [sql.lit('MONTH'), 'Document.updatedAt']).as('month'),
|
fn<Date>('DATE_TRUNC', [sql.lit('MONTH'), 'Envelope.updatedAt']).as('month'),
|
||||||
fn.count('id').as('count'),
|
fn.count('id').as('count'),
|
||||||
fn
|
fn
|
||||||
.sum(fn.count('id'))
|
.sum(fn.count('id'))
|
||||||
// Feels like a bug in the Kysely extension but I just can not do this orderBy in a type-safe manner
|
// Feels like a bug in the Kysely extension but I just can not do this orderBy in a type-safe manner
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any
|
||||||
.over((ob) => ob.orderBy(fn('DATE_TRUNC', [sql.lit('MONTH'), 'Document.updatedAt']) as any))
|
.over((ob) => ob.orderBy(fn('DATE_TRUNC', [sql.lit('MONTH'), 'Envelope.updatedAt']) as any))
|
||||||
.as('cume_count'),
|
.as('cume_count'),
|
||||||
])
|
])
|
||||||
.where(() => sql`"Document"."status" = ${DocumentStatus.COMPLETED}::"DocumentStatus"`)
|
.where(() => sql`"Envelope"."status" = ${DocumentStatus.COMPLETED}::"DocumentStatus"`)
|
||||||
|
.where(() => sql`"Envelope"."type" = ${EnvelopeType.DOCUMENT}::"EnvelopeType"`)
|
||||||
.groupBy('month')
|
.groupBy('month')
|
||||||
.orderBy('month', 'desc')
|
.orderBy('month', 'desc')
|
||||||
.limit(12);
|
.limit(12);
|
||||||
|
|||||||
@ -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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import { useState } from 'react';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { Document } from '@prisma/client';
|
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@ -22,10 +21,10 @@ import { Input } from '@documenso/ui/primitives/input';
|
|||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
export type AdminDocumentDeleteDialogProps = {
|
export type AdminDocumentDeleteDialogProps = {
|
||||||
document: Document;
|
envelopeId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AdminDocumentDeleteDialog = ({ document }: AdminDocumentDeleteDialogProps) => {
|
export const AdminDocumentDeleteDialog = ({ envelopeId }: AdminDocumentDeleteDialogProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@ -42,7 +41,7 @@ export const AdminDocumentDeleteDialog = ({ document }: AdminDocumentDeleteDialo
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await deleteDocument({ id: document.id, reason });
|
await deleteDocument({ id: envelopeId, reason });
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Document deleted`),
|
title: _(msg`Document deleted`),
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -19,13 +19,15 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
type DocumentDuplicateDialogProps = {
|
type DocumentDuplicateDialogProps = {
|
||||||
id: number;
|
id: string;
|
||||||
|
token?: string;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (_open: boolean) => void;
|
onOpenChange: (_open: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentDuplicateDialog = ({
|
export const DocumentDuplicateDialog = ({
|
||||||
id,
|
id,
|
||||||
|
token,
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: DocumentDuplicateDialogProps) => {
|
}: DocumentDuplicateDialogProps) => {
|
||||||
@ -36,42 +38,38 @@ export const DocumentDuplicateDialog = ({
|
|||||||
|
|
||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
const { data: document, isLoading } = trpcReact.document.get.useQuery(
|
const { data: envelopeItemsPayload, isLoading: isLoadingEnvelopeItems } =
|
||||||
|
trpcReact.envelope.item.getManyByToken.useQuery(
|
||||||
{
|
{
|
||||||
documentId: id,
|
envelopeId: id,
|
||||||
|
access: token ? { type: 'recipient', token } : { type: 'user' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
queryHash: `document-duplicate-dialog-${id}`,
|
enabled: open,
|
||||||
enabled: open === true,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const documentData = document?.documentData
|
const envelopeItems = envelopeItemsPayload?.data || [];
|
||||||
? {
|
|
||||||
...document.documentData,
|
|
||||||
data: document.documentData.initialData,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const documentsPath = formatDocumentsPath(team.url);
|
const documentsPath = formatDocumentsPath(team.url);
|
||||||
|
|
||||||
const { mutateAsync: duplicateDocument, isPending: isDuplicateLoading } =
|
const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } =
|
||||||
trpcReact.document.duplicate.useMutation({
|
trpcReact.envelope.duplicate.useMutation({
|
||||||
onSuccess: async ({ documentId }) => {
|
onSuccess: async ({ id }) => {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Document Duplicated`),
|
title: _(msg`Document Duplicated`),
|
||||||
description: _(msg`Your document has been successfully duplicated.`),
|
description: _(msg`Your document has been successfully duplicated.`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
await navigate(`${documentsPath}/${documentId}/edit`);
|
await navigate(`${documentsPath}/${id}/edit`);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const onDuplicate = async () => {
|
const onDuplicate = async () => {
|
||||||
try {
|
try {
|
||||||
await duplicateDocument({ documentId: id });
|
await duplicateEnvelope({ envelopeId: id });
|
||||||
} catch {
|
} catch {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Something went wrong`),
|
title: _(msg`Something went wrong`),
|
||||||
@ -83,14 +81,14 @@ export const DocumentDuplicateDialog = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
<Dialog open={open} onOpenChange={(value) => !isDuplicating && onOpenChange(value)}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<Trans>Duplicate</Trans>
|
<Trans>Duplicate</Trans>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{!documentData || isLoading ? (
|
{isLoadingEnvelopeItems || !envelopeItems || envelopeItems.length === 0 ? (
|
||||||
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
|
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
|
||||||
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
|
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
|
||||||
<Trans>Loading Document...</Trans>
|
<Trans>Loading Document...</Trans>
|
||||||
@ -98,7 +96,12 @@ export const DocumentDuplicateDialog = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll">
|
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll">
|
||||||
<PDFViewer key={document?.id} documentData={documentData} />
|
<PDFViewer
|
||||||
|
key={envelopeItems[0].id}
|
||||||
|
envelopeItem={envelopeItems[0]}
|
||||||
|
token={undefined}
|
||||||
|
version="original"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -115,8 +118,8 @@ export const DocumentDuplicateDialog = ({
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isDuplicateLoading || isLoading}
|
disabled={isDuplicating}
|
||||||
loading={isDuplicateLoading}
|
loading={isDuplicating}
|
||||||
onClick={onDuplicate}
|
onClick={onDuplicate}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -71,7 +71,7 @@ export const DocumentMoveToFolderDialog = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFolders.useQuery(
|
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery(
|
||||||
{
|
{
|
||||||
parentId: currentFolderId,
|
parentId: currentFolderId,
|
||||||
type: FolderType.DOCUMENT,
|
type: FolderType.DOCUMENT,
|
||||||
@ -81,7 +81,7 @@ export const DocumentMoveToFolderDialog = ({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { mutateAsync: moveDocumentToFolder } = trpc.folder.moveDocumentToFolder.useMutation();
|
const { mutateAsync: updateDocument } = trpc.document.update.useMutation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
@ -94,9 +94,11 @@ export const DocumentMoveToFolderDialog = ({
|
|||||||
|
|
||||||
const onSubmit = async (data: TMoveDocumentFormSchema) => {
|
const onSubmit = async (data: TMoveDocumentFormSchema) => {
|
||||||
try {
|
try {
|
||||||
await moveDocumentToFolder({
|
await updateDocument({
|
||||||
documentId,
|
documentId,
|
||||||
|
data: {
|
||||||
folderId: data.folderId ?? null,
|
folderId: data.folderId ?? null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const documentsPath = formatDocumentsPath(team.url);
|
const documentsPath = formatDocumentsPath(team.url);
|
||||||
|
|||||||
@ -4,15 +4,15 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { type Recipient, SigningStatus } from '@prisma/client';
|
import { type Recipient, SigningStatus, type Team, type User } from '@prisma/client';
|
||||||
import { History } from 'lucide-react';
|
import { History } from 'lucide-react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
|
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||||
import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/document';
|
|
||||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||||
|
import type { Document } from '@documenso/prisma/types/document-legacy-schema';
|
||||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -43,7 +43,11 @@ import { StackAvatar } from '../general/stack-avatar';
|
|||||||
const FORM_ID = 'resend-email';
|
const FORM_ID = 'resend-email';
|
||||||
|
|
||||||
export type DocumentResendDialogProps = {
|
export type DocumentResendDialogProps = {
|
||||||
document: TDocumentRow;
|
document: Pick<Document, 'id' | 'userId' | 'teamId' | 'status'> & {
|
||||||
|
user: Pick<User, 'id' | 'name' | 'email'>;
|
||||||
|
recipients: Recipient[];
|
||||||
|
team: Pick<Team, 'id' | 'url'> | null;
|
||||||
|
};
|
||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -85,6 +89,11 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
|
|||||||
formState: { isSubmitting },
|
formState: { isSubmitting },
|
||||||
} = form;
|
} = form;
|
||||||
|
|
||||||
|
const selectedRecipients = useWatch({
|
||||||
|
control: form.control,
|
||||||
|
name: 'recipients',
|
||||||
|
});
|
||||||
|
|
||||||
const onFormSubmit = async ({ recipients }: TResendDocumentFormSchema) => {
|
const onFormSubmit = async ({ recipients }: TResendDocumentFormSchema) => {
|
||||||
try {
|
try {
|
||||||
await resendDocument({ documentId: document.id, recipients });
|
await resendDocument({ documentId: document.id, recipients });
|
||||||
@ -151,7 +160,7 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
|
|||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
className="h-5 w-5 rounded-full"
|
className="h-5 w-5 rounded-full border border-neutral-400"
|
||||||
value={recipient.id}
|
value={recipient.id}
|
||||||
checked={value.includes(recipient.id)}
|
checked={value.includes(recipient.id)}
|
||||||
onCheckedChange={(checked: boolean) =>
|
onCheckedChange={(checked: boolean) =>
|
||||||
@ -182,7 +191,13 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
|
|
||||||
<Button className="flex-1" loading={isSubmitting} type="submit" form={FORM_ID}>
|
<Button
|
||||||
|
className="flex-1"
|
||||||
|
loading={isSubmitting}
|
||||||
|
type="submit"
|
||||||
|
form={FORM_ID}
|
||||||
|
disabled={isSubmitting || selectedRecipients.length === 0}
|
||||||
|
>
|
||||||
<Trans>Send reminder</Trans>
|
<Trans>Send reminder</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
442
apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
Normal file
442
apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
Normal file
@ -0,0 +1,442 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useLingui } from '@lingui/react/macro';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import {
|
||||||
|
DocumentDistributionMethod,
|
||||||
|
DocumentStatus,
|
||||||
|
EnvelopeType,
|
||||||
|
type Field,
|
||||||
|
FieldType,
|
||||||
|
type Recipient,
|
||||||
|
RecipientRole,
|
||||||
|
} from '@prisma/client';
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
import { InfoIcon } from 'lucide-react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
import * as z from 'zod';
|
||||||
|
|
||||||
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
|
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||||
|
import { trpc, trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
|
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 { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
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 { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@documenso/ui/primitives/select';
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||||
|
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type EnvelopeDistributeDialogProps = {
|
||||||
|
envelope: Pick<TEnvelope, 'id' | 'userId' | 'teamId' | 'status' | 'type' | 'documentMeta'> & {
|
||||||
|
recipients: Recipient[];
|
||||||
|
fields: Pick<Field, 'type' | 'recipientId'>[];
|
||||||
|
};
|
||||||
|
onDistribute?: () => Promise<void>;
|
||||||
|
documentRootPath: string;
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ZEnvelopeDistributeFormSchema = z.object({
|
||||||
|
meta: z.object({
|
||||||
|
emailId: z.string().nullable(),
|
||||||
|
emailReplyTo: z.preprocess(
|
||||||
|
(val) => (val === '' ? undefined : val),
|
||||||
|
z.string().email().optional(),
|
||||||
|
),
|
||||||
|
subject: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
distributionMethod: z
|
||||||
|
.nativeEnum(DocumentDistributionMethod)
|
||||||
|
.optional()
|
||||||
|
.default(DocumentDistributionMethod.EMAIL),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TEnvelopeDistributeFormSchema = z.infer<typeof ZEnvelopeDistributeFormSchema>;
|
||||||
|
|
||||||
|
export const EnvelopeDistributeDialog = ({
|
||||||
|
envelope,
|
||||||
|
trigger,
|
||||||
|
documentRootPath,
|
||||||
|
onDistribute,
|
||||||
|
}: EnvelopeDistributeDialogProps) => {
|
||||||
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
|
const recipients = envelope.recipients;
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { t } = useLingui();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const { mutateAsync: distributeEnvelope } = trpcReact.envelope.distribute.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<TEnvelopeDistributeFormSchema>({
|
||||||
|
defaultValues: {
|
||||||
|
meta: {
|
||||||
|
emailId: envelope.documentMeta?.emailId ?? null,
|
||||||
|
emailReplyTo: envelope.documentMeta?.emailReplyTo || undefined,
|
||||||
|
subject: envelope.documentMeta?.subject ?? '',
|
||||||
|
message: envelope.documentMeta?.message ?? '',
|
||||||
|
distributionMethod:
|
||||||
|
envelope.documentMeta?.distributionMethod || DocumentDistributionMethod.EMAIL,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resolver: zodResolver(ZEnvelopeDistributeFormSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
setValue,
|
||||||
|
watch,
|
||||||
|
formState: { isSubmitting },
|
||||||
|
} = form;
|
||||||
|
|
||||||
|
const { data: emailData, isLoading: isLoadingEmails } =
|
||||||
|
trpc.enterprise.organisation.email.find.useQuery({
|
||||||
|
organisationId: organisation.id,
|
||||||
|
perPage: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emails = emailData?.data || [];
|
||||||
|
|
||||||
|
const distributionMethod = watch('meta.distributionMethod');
|
||||||
|
|
||||||
|
const recipientsMissingSignatureFields = useMemo(
|
||||||
|
() =>
|
||||||
|
envelope.recipients.filter(
|
||||||
|
(recipient) =>
|
||||||
|
recipient.role === RecipientRole.SIGNER &&
|
||||||
|
!envelope.fields.some(
|
||||||
|
(field) => field.type === FieldType.SIGNATURE && field.recipientId === recipient.id,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
[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) => {
|
||||||
|
try {
|
||||||
|
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({
|
||||||
|
title: t`Envelope distributed`,
|
||||||
|
description: t`Your envelope has been distributed successfully.`,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: t`Something went wrong`,
|
||||||
|
description: t`This envelope could not be distributed at this time. Please try again.`,
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 7500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (envelope.status !== DocumentStatus.DRAFT || envelope.type !== EnvelopeType.DOCUMENT) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent className="max-w-md" hideClose>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Send Document</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>Recipients will be able to sign the document once sent</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{!invalidEnvelopeCode ? (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||||
|
<fieldset disabled={isSubmitting}>
|
||||||
|
<Tabs
|
||||||
|
onValueChange={(value) =>
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
setValue('meta.distributionMethod', value as DocumentDistributionMethod)
|
||||||
|
}
|
||||||
|
value={distributionMethod}
|
||||||
|
className="mb-2"
|
||||||
|
>
|
||||||
|
<TabsList className="w-full">
|
||||||
|
<TabsTrigger className="w-full" value={DocumentDistributionMethod.EMAIL}>
|
||||||
|
Email
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger className="w-full" value={DocumentDistributionMethod.NONE}>
|
||||||
|
None
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn('min-h-72', {
|
||||||
|
'min-h-[23rem]': organisation.organisationClaim.flags.emailDomains,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<AnimatePresence initial={false} mode="wait">
|
||||||
|
{distributionMethod === DocumentDistributionMethod.EMAIL && (
|
||||||
|
<motion.div
|
||||||
|
key={'Emails'}
|
||||||
|
initial={{ opacity: 0, y: 5 }}
|
||||||
|
animate={{ opacity: 1, y: 0, transition: { duration: 0.3 } }}
|
||||||
|
exit={{ opacity: 0, transition: { duration: 0.15 } }}
|
||||||
|
>
|
||||||
|
<Form {...form}>
|
||||||
|
<fieldset
|
||||||
|
className="mt-2 flex flex-col gap-y-4 rounded-lg"
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
{organisation.organisationClaim.flags.emailDomains && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="meta.emailId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Email Sender</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select
|
||||||
|
{...field}
|
||||||
|
value={field.value === null ? '-1' : field.value}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
field.onChange(value === '-1' ? null : value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
loading={isLoadingEmails}
|
||||||
|
className="bg-background"
|
||||||
|
>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
{emails.map((email) => (
|
||||||
|
<SelectItem key={email.id} value={email.id}>
|
||||||
|
{email.email}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<SelectItem value={'-1'}>Documenso</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="meta.emailReplyTo"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Reply To Email</Trans>{' '}
|
||||||
|
<span className="text-muted-foreground">(Optional)</span>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} maxLength={254} />
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="meta.subject"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Subject</Trans>{' '}
|
||||||
|
<span className="text-muted-foreground">(Optional)</span>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} maxLength={255} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="meta.message"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="flex flex-row items-center">
|
||||||
|
<Trans>Message</Trans>{' '}
|
||||||
|
<span className="text-muted-foreground">(Optional)</span>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<InfoIcon className="mx-2 h-4 w-4" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="text-muted-foreground p-4">
|
||||||
|
<DocumentSendEmailMessageHelper />
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
className="bg-background mt-2 h-16 resize-none"
|
||||||
|
{...field}
|
||||||
|
maxLength={5000}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
</Form>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{distributionMethod === DocumentDistributionMethod.NONE && (
|
||||||
|
<motion.div
|
||||||
|
key={'Links'}
|
||||||
|
initial={{ opacity: 0, y: 5 }}
|
||||||
|
animate={{ opacity: 1, y: 0, transition: { duration: 0.3 } }}
|
||||||
|
exit={{ opacity: 0, transition: { duration: 0.15 } }}
|
||||||
|
className="min-h-60 rounded-lg border"
|
||||||
|
>
|
||||||
|
<div className="text-muted-foreground py-24 text-center text-sm">
|
||||||
|
<p>
|
||||||
|
<Trans>We won't send anything to notify recipients.</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="mt-2">
|
||||||
|
<Trans>
|
||||||
|
We will generate signing links for you, which you can send to the
|
||||||
|
recipients through your method of choice.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="secondary" disabled={isSubmitting}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
|
||||||
|
<Button loading={isSubmitting} type="submit">
|
||||||
|
{distributionMethod === DocumentDistributionMethod.EMAIL ? (
|
||||||
|
<Trans>Send</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Generate Links</Trans>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Alert variant="warning">
|
||||||
|
{match(invalidEnvelopeCode)
|
||||||
|
.with('MISSING_RECIPIENTS', () => (
|
||||||
|
<AlertDescription>
|
||||||
|
<Trans>You need at least one recipient to send a document</Trans>
|
||||||
|
</AlertDescription>
|
||||||
|
))
|
||||||
|
.with('MISSING_SIGNATURES', () => (
|
||||||
|
<AlertDescription>
|
||||||
|
<Trans>The following signers are missing signature fields:</Trans>
|
||||||
|
|
||||||
|
<ul className="ml-2 mt-1 list-inside list-disc">
|
||||||
|
{recipientsMissingSignatureFields.map((recipient) => (
|
||||||
|
<li key={recipient.id}>{recipient.email}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</AlertDescription>
|
||||||
|
))
|
||||||
|
.exhaustive()}
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="secondary">
|
||||||
|
<Trans>Close</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
204
apps/remix/app/components/dialogs/envelope-download-dialog.tsx
Normal file
204
apps/remix/app/components/dialogs/envelope-download-dialog.tsx
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { useLingui } from '@lingui/react/macro';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { DocumentStatus, type EnvelopeItem } from '@prisma/client';
|
||||||
|
import { DownloadIcon, FileTextIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
type EnvelopeItemToDownload = Pick<EnvelopeItem, 'id' | 'envelopeId' | 'title' | 'order'>;
|
||||||
|
|
||||||
|
type EnvelopeDownloadDialogProps = {
|
||||||
|
envelopeId: string;
|
||||||
|
envelopeStatus: DocumentStatus;
|
||||||
|
envelopeItems?: EnvelopeItemToDownload[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The recipient token to download the document.
|
||||||
|
*
|
||||||
|
* If not provided, it will be assumed that the current user can access the document.
|
||||||
|
*/
|
||||||
|
token?: string;
|
||||||
|
trigger: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EnvelopeDownloadDialog = ({
|
||||||
|
envelopeId,
|
||||||
|
envelopeStatus,
|
||||||
|
envelopeItems: initialEnvelopeItems,
|
||||||
|
token,
|
||||||
|
trigger,
|
||||||
|
}: EnvelopeDownloadDialogProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const [isDownloadingState, setIsDownloadingState] = useState<{
|
||||||
|
[envelopeItemIdAndVersion: string]: boolean;
|
||||||
|
}>({});
|
||||||
|
|
||||||
|
const generateDownloadKey = (envelopeItemId: string, version: 'original' | 'signed') =>
|
||||||
|
`${envelopeItemId}-${version}`;
|
||||||
|
|
||||||
|
const { data: envelopeItemsPayload, isLoading: isLoadingEnvelopeItems } =
|
||||||
|
trpc.envelope.item.getManyByToken.useQuery(
|
||||||
|
{
|
||||||
|
envelopeId,
|
||||||
|
access: token ? { type: 'recipient', token } : { type: 'user' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
initialData: initialEnvelopeItems ? { data: initialEnvelopeItems } : undefined,
|
||||||
|
enabled: open,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const envelopeItems = envelopeItemsPayload?.data || [];
|
||||||
|
|
||||||
|
const onDownload = async (
|
||||||
|
envelopeItem: EnvelopeItemToDownload,
|
||||||
|
version: 'original' | 'signed',
|
||||||
|
) => {
|
||||||
|
const { id: envelopeItemId } = envelopeItem;
|
||||||
|
|
||||||
|
if (isDownloadingState[generateDownloadKey(envelopeItemId, version)]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsDownloadingState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[generateDownloadKey(envelopeItemId, version)]: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await downloadPDF({
|
||||||
|
envelopeItem,
|
||||||
|
token,
|
||||||
|
fileName: envelopeItem.title,
|
||||||
|
version,
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsDownloadingState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[generateDownloadKey(envelopeItemId, version)]: false,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
setIsDownloadingState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[generateDownloadKey(envelopeItemId, version)]: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t`Something went wrong`,
|
||||||
|
description: t`This document could not be downloaded at this time. Please try again.`,
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 7500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(value) => setOpen(value)}>
|
||||||
|
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Download Files</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>Select the files you would like to download.</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{isLoadingEnvelopeItems ? (
|
||||||
|
<>
|
||||||
|
{Array.from({ length: 1 }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="border-border bg-card flex items-center gap-2 rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
<Skeleton className="h-10 w-10 flex-shrink-0 rounded-lg" />
|
||||||
|
|
||||||
|
<div className="flex w-full flex-col gap-2">
|
||||||
|
<Skeleton className="h-4 w-28 rounded-lg" />
|
||||||
|
<Skeleton className="h-4 w-20 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Skeleton className="h-10 w-20 flex-shrink-0 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
envelopeItems.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="border-border bg-card hover:bg-accent/50 flex items-center gap-4 rounded-lg border p-4 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg">
|
||||||
|
<FileTextIcon className="text-primary h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{/* Todo: Envelopes - Fix overflow */}
|
||||||
|
<h4 className="text-foreground truncate text-sm font-medium">{item.title}</h4>
|
||||||
|
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||||
|
<Trans>PDF Document</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-shrink-0 items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs"
|
||||||
|
onClick={async () => onDownload(item, 'original')}
|
||||||
|
loading={isDownloadingState[generateDownloadKey(item.id, 'original')]}
|
||||||
|
>
|
||||||
|
{!isDownloadingState[generateDownloadKey(item.id, 'original')] && (
|
||||||
|
<DownloadIcon className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
<Trans>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>
|
||||||
|
);
|
||||||
|
};
|
||||||
113
apps/remix/app/components/dialogs/envelope-duplicate-dialog.tsx
Normal file
113
apps/remix/app/components/dialogs/envelope-duplicate-dialog.tsx
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { useLingui } from '@lingui/react/macro';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { EnvelopeType } from '@prisma/client';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
|
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
|
type EnvelopeDuplicateDialogProps = {
|
||||||
|
envelopeId: string;
|
||||||
|
envelopeType: EnvelopeType;
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EnvelopeDuplicateDialog = ({
|
||||||
|
envelopeId,
|
||||||
|
envelopeType,
|
||||||
|
trigger,
|
||||||
|
}: EnvelopeDuplicateDialogProps) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
|
const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } =
|
||||||
|
trpc.envelope.duplicate.useMutation({
|
||||||
|
onSuccess: async ({ id }) => {
|
||||||
|
toast({
|
||||||
|
title: t`Envelope Duplicated`,
|
||||||
|
description: t`Your envelope has been successfully duplicated.`,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const path =
|
||||||
|
envelopeType === EnvelopeType.DOCUMENT
|
||||||
|
? formatDocumentsPath(team.url)
|
||||||
|
: formatTemplatesPath(team.url);
|
||||||
|
|
||||||
|
await navigate(`${path}/${id}/edit`);
|
||||||
|
setOpen(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onDuplicate = async () => {
|
||||||
|
try {
|
||||||
|
await duplicateEnvelope({ envelopeId });
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
title: t`Something went wrong`,
|
||||||
|
description: t`This document could not be duplicated at this time. Please try again.`,
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 7500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(value) => !isDuplicating && setOpen(value)}>
|
||||||
|
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
{envelopeType === EnvelopeType.DOCUMENT ? (
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Duplicate Document</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>This document will be duplicated.</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
) : (
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Duplicate Template</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>This template will be duplicated.</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" disabled={isDuplicating}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="button" loading={isDuplicating} onClick={onDuplicate}>
|
||||||
|
<Trans>Duplicate</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,134 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { useLingui } from '@lingui/react/macro';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type EnvelopeItemDeleteDialogProps = {
|
||||||
|
canItemBeDeleted: boolean;
|
||||||
|
envelopeId: string;
|
||||||
|
envelopeItemId: string;
|
||||||
|
envelopeItemTitle: string;
|
||||||
|
onDelete?: (envelopeItemId: string) => void;
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EnvelopeItemDeleteDialog = ({
|
||||||
|
trigger,
|
||||||
|
canItemBeDeleted,
|
||||||
|
envelopeId,
|
||||||
|
envelopeItemId,
|
||||||
|
envelopeItemTitle,
|
||||||
|
onDelete,
|
||||||
|
}: EnvelopeItemDeleteDialogProps) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const { t } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { mutateAsync: deleteEnvelopeItem, isPending: isDeleting } =
|
||||||
|
trpc.envelope.item.delete.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: t`Success`,
|
||||||
|
description: t`You have successfully removed this envelope item.`,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
onDelete?.(envelopeItemId);
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({
|
||||||
|
title: t`An unknown error occurred`,
|
||||||
|
description: t`We encountered an unknown error while attempting to remove this envelope item. Please try again later.`,
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 10000,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(value) => !isDeleting && setOpen(value)}>
|
||||||
|
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||||
|
|
||||||
|
{canItemBeDeleted ? (
|
||||||
|
<DialogContent position="center">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Are you sure?</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription className="mt-4">
|
||||||
|
<Trans>
|
||||||
|
You are about to remove the following document and all associated fields
|
||||||
|
</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Alert variant="neutral">
|
||||||
|
<AlertDescription className="text-center font-semibold">
|
||||||
|
{envelopeItemTitle}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<fieldset disabled={isDeleting}>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="destructive"
|
||||||
|
loading={isDeleting}
|
||||||
|
onClick={async () =>
|
||||||
|
deleteEnvelopeItem({
|
||||||
|
envelopeId,
|
||||||
|
envelopeItemId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trans>Delete</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</DialogContent>
|
||||||
|
) : (
|
||||||
|
<DialogContent position="center">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>This item cannot be deleted</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription className="mt-4">
|
||||||
|
<Trans>
|
||||||
|
You cannot delete this item because the document has been sent to recipients
|
||||||
|
</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
|
<Trans>Close</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,187 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react/macro';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { DocumentStatus, EnvelopeType, type Recipient, SigningStatus } from '@prisma/client';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import * as z from 'zod';
|
||||||
|
|
||||||
|
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||||
|
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||||
|
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||||
|
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { StackAvatar } from '../general/stack-avatar';
|
||||||
|
|
||||||
|
export type EnvelopeRedistributeDialogProps = {
|
||||||
|
envelope: Pick<TEnvelope, 'id' | 'userId' | 'teamId' | 'status' | 'type' | 'documentMeta'> & {
|
||||||
|
recipients: Recipient[];
|
||||||
|
};
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ZEnvelopeRedistributeFormSchema = z.object({
|
||||||
|
recipients: z.array(z.number()).min(1, {
|
||||||
|
message: msg`You must select at least one item`.id,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TEnvelopeRedistributeFormSchema = z.infer<typeof ZEnvelopeRedistributeFormSchema>;
|
||||||
|
|
||||||
|
export const EnvelopeRedistributeDialog = ({
|
||||||
|
envelope,
|
||||||
|
trigger,
|
||||||
|
}: EnvelopeRedistributeDialogProps) => {
|
||||||
|
const recipients = envelope.recipients;
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const { mutateAsync: redistributeEnvelope } = trpcReact.envelope.redistribute.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<TEnvelopeRedistributeFormSchema>({
|
||||||
|
defaultValues: {
|
||||||
|
recipients: [],
|
||||||
|
},
|
||||||
|
resolver: zodResolver(ZEnvelopeRedistributeFormSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
formState: { isSubmitting },
|
||||||
|
} = form;
|
||||||
|
|
||||||
|
const onFormSubmit = async ({ recipients }: TEnvelopeRedistributeFormSchema) => {
|
||||||
|
try {
|
||||||
|
await redistributeEnvelope({ envelopeId: envelope.id, recipients });
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t`Envelope resent`,
|
||||||
|
description: t`Your envelope has been resent successfully.`,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: t`Something went wrong`,
|
||||||
|
description: t`This envelope could not be resent at this time. Please try again.`,
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 7500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
form.reset();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
if (envelope.status !== DocumentStatus.PENDING || envelope.type !== EnvelopeType.DOCUMENT) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent className="max-w-md" hideClose>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Resend Document</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>Send reminders to the following recipients</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||||
|
<fieldset disabled={isSubmitting}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="recipients"
|
||||||
|
render={({ field: { value, onChange } }) => (
|
||||||
|
<>
|
||||||
|
{recipients
|
||||||
|
.filter((recipient) => recipient.signingStatus === SigningStatus.NOT_SIGNED)
|
||||||
|
.map((recipient) => (
|
||||||
|
<FormItem
|
||||||
|
key={recipient.id}
|
||||||
|
className="flex flex-row items-center justify-between gap-x-3 px-3"
|
||||||
|
>
|
||||||
|
<FormLabel
|
||||||
|
className={cn('my-2 flex items-center gap-2 font-normal', {
|
||||||
|
'opacity-50': !value.includes(recipient.id),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<StackAvatar
|
||||||
|
key={recipient.id}
|
||||||
|
type={getRecipientType(recipient)}
|
||||||
|
fallbackText={recipientAbbreviation(recipient)}
|
||||||
|
/>
|
||||||
|
{recipient.email}
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Checkbox
|
||||||
|
className="h-5 w-5 rounded-full"
|
||||||
|
value={recipient.id}
|
||||||
|
checked={value.includes(recipient.id)}
|
||||||
|
onCheckedChange={(checked: boolean) =>
|
||||||
|
checked
|
||||||
|
? onChange([...value, recipient.id])
|
||||||
|
: onChange(value.filter((v) => v !== recipient.id))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter className="mt-4">
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="secondary" disabled={isSubmitting}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
|
||||||
|
<Button loading={isSubmitting} type="submit">
|
||||||
|
<Trans>Send reminder</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -63,7 +63,7 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
|
|||||||
const onFormSubmit = async () => {
|
const onFormSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
await deleteFolder({
|
await deleteFolder({
|
||||||
id: folder.id,
|
folderId: folder.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
|
|||||||
@ -53,7 +53,7 @@ export const FolderMoveDialog = ({
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
const { mutateAsync: moveFolder } = trpc.folder.moveFolder.useMutation();
|
const { mutateAsync: moveFolder } = trpc.folder.updateFolder.useMutation();
|
||||||
|
|
||||||
const form = useForm<TMoveFolderFormSchema>({
|
const form = useForm<TMoveFolderFormSchema>({
|
||||||
resolver: zodResolver(ZMoveFolderFormSchema),
|
resolver: zodResolver(ZMoveFolderFormSchema),
|
||||||
@ -63,12 +63,16 @@ export const FolderMoveDialog = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const onFormSubmit = async ({ targetFolderId }: TMoveFolderFormSchema) => {
|
const onFormSubmit = async ({ targetFolderId }: TMoveFolderFormSchema) => {
|
||||||
if (!folder) return;
|
if (!folder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await moveFolder({
|
await moveFolder({
|
||||||
id: folder.id,
|
folderId: folder.id,
|
||||||
|
data: {
|
||||||
parentId: targetFolderId || null,
|
parentId: targetFolderId || null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
|
|||||||
@ -61,8 +61,6 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation();
|
const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation();
|
||||||
|
|
||||||
const isTeamContext = !!team;
|
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof ZUpdateFolderFormSchema>>({
|
const form = useForm<z.infer<typeof ZUpdateFolderFormSchema>>({
|
||||||
resolver: zodResolver(ZUpdateFolderFormSchema),
|
resolver: zodResolver(ZUpdateFolderFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@ -87,11 +85,11 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await updateFolder({
|
await updateFolder({
|
||||||
id: folder.id,
|
folderId: folder.id,
|
||||||
|
data: {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
visibility: isTeamContext
|
visibility: data.visibility,
|
||||||
? (data.visibility ?? DocumentVisibility.EVERYONE)
|
},
|
||||||
: DocumentVisibility.EVERYONE,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@ -140,7 +138,6 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isTeamContext && (
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="visibility"
|
name="visibility"
|
||||||
@ -171,7 +168,6 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<DialogClose asChild>
|
<DialogClose asChild>
|
||||||
|
|||||||
@ -185,6 +185,10 @@ 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';
|
||||||
|
|||||||
@ -4,14 +4,14 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Plural, Trans } from '@lingui/react/macro';
|
import { Plural, Trans } from '@lingui/react/macro';
|
||||||
import type { Template, TemplateDirectLink } from '@prisma/client';
|
import { type TemplateDirectLink, TemplateType } from '@prisma/client';
|
||||||
import { TemplateType } from '@prisma/client';
|
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
import { CheckCircle2Icon, CircleIcon } from 'lucide-react';
|
import { CheckCircle2Icon, CircleIcon } from 'lucide-react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { P, match } from 'ts-pattern';
|
import { P, match } from 'ts-pattern';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { type Template } from '@documenso/prisma/types/template-legacy-schema';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import {
|
import {
|
||||||
MAX_TEMPLATE_PUBLIC_DESCRIPTION_LENGTH,
|
MAX_TEMPLATE_PUBLIC_DESCRIPTION_LENGTH,
|
||||||
@ -52,7 +52,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export type ManagePublicTemplateDialogProps = {
|
export type ManagePublicTemplateDialogProps = {
|
||||||
directTemplates: (Template & {
|
directTemplates: (Omit<Template, 'templateDocumentDataId'> & {
|
||||||
directLink: Pick<TemplateDirectLink, 'token' | 'enabled'>;
|
directLink: Pick<TemplateDirectLink, 'token' | 'enabled'>;
|
||||||
})[];
|
})[];
|
||||||
initialTemplateId?: number | null;
|
initialTemplateId?: number | null;
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
import { useLingui } from '@lingui/react/macro';
|
||||||
|
import { createCallable } from 'react-call';
|
||||||
|
|
||||||
|
import type { TDropdownFieldMeta } from '@documenso/lib/types/field-meta';
|
||||||
|
import {
|
||||||
|
CommandDialog,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from '@documenso/ui/primitives/command';
|
||||||
|
|
||||||
|
export type SignFieldDropdownDialogProps = {
|
||||||
|
fieldMeta: TDropdownFieldMeta;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SignFieldDropdownDialog = createCallable<SignFieldDropdownDialogProps, string | null>(
|
||||||
|
({ call, fieldMeta }) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
const values = fieldMeta.values?.map((value) => value.value) ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandDialog
|
||||||
|
position="start"
|
||||||
|
dialogContentClassName="mt-4"
|
||||||
|
open={true}
|
||||||
|
onOpenChange={(value) => (!value ? call.end(null) : null)}
|
||||||
|
>
|
||||||
|
<CommandInput placeholder={t`Select an option`} />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No results found.</CommandEmpty>
|
||||||
|
<CommandGroup heading={t`Options`}>
|
||||||
|
{values.map((value, i) => (
|
||||||
|
<CommandItem onSelect={() => call.end(value)} key={i} value={value}>
|
||||||
|
{value}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</CommandDialog>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
@ -0,0 +1,93 @@
|
|||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { createCallable } from 'react-call';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
|
||||||
|
const ZSignFieldEmailFormSchema = z.object({
|
||||||
|
email: z.string().min(1, { message: msg`Email is required`.id }),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TSignFieldEmailFormSchema = z.infer<typeof ZSignFieldEmailFormSchema>;
|
||||||
|
|
||||||
|
export type SignFieldEmailDialogProps = {
|
||||||
|
placeholderEmail: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SignFieldEmailDialog = createCallable<SignFieldEmailDialogProps, string | null>(
|
||||||
|
({ call, placeholderEmail }) => {
|
||||||
|
const form = useForm<TSignFieldEmailFormSchema>({
|
||||||
|
resolver: zodResolver(ZSignFieldEmailFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
email: placeholderEmail || '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Sign Email</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription className="mt-4">
|
||||||
|
<Trans>Sign your email into the field</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit((data) => call.end(data.email))}>
|
||||||
|
<fieldset
|
||||||
|
className="flex h-full flex-col space-y-4"
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
@ -0,0 +1,97 @@
|
|||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { createCallable } from 'react-call';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
|
||||||
|
const ZSignFieldInitialsFormSchema = z.object({
|
||||||
|
initials: z.string().min(1, { message: msg`Initials are required`.id }),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TSignFieldInitialsFormSchema = z.infer<typeof ZSignFieldInitialsFormSchema>;
|
||||||
|
|
||||||
|
export type SignFieldInitialsDialogProps = {
|
||||||
|
//
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SignFieldInitialsDialog = createCallable<SignFieldInitialsDialogProps, string | null>(
|
||||||
|
({ call }) => {
|
||||||
|
const form = useForm<TSignFieldInitialsFormSchema>({
|
||||||
|
resolver: zodResolver(ZSignFieldInitialsFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
initials: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Sign Initials</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription className="mt-4">
|
||||||
|
<Trans>Sign your initials into the field</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit((data) => call.end(data.initials))}>
|
||||||
|
<fieldset
|
||||||
|
className="flex h-full flex-col space-y-4"
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="initials"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel required>
|
||||||
|
<Trans>Initials</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
93
apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
Normal file
93
apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { createCallable } from 'react-call';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
|
||||||
|
const ZSignFieldNameFormSchema = z.object({
|
||||||
|
name: z.string().min(1, { message: msg`Name is required`.id }),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TSignFieldNameFormSchema = z.infer<typeof ZSignFieldNameFormSchema>;
|
||||||
|
|
||||||
|
export type SignFieldNameDialogProps = {
|
||||||
|
//
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SignFieldNameDialog = createCallable<SignFieldNameDialogProps, string | null>(
|
||||||
|
({ call }) => {
|
||||||
|
const form = useForm<TSignFieldNameFormSchema>({
|
||||||
|
resolver: zodResolver(ZSignFieldNameFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Sign Name</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription className="mt-4">
|
||||||
|
<Trans>Sign your full name into the field</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit((data) => call.end(data.name))}>
|
||||||
|
<fieldset
|
||||||
|
className="flex h-full flex-col space-y-4"
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
142
apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
Normal file
142
apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react/macro';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { createCallable } from 'react-call';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import type { TNumberFieldMeta } from '@documenso/lib/types/field-meta';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { numberFormatValues } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
|
||||||
|
const createNumberFieldSchema = (fieldMeta: TNumberFieldMeta) => {
|
||||||
|
let schema = z.coerce.number({
|
||||||
|
invalid_type_error: msg`Please enter a valid number`.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { numberFormat, minValue, maxValue } = fieldMeta;
|
||||||
|
|
||||||
|
if (typeof minValue === 'number') {
|
||||||
|
schema = schema.min(minValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof maxValue === 'number') {
|
||||||
|
schema = schema.max(maxValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (numberFormat) {
|
||||||
|
const foundRegex = numberFormatValues.find((item) => item.value === numberFormat)?.regex;
|
||||||
|
|
||||||
|
if (!foundRegex) {
|
||||||
|
return schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
return schema.refine(
|
||||||
|
(value) => {
|
||||||
|
return foundRegex.test(value.toString());
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: msg`Number needs to be formatted as ${numberFormat}`.id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return schema;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SignFieldNumberDialogProps = {
|
||||||
|
fieldMeta: TNumberFieldMeta;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SignFieldNumberDialog = createCallable<SignFieldNumberDialogProps, number | null>(
|
||||||
|
({ call, fieldMeta }) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
const ZSignFieldNumberFormSchema = z.object({
|
||||||
|
number: createNumberFieldSchema(fieldMeta),
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof ZSignFieldNumberFormSchema>>({
|
||||||
|
resolver: zodResolver(ZSignFieldNumberFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
number: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Sign Number Field</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription className="mt-4">
|
||||||
|
<Trans>Insert a value into the number field</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit((data) => call.end(data.number))}>
|
||||||
|
<fieldset
|
||||||
|
className="flex h-full flex-col space-y-4"
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="number"
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<FormItem>
|
||||||
|
{fieldMeta.label && <FormLabel>{fieldMeta.label}</FormLabel>}
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder={fieldMeta.placeholder ?? t`Enter your number here`}
|
||||||
|
className={cn('w-full rounded-md', {
|
||||||
|
'border-2 border-red-300 text-left ring-2 ring-red-200 ring-offset-2 ring-offset-red-200 focus-visible:border-red-400 focus-visible:ring-4 focus-visible:ring-red-200 focus-visible:ring-offset-2 focus-visible:ring-offset-red-200':
|
||||||
|
fieldState.error,
|
||||||
|
})}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { createCallable } from 'react-call';
|
||||||
|
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||||
|
|
||||||
|
import { DocumentSigningDisclosure } from '../general/document-signing/document-signing-disclosure';
|
||||||
|
|
||||||
|
export type SignFieldSignatureDialogProps = {
|
||||||
|
initialSignature?: string;
|
||||||
|
typedSignatureEnabled?: boolean;
|
||||||
|
uploadSignatureEnabled?: boolean;
|
||||||
|
drawSignatureEnabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SignFieldSignatureDialog = createCallable<
|
||||||
|
SignFieldSignatureDialogProps,
|
||||||
|
string | null
|
||||||
|
>(
|
||||||
|
({
|
||||||
|
call,
|
||||||
|
typedSignatureEnabled,
|
||||||
|
uploadSignatureEnabled,
|
||||||
|
drawSignatureEnabled,
|
||||||
|
initialSignature,
|
||||||
|
}) => {
|
||||||
|
const [localSignature, setLocalSignature] = useState(initialSignature);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
||||||
|
<DialogContent position="center">
|
||||||
|
<div>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Sign Signature Field</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<SignaturePad
|
||||||
|
value={localSignature ?? ''}
|
||||||
|
onChange={({ value }) => setLocalSignature(value)}
|
||||||
|
typedSignatureEnabled={typedSignatureEnabled}
|
||||||
|
uploadSignatureEnabled={uploadSignatureEnabled}
|
||||||
|
drawSignatureEnabled={drawSignatureEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DocumentSigningDisclosure />
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => call.end(null)}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={!localSignature}
|
||||||
|
onClick={() => call.end(localSignature || null)}
|
||||||
|
>
|
||||||
|
<Trans>Sign</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
120
apps/remix/app/components/dialogs/sign-field-text-dialog.tsx
Normal file
120
apps/remix/app/components/dialogs/sign-field-text-dialog.tsx
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { Plural, useLingui } from '@lingui/react/macro';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { createCallable } from 'react-call';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import type { TTextFieldMeta } from '@documenso/lib/types/field-meta';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||||
|
|
||||||
|
const ZSignFieldTextFormSchema = z.object({
|
||||||
|
text: z.string().min(1, { message: msg`Text is required`.id }),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TSignFieldTextFormSchema = z.infer<typeof ZSignFieldTextFormSchema>;
|
||||||
|
|
||||||
|
export type SignFieldTextDialogProps = {
|
||||||
|
fieldMeta?: TTextFieldMeta;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SignFieldTextDialog = createCallable<SignFieldTextDialogProps, string | null>(
|
||||||
|
({ call, fieldMeta }) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
const form = useForm<TSignFieldTextFormSchema>({
|
||||||
|
resolver: zodResolver(ZSignFieldTextFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
text: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Sign Text Field</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription className="mt-4">
|
||||||
|
<Trans>Insert a value into the text field</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit((data) => call.end(data.text))}>
|
||||||
|
<fieldset
|
||||||
|
className="flex h-full flex-col space-y-4"
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="text"
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<FormItem>
|
||||||
|
{fieldMeta?.label && <FormLabel>{fieldMeta?.label}</FormLabel>}
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
id="custom-text"
|
||||||
|
placeholder={fieldMeta?.placeholder ?? t`Enter your text here`}
|
||||||
|
className={cn('w-full rounded-md', {
|
||||||
|
'border-2 border-red-300 text-left ring-2 ring-red-200 ring-offset-2 ring-offset-red-200 focus-visible:border-red-400 focus-visible:ring-4 focus-visible:ring-red-200 focus-visible:ring-offset-2 focus-visible:ring-offset-red-200':
|
||||||
|
fieldState.error,
|
||||||
|
})}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
{fieldMeta?.characterLimit !== undefined &&
|
||||||
|
fieldMeta?.characterLimit > 0 &&
|
||||||
|
!fieldState.error && (
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
<Plural
|
||||||
|
value={fieldMeta?.characterLimit - (field.value?.length ?? 0)}
|
||||||
|
one="# character remaining"
|
||||||
|
other="# characters remaining"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
@ -7,9 +7,9 @@ import { FilePlus, Loader } from 'lucide-react';
|
|||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
|
||||||
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import type { TCreateTemplatePayloadSchema } from '@documenso/trpc/server/template-router/schema';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@ -44,7 +44,9 @@ export const TemplateCreateDialog = ({ folderId }: TemplateCreateDialogProps) =>
|
|||||||
const [showTemplateCreateDialog, setShowTemplateCreateDialog] = useState(false);
|
const [showTemplateCreateDialog, setShowTemplateCreateDialog] = useState(false);
|
||||||
const [isUploadingFile, setIsUploadingFile] = useState(false);
|
const [isUploadingFile, setIsUploadingFile] = useState(false);
|
||||||
|
|
||||||
const onFileDrop = async (file: File) => {
|
const onFileDrop = async (files: File[]) => {
|
||||||
|
const file = files[0];
|
||||||
|
|
||||||
if (isUploadingFile) {
|
if (isUploadingFile) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -52,13 +54,17 @@ export const TemplateCreateDialog = ({ folderId }: TemplateCreateDialogProps) =>
|
|||||||
setIsUploadingFile(true);
|
setIsUploadingFile(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await putPdfFile(file);
|
const payload = {
|
||||||
|
|
||||||
const { id } = await createTemplate({
|
|
||||||
title: file.name,
|
title: file.name,
|
||||||
templateDocumentDataId: response.id,
|
|
||||||
folderId: folderId,
|
folderId: folderId,
|
||||||
});
|
} satisfies TCreateTemplatePayloadSchema;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
formData.append('payload', JSON.stringify(payload));
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const { envelopeId: id } = await createTemplate(formData);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Template document uploaded`),
|
title: _(msg`Template document uploaded`),
|
||||||
|
|||||||
@ -1,46 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import type { Recipient, Template, TemplateDirectLink } from '@prisma/client';
|
|
||||||
import { LinkIcon } from 'lucide-react';
|
|
||||||
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
|
|
||||||
import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog';
|
|
||||||
|
|
||||||
export type TemplateDirectLinkDialogWrapperProps = {
|
|
||||||
template: Template & { directLink?: TemplateDirectLink | null; recipients: Recipient[] };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TemplateDirectLinkDialogWrapper = ({
|
|
||||||
template,
|
|
||||||
}: TemplateDirectLinkDialogWrapperProps) => {
|
|
||||||
const [isTemplateDirectLinkOpen, setTemplateDirectLinkOpen] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="px-3"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setTemplateDirectLinkOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<LinkIcon className="mr-1.5 h-3.5 w-3.5" />
|
|
||||||
|
|
||||||
{template.directLink ? (
|
|
||||||
<Trans>Manage Direct Link</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>Create Direct Link</Trans>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<TemplateDirectLinkDialog
|
|
||||||
template={template}
|
|
||||||
open={isTemplateDirectLinkOpen}
|
|
||||||
onOpenChange={setTemplateDirectLinkOpen}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -3,13 +3,15 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { type Recipient, RecipientRole, type TemplateDirectLink } from '@prisma/client';
|
||||||
import {
|
import {
|
||||||
type Recipient,
|
CircleDotIcon,
|
||||||
RecipientRole,
|
CircleIcon,
|
||||||
type Template,
|
ClipboardCopyIcon,
|
||||||
type TemplateDirectLink,
|
InfoIcon,
|
||||||
} from '@prisma/client';
|
LinkIcon,
|
||||||
import { CircleDotIcon, CircleIcon, ClipboardCopyIcon, InfoIcon, LoaderIcon } from 'lucide-react';
|
LoaderIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
import { Link, useRevalidator } from 'react-router';
|
import { Link, useRevalidator } from 'react-router';
|
||||||
import { P, match } from 'ts-pattern';
|
import { P, match } from 'ts-pattern';
|
||||||
|
|
||||||
@ -31,6 +33,7 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
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';
|
||||||
@ -47,20 +50,19 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitive
|
|||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
type TemplateDirectLinkDialogProps = {
|
type TemplateDirectLinkDialogProps = {
|
||||||
template: Template & {
|
templateId: number;
|
||||||
directLink?: Pick<TemplateDirectLink, 'token' | 'enabled'> | null;
|
directLink?: Pick<TemplateDirectLink, 'token' | 'enabled'> | null;
|
||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
};
|
trigger?: React.ReactNode;
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (_open: boolean) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type TemplateDirectLinkStep = 'ONBOARD' | 'SELECT_RECIPIENT' | 'MANAGE' | 'CONFIRM_DELETE';
|
type TemplateDirectLinkStep = 'ONBOARD' | 'SELECT_RECIPIENT' | 'MANAGE' | 'CONFIRM_DELETE';
|
||||||
|
|
||||||
export const TemplateDirectLinkDialog = ({
|
export const TemplateDirectLinkDialog = ({
|
||||||
template,
|
templateId,
|
||||||
open,
|
directLink,
|
||||||
onOpenChange,
|
recipients,
|
||||||
|
trigger,
|
||||||
}: TemplateDirectLinkDialogProps) => {
|
}: TemplateDirectLinkDialogProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { quota, remaining } = useLimits();
|
const { quota, remaining } = useLimits();
|
||||||
@ -69,8 +71,9 @@ export const TemplateDirectLinkDialog = ({
|
|||||||
|
|
||||||
const [, copy] = useCopyToClipboard();
|
const [, copy] = useCopyToClipboard();
|
||||||
|
|
||||||
const [isEnabled, setIsEnabled] = useState(template.directLink?.enabled ?? false);
|
const [open, setOpen] = useState(false);
|
||||||
const [token, setToken] = useState(template.directLink?.token ?? null);
|
const [isEnabled, setIsEnabled] = useState(directLink?.enabled ?? false);
|
||||||
|
const [token, setToken] = useState(directLink?.token ?? null);
|
||||||
const [selectedRecipientId, setSelectedRecipientId] = useState<number | null>(null);
|
const [selectedRecipientId, setSelectedRecipientId] = useState<number | null>(null);
|
||||||
const [currentStep, setCurrentStep] = useState<TemplateDirectLinkStep>(
|
const [currentStep, setCurrentStep] = useState<TemplateDirectLinkStep>(
|
||||||
token ? 'MANAGE' : 'ONBOARD',
|
token ? 'MANAGE' : 'ONBOARD',
|
||||||
@ -80,11 +83,11 @@ export const TemplateDirectLinkDialog = ({
|
|||||||
|
|
||||||
const validDirectTemplateRecipients = useMemo(
|
const validDirectTemplateRecipients = useMemo(
|
||||||
() =>
|
() =>
|
||||||
template.recipients.filter(
|
recipients.filter(
|
||||||
(recipient) =>
|
(recipient) =>
|
||||||
recipient.role !== RecipientRole.CC && recipient.role !== RecipientRole.ASSISTANT,
|
recipient.role !== RecipientRole.CC && recipient.role !== RecipientRole.ASSISTANT,
|
||||||
),
|
),
|
||||||
[template.recipients],
|
[recipients],
|
||||||
);
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -140,7 +143,7 @@ export const TemplateDirectLinkDialog = ({
|
|||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await revalidate();
|
await revalidate();
|
||||||
|
|
||||||
onOpenChange(false);
|
setOpen(false);
|
||||||
setToken(null);
|
setToken(null);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@ -178,7 +181,7 @@ export const TemplateDirectLinkDialog = ({
|
|||||||
setSelectedRecipientId(recipientId);
|
setSelectedRecipientId(recipientId);
|
||||||
|
|
||||||
await createTemplateDirectLink({
|
await createTemplateDirectLink({
|
||||||
templateId: template.id,
|
templateId,
|
||||||
directRecipientId: recipientId,
|
directRecipientId: recipientId,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -195,7 +198,17 @@ export const TemplateDirectLinkDialog = ({
|
|||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
<Dialog open={open} onOpenChange={(value) => !isLoading && setOpen(value)}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{trigger || (
|
||||||
|
<Button variant="outline" className="px-3">
|
||||||
|
<LinkIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
|
||||||
|
{directLink ? <Trans>Manage Direct Link</Trans> : <Trans>Create Direct Link</Trans>}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent hideClose>
|
||||||
<fieldset disabled={isLoading} className="relative">
|
<fieldset disabled={isLoading} className="relative">
|
||||||
<AnimateGenericFadeInOut motionKey={currentStep}>
|
<AnimateGenericFadeInOut motionKey={currentStep}>
|
||||||
{match({ token, currentStep })
|
{match({ token, currentStep })
|
||||||
@ -331,7 +344,7 @@ export const TemplateDirectLinkDialog = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Prevent creating placeholder direct template recipient if the email already exists. */}
|
{/* Prevent creating placeholder direct template recipient if the email already exists. */}
|
||||||
{!template.recipients.some(
|
{!recipients.some(
|
||||||
(recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL,
|
(recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL,
|
||||||
) && (
|
) && (
|
||||||
<DialogFooter className="mx-auto">
|
<DialogFooter className="mx-auto">
|
||||||
@ -348,7 +361,7 @@ export const TemplateDirectLinkDialog = ({
|
|||||||
loading={isCreatingTemplateDirectLink && !selectedRecipientId}
|
loading={isCreatingTemplateDirectLink && !selectedRecipientId}
|
||||||
onClick={async () =>
|
onClick={async () =>
|
||||||
createTemplateDirectLink({
|
createTemplateDirectLink({
|
||||||
templateId: template.id,
|
templateId,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@ -439,11 +452,11 @@ export const TemplateDirectLinkDialog = ({
|
|||||||
loading={isTogglingTemplateAccess}
|
loading={isTogglingTemplateAccess}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await toggleTemplateDirectLink({
|
await toggleTemplateDirectLink({
|
||||||
templateId: template.id,
|
templateId,
|
||||||
enabled: isEnabled,
|
enabled: isEnabled,
|
||||||
}).catch(() => null);
|
}).catch(() => null);
|
||||||
|
|
||||||
onOpenChange(false);
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trans>Save</Trans>
|
<Trans>Save</Trans>
|
||||||
@ -479,7 +492,7 @@ export const TemplateDirectLinkDialog = ({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
loading={isDeletingTemplateDirectLink}
|
loading={isDeletingTemplateDirectLink}
|
||||||
onClick={() => void deleteTemplateDirectLink({ templateId: template.id })}
|
onClick={() => void deleteTemplateDirectLink({ templateId })}
|
||||||
>
|
>
|
||||||
<Trans>Confirm</Trans>
|
<Trans>Confirm</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
@ -489,6 +502,7 @@ export const TemplateDirectLinkDialog = ({
|
|||||||
.otherwise(() => null)}
|
.otherwise(() => null)}
|
||||||
</AnimateGenericFadeInOut>
|
</AnimateGenericFadeInOut>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -73,7 +73,7 @@ export function TemplateMoveToFolderDialog({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFolders.useQuery(
|
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery(
|
||||||
{
|
{
|
||||||
parentId: currentFolderId ?? null,
|
parentId: currentFolderId ?? null,
|
||||||
type: FolderType.TEMPLATE,
|
type: FolderType.TEMPLATE,
|
||||||
@ -83,7 +83,7 @@ export function TemplateMoveToFolderDialog({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { mutateAsync: moveTemplateToFolder } = trpc.folder.moveTemplateToFolder.useMutation();
|
const { mutateAsync: updateTemplate } = trpc.template.updateTemplate.useMutation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
@ -96,9 +96,11 @@ export function TemplateMoveToFolderDialog({
|
|||||||
|
|
||||||
const onSubmit = async (data: TMoveTemplateFormSchema) => {
|
const onSubmit = async (data: TMoveTemplateFormSchema) => {
|
||||||
try {
|
try {
|
||||||
await moveTemplateToFolder({
|
await updateTemplate({
|
||||||
templateId,
|
templateId,
|
||||||
|
data: {
|
||||||
folderId: data.folderId ?? null,
|
folderId: data.folderId ?? null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
|
|||||||
@ -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';
|
||||||
@ -15,8 +15,11 @@ import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
|||||||
import {
|
import {
|
||||||
TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
|
TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
|
||||||
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
|
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
|
||||||
isTemplateRecipientEmailPlaceholder,
|
|
||||||
} 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';
|
||||||
@ -42,17 +45,22 @@ 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';
|
||||||
|
|
||||||
const ZAddRecipientsForNewDocumentSchema = z
|
const ZAddRecipientsForNewDocumentSchema = z.object({
|
||||||
.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({
|
||||||
@ -62,38 +70,12 @@ const ZAddRecipientsForNewDocumentSchema = z
|
|||||||
signingOrder: z.number().optional(),
|
signingOrder: z.number().optional(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
})
|
});
|
||||||
// Display exactly which rows are duplicates.
|
|
||||||
.superRefine((items, ctx) => {
|
|
||||||
const uniqueEmails = new Map<string, number>();
|
|
||||||
|
|
||||||
for (const [index, recipients] of items.recipients.entries()) {
|
|
||||||
const email = recipients.email.toLowerCase();
|
|
||||||
|
|
||||||
const firstFoundIndex = uniqueEmails.get(email);
|
|
||||||
|
|
||||||
if (firstFoundIndex === undefined) {
|
|
||||||
uniqueEmails.set(email, index);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: 'Emails must be unique',
|
|
||||||
path: ['recipients', index, 'email'],
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: 'Emails must be unique',
|
|
||||||
path: ['recipients', firstFoundIndex, 'email'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
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[];
|
||||||
@ -106,6 +88,7 @@ export function TemplateUseDialog({
|
|||||||
recipients,
|
recipients,
|
||||||
documentDistributionMethod = DocumentDistributionMethod.EMAIL,
|
documentDistributionMethod = DocumentDistributionMethod.EMAIL,
|
||||||
documentRootPath,
|
documentRootPath,
|
||||||
|
envelopeId,
|
||||||
templateId,
|
templateId,
|
||||||
templateSigningOrder,
|
templateSigningOrder,
|
||||||
trigger,
|
trigger,
|
||||||
@ -122,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) => {
|
||||||
@ -144,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?.data ?? [];
|
||||||
|
|
||||||
const { mutateAsync: createDocumentFromTemplate } =
|
const { mutateAsync: createDocumentFromTemplate } =
|
||||||
trpc.template.createDocumentFromTemplate.useMutation();
|
trpc.template.createDocumentFromTemplate.useMutation();
|
||||||
|
|
||||||
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
|
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
|
||||||
try {
|
try {
|
||||||
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({
|
||||||
@ -169,7 +179,7 @@ export function TemplateUseDialog({
|
|||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
let documentPath = `${documentRootPath}/${id}`;
|
let documentPath = `${documentRootPath}/${envelopeId}`;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
data.distributeDocument &&
|
data.distributeDocument &&
|
||||||
@ -209,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>
|
||||||
@ -278,14 +300,7 @@ export function TemplateUseDialog({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input {...field} aria-label="Email" placeholder={_(msg`Email`)} />
|
||||||
{...field}
|
|
||||||
placeholder={
|
|
||||||
isTemplateRecipientEmailPlaceholder(field.value)
|
|
||||||
? ''
|
|
||||||
: _(msg`Email`)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@ -306,6 +321,7 @@ export function TemplateUseDialog({
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
{...field}
|
{...field}
|
||||||
|
aria-label="Name"
|
||||||
placeholder={recipients[index].name || _(msg`Name`)}
|
placeholder={recipients[index].name || _(msg`Name`)}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@ -440,57 +456,88 @@ export function TemplateUseDialog({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{form.watch('useCustomDocument') && (
|
{form.watch('useCustomDocument') && (
|
||||||
<div className="my-4">
|
<div className="my-4 space-y-2">
|
||||||
|
{isLoadingEnvelopeItems ? (
|
||||||
|
<SpinnerBox className="py-16" />
|
||||||
|
) : (
|
||||||
|
localCustomDocumentData.map((item, i) => (
|
||||||
<FormField
|
<FormField
|
||||||
|
key={item.id}
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="customDocumentData"
|
name={`customDocumentData.${i}.data`}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="w-full space-y-4">
|
<div
|
||||||
<label
|
key={item.id}
|
||||||
className={cn(
|
className="border-border bg-card hover:bg-accent/10 flex items-center gap-4 rounded-lg border p-4 transition-colors"
|
||||||
'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',
|
|
||||||
{
|
|
||||||
'border-destructive hover:border-destructive':
|
|
||||||
form.formState.errors.customDocumentData,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<div className="text-center">
|
<div className="flex-shrink-0">
|
||||||
{!field.value && (
|
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg">
|
||||||
<>
|
<FileTextIcon className="text-primary h-5 w-5" />
|
||||||
<Upload className="text-muted-foreground/50 mx-auto h-10 w-10" />
|
</div>
|
||||||
<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>
|
</div>
|
||||||
<p className="text-muted-foreground/80 text-xs">
|
|
||||||
PDF files only
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{field.value && (
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-muted-foreground space-y-1">
|
<h4 className="text-foreground truncate text-sm font-medium">
|
||||||
<p className="text-sm font-medium">{field.value.name}</p>
|
{item.title}
|
||||||
<p className="text-muted-foreground/60 text-xs">
|
</h4>
|
||||||
{(field.value.size / (1024 * 1024)).toFixed(2)} MB
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
<div className="flex flex-shrink-0 items-center gap-2">
|
||||||
|
{field.value ? (
|
||||||
|
<div className="">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
field.onChange(undefined);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Remove</Trans>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<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
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
data-testid="template-use-dialog-file-input"
|
id={`template-use-dialog-file-input-${item.envelopeItemId}`}
|
||||||
className="absolute h-full w-full opacity-0"
|
className="hidden"
|
||||||
accept=".pdf,application/pdf"
|
accept=".pdf,application/pdf"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
@ -510,7 +557,10 @@ export function TemplateUseDialog({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file.size > APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024) {
|
if (
|
||||||
|
file.size >
|
||||||
|
APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024
|
||||||
|
) {
|
||||||
form.setError('customDocumentData', {
|
form.setError('customDocumentData', {
|
||||||
type: 'manual',
|
type: 'manual',
|
||||||
message: _(
|
message: _(
|
||||||
@ -524,32 +574,15 @@ export function TemplateUseDialog({
|
|||||||
field.onChange(file);
|
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>
|
</div>
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
||||||
import { DocumentDistributionMethod } from '@documenso/prisma/generated/types';
|
|
||||||
import {
|
import {
|
||||||
ZDocumentMetaDateFormatSchema,
|
ZDocumentMetaDateFormatSchema,
|
||||||
ZDocumentMetaLanguageSchema,
|
ZDocumentMetaLanguageSchema,
|
||||||
} from '@documenso/trpc/server/document-router/schema';
|
} from '@documenso/lib/types/document-meta';
|
||||||
|
import { DocumentDistributionMethod } from '@documenso/prisma/generated/types';
|
||||||
|
|
||||||
// Define the schema for configuration
|
// Define the schema for configuration
|
||||||
export type TConfigureEmbedFormSchema = z.infer<typeof ZConfigureEmbedFormSchema>;
|
export type TConfigureEmbedFormSchema = z.infer<typeof ZConfigureEmbedFormSchema>;
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { useLingui } from '@lingui/react';
|
|||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { DocumentData, FieldType } from '@prisma/client';
|
import type { DocumentData, FieldType } from '@prisma/client';
|
||||||
import { ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client';
|
import { ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client';
|
||||||
|
import { base64 } from '@scure/base';
|
||||||
import { ChevronsUpDown } from 'lucide-react';
|
import { ChevronsUpDown } from 'lucide-react';
|
||||||
import { useFieldArray, useForm } from 'react-hook-form';
|
import { useFieldArray, useForm } from 'react-hook-form';
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
@ -12,7 +13,6 @@ import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-c
|
|||||||
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
|
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
import { type TFieldMetaSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
import { type TFieldMetaSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||||
import { base64 } from '@documenso/lib/universal/base64';
|
|
||||||
import { nanoid } from '@documenso/lib/universal/id';
|
import { nanoid } from '@documenso/lib/universal/id';
|
||||||
import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers';
|
import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
import { useRecipientColors } from '@documenso/ui/lib/recipient-colors';
|
import { useRecipientColors } from '@documenso/ui/lib/recipient-colors';
|
||||||
@ -83,21 +83,14 @@ export const ConfigureFieldsView = ({
|
|||||||
|
|
||||||
const normalizedDocumentData = useMemo(() => {
|
const normalizedDocumentData = useMemo(() => {
|
||||||
if (documentData) {
|
if (documentData) {
|
||||||
return documentData;
|
return documentData.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!configData.documentData) {
|
if (!configData.documentData) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = base64.encode(configData.documentData?.data);
|
return base64.encode(configData.documentData.data);
|
||||||
|
|
||||||
return {
|
|
||||||
id: 'preview',
|
|
||||||
type: 'BYTES_64',
|
|
||||||
data,
|
|
||||||
initialData: data,
|
|
||||||
} satisfies DocumentData;
|
|
||||||
}, [configData.documentData]);
|
}, [configData.documentData]);
|
||||||
|
|
||||||
const recipients = useMemo(() => {
|
const recipients = useMemo(() => {
|
||||||
@ -118,6 +111,7 @@ export const ConfigureFieldsView = ({
|
|||||||
sendStatus: signer.disabled ? SendStatus.SENT : SendStatus.NOT_SENT,
|
sendStatus: signer.disabled ? SendStatus.SENT : SendStatus.NOT_SENT,
|
||||||
readStatus: signer.disabled ? ReadStatus.OPENED : ReadStatus.NOT_OPENED,
|
readStatus: signer.disabled ? ReadStatus.OPENED : ReadStatus.NOT_OPENED,
|
||||||
signingStatus: signer.disabled ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
|
signingStatus: signer.disabled ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
|
||||||
|
envelopeId: '',
|
||||||
}));
|
}));
|
||||||
}, [configData.signers]);
|
}, [configData.signers]);
|
||||||
|
|
||||||
@ -540,7 +534,15 @@ export const ConfigureFieldsView = ({
|
|||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
{normalizedDocumentData && (
|
{normalizedDocumentData && (
|
||||||
<div>
|
<div>
|
||||||
<PDFViewer documentData={normalizedDocumentData} />
|
<PDFViewer
|
||||||
|
overrideData={normalizedDocumentData}
|
||||||
|
envelopeItem={{
|
||||||
|
id: '',
|
||||||
|
envelopeId: '',
|
||||||
|
}}
|
||||||
|
token={undefined}
|
||||||
|
version="signed"
|
||||||
|
/>
|
||||||
|
|
||||||
<ElementVisible
|
<ElementVisible
|
||||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
|
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ 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;
|
||||||
};
|
};
|
||||||
@ -17,6 +18,7 @@ export const EmbedAuthenticationRequired = ({
|
|||||||
email,
|
email,
|
||||||
returnTo,
|
returnTo,
|
||||||
// isGoogleSSOEnabled,
|
// isGoogleSSOEnabled,
|
||||||
|
// isMicrosoftSSOEnabled,
|
||||||
// isOIDCSSOEnabled,
|
// isOIDCSSOEnabled,
|
||||||
// oidcProviderLabel,
|
// oidcProviderLabel,
|
||||||
}: EmbedAuthenticationRequiredProps) => {
|
}: EmbedAuthenticationRequiredProps) => {
|
||||||
@ -37,6 +39,7 @@ 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"
|
||||||
|
|||||||
@ -3,8 +3,8 @@ import { useEffect, useLayoutEffect, useState } from 'react';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { DocumentMeta, Recipient, Signature, TemplateMeta } from '@prisma/client';
|
import type { DocumentMeta, EnvelopeItem, Recipient, Signature } from '@prisma/client';
|
||||||
import { type DocumentData, type Field, FieldType } from '@prisma/client';
|
import { type Field, FieldType } from '@prisma/client';
|
||||||
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { useSearchParams } from 'react-router';
|
import { useSearchParams } from 'react-router';
|
||||||
@ -37,6 +37,7 @@ import { ZDirectTemplateEmbedDataSchema } from '~/types/embed-direct-template-sc
|
|||||||
import { injectCss } from '~/utils/css-vars';
|
import { injectCss } from '~/utils/css-vars';
|
||||||
|
|
||||||
import type { DirectTemplateLocalField } from '../general/direct-template/direct-template-signing-form';
|
import type { DirectTemplateLocalField } from '../general/direct-template/direct-template-signing-form';
|
||||||
|
import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover';
|
||||||
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
|
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
|
||||||
import { EmbedClientLoading } from './embed-client-loading';
|
import { EmbedClientLoading } from './embed-client-loading';
|
||||||
import { EmbedDocumentCompleted } from './embed-document-completed';
|
import { EmbedDocumentCompleted } from './embed-document-completed';
|
||||||
@ -44,20 +45,22 @@ import { EmbedDocumentFields } from './embed-document-fields';
|
|||||||
|
|
||||||
export type EmbedDirectTemplateClientPageProps = {
|
export type EmbedDirectTemplateClientPageProps = {
|
||||||
token: string;
|
token: string;
|
||||||
|
envelopeId: string;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
documentData: DocumentData;
|
envelopeItems: Pick<EnvelopeItem, 'id' | 'envelopeId'>[];
|
||||||
recipient: Recipient;
|
recipient: Recipient;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
metadata?: DocumentMeta | TemplateMeta | null;
|
metadata?: DocumentMeta | null;
|
||||||
hidePoweredBy?: boolean;
|
hidePoweredBy?: boolean;
|
||||||
allowWhiteLabelling?: boolean;
|
allowWhiteLabelling?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EmbedDirectTemplateClientPage = ({
|
export const EmbedDirectTemplateClientPage = ({
|
||||||
token,
|
token,
|
||||||
|
envelopeId,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
documentData,
|
envelopeItems,
|
||||||
recipient: _recipient,
|
recipient,
|
||||||
fields,
|
fields,
|
||||||
metadata,
|
metadata,
|
||||||
hidePoweredBy = false,
|
hidePoweredBy = false,
|
||||||
@ -321,14 +324,20 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
<div className="embed--Root relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||||
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
|
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
|
||||||
|
|
||||||
|
<div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between">
|
||||||
|
<DocumentSigningAttachmentsPopover envelopeId={envelopeId} token={recipient.token} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
<div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
||||||
{/* Viewer */}
|
{/* Viewer */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<PDFViewer
|
<PDFViewer
|
||||||
documentData={documentData}
|
envelopeItem={envelopeItems[0]}
|
||||||
|
token={token}
|
||||||
|
version="signed"
|
||||||
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import type { DocumentMeta, TemplateMeta } from '@prisma/client';
|
import type { DocumentMeta } from '@prisma/client';
|
||||||
import { type Field, FieldType } from '@prisma/client';
|
import { type Field, FieldType } from '@prisma/client';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
@ -33,7 +33,7 @@ import { DocumentSigningTextField } from '~/components/general/document-signing/
|
|||||||
export type EmbedDocumentFieldsProps = {
|
export type EmbedDocumentFieldsProps = {
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
metadata?: Pick<
|
metadata?: Pick<
|
||||||
DocumentMeta | TemplateMeta,
|
DocumentMeta,
|
||||||
| 'timezone'
|
| 'timezone'
|
||||||
| 'dateFormat'
|
| 'dateFormat'
|
||||||
| 'typedSignatureEnabled'
|
| 'typedSignatureEnabled'
|
||||||
|
|||||||
@ -3,24 +3,20 @@ import { useEffect, useId, useLayoutEffect, useMemo, useState } from 'react';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { DocumentMeta, TemplateMeta } from '@prisma/client';
|
import type { DocumentMeta, EnvelopeItem } from '@prisma/client';
|
||||||
import {
|
import { type Field, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
|
||||||
type DocumentData,
|
|
||||||
type Field,
|
|
||||||
FieldType,
|
|
||||||
RecipientRole,
|
|
||||||
SigningStatus,
|
|
||||||
} from '@prisma/client';
|
|
||||||
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
||||||
|
|
||||||
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
import type { DocumentField } from '@documenso/lib/server-only/field/get-fields-for-document';
|
|
||||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
|
import {
|
||||||
|
type DocumentField,
|
||||||
|
DocumentReadOnlyFields,
|
||||||
|
} from '@documenso/ui/components/document/document-read-only-fields';
|
||||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
@ -35,6 +31,7 @@ import { BrandingLogo } from '~/components/general/branding-logo';
|
|||||||
import { injectCss } from '~/utils/css-vars';
|
import { injectCss } from '~/utils/css-vars';
|
||||||
|
|
||||||
import { ZSignDocumentEmbedDataSchema } from '../../types/embed-document-sign-schema';
|
import { ZSignDocumentEmbedDataSchema } from '../../types/embed-document-sign-schema';
|
||||||
|
import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover';
|
||||||
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
|
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
|
||||||
import { DocumentSigningRecipientProvider } from '../general/document-signing/document-signing-recipient-provider';
|
import { DocumentSigningRecipientProvider } from '../general/document-signing/document-signing-recipient-provider';
|
||||||
import { DocumentSigningRejectDialog } from '../general/document-signing/document-signing-reject-dialog';
|
import { DocumentSigningRejectDialog } from '../general/document-signing/document-signing-reject-dialog';
|
||||||
@ -43,24 +40,26 @@ import { EmbedDocumentCompleted } from './embed-document-completed';
|
|||||||
import { EmbedDocumentFields } from './embed-document-fields';
|
import { EmbedDocumentFields } from './embed-document-fields';
|
||||||
import { EmbedDocumentRejected } from './embed-document-rejected';
|
import { EmbedDocumentRejected } from './embed-document-rejected';
|
||||||
|
|
||||||
export type EmbedSignDocumentClientPageProps = {
|
export type EmbedSignDocumentV1ClientPageProps = {
|
||||||
token: string;
|
token: string;
|
||||||
documentId: number;
|
documentId: number;
|
||||||
documentData: DocumentData;
|
envelopeId: string;
|
||||||
|
envelopeItems: Pick<EnvelopeItem, 'id' | 'envelopeId'>[];
|
||||||
recipient: RecipientWithFields;
|
recipient: RecipientWithFields;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
completedFields: DocumentField[];
|
completedFields: DocumentField[];
|
||||||
metadata?: DocumentMeta | TemplateMeta | null;
|
metadata?: DocumentMeta | null;
|
||||||
isCompleted?: boolean;
|
isCompleted?: boolean;
|
||||||
hidePoweredBy?: boolean;
|
hidePoweredBy?: boolean;
|
||||||
allowWhitelabelling?: boolean;
|
allowWhitelabelling?: boolean;
|
||||||
allRecipients?: RecipientWithFields[];
|
allRecipients?: RecipientWithFields[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EmbedSignDocumentClientPage = ({
|
export const EmbedSignDocumentV1ClientPage = ({
|
||||||
token,
|
token,
|
||||||
documentId,
|
documentId,
|
||||||
documentData,
|
envelopeId,
|
||||||
|
envelopeItems,
|
||||||
recipient,
|
recipient,
|
||||||
fields,
|
fields,
|
||||||
completedFields,
|
completedFields,
|
||||||
@ -69,7 +68,7 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
hidePoweredBy = false,
|
hidePoweredBy = false,
|
||||||
allowWhitelabelling = false,
|
allowWhitelabelling = false,
|
||||||
allRecipients = [],
|
allRecipients = [],
|
||||||
}: EmbedSignDocumentClientPageProps) => {
|
}: EmbedSignDocumentV1ClientPageProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@ -272,21 +271,25 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
<div className="embed--Root relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
<div className="embed--Root relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||||
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
|
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
|
||||||
|
|
||||||
{allowDocumentRejection && (
|
|
||||||
<div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between">
|
<div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between">
|
||||||
|
<DocumentSigningAttachmentsPopover envelopeId={envelopeId} token={token} />
|
||||||
|
|
||||||
|
{allowDocumentRejection && (
|
||||||
<DocumentSigningRejectDialog
|
<DocumentSigningRejectDialog
|
||||||
document={{ id: documentId }}
|
documentId={documentId}
|
||||||
token={token}
|
token={token}
|
||||||
onRejected={onDocumentRejected}
|
onRejected={onDocumentRejected}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
<div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
||||||
{/* Viewer */}
|
{/* Viewer */}
|
||||||
<div className="embed--DocumentViewer flex-1">
|
<div className="embed--DocumentViewer flex-1">
|
||||||
<PDFViewer
|
<PDFViewer
|
||||||
documentData={documentData}
|
envelopeItem={envelopeItems[0]}
|
||||||
|
token={token}
|
||||||
|
version="signed"
|
||||||
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -0,0 +1,232 @@
|
|||||||
|
import { useEffect, useLayoutEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
|
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||||
|
|
||||||
|
import { ZSignDocumentEmbedDataSchema } from '~/types/embed-document-sign-schema';
|
||||||
|
import { injectCss } from '~/utils/css-vars';
|
||||||
|
|
||||||
|
import { DocumentSigningPageViewV2 } from '../general/document-signing/document-signing-page-view-v2';
|
||||||
|
import { useRequiredEnvelopeSigningContext } from '../general/document-signing/envelope-signing-provider';
|
||||||
|
import { EmbedClientLoading } from './embed-client-loading';
|
||||||
|
import { EmbedDocumentCompleted } from './embed-document-completed';
|
||||||
|
import { EmbedDocumentRejected } from './embed-document-rejected';
|
||||||
|
import { EmbedSigningProvider } from './embed-signing-context';
|
||||||
|
|
||||||
|
export type EmbedSignDocumentV2ClientPageProps = {
|
||||||
|
hidePoweredBy?: boolean;
|
||||||
|
allowWhitelabelling?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EmbedSignDocumentV2ClientPage = ({
|
||||||
|
hidePoweredBy = false,
|
||||||
|
allowWhitelabelling = false,
|
||||||
|
}: EmbedSignDocumentV2ClientPageProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
const { envelope, recipient, envelopeData, setFullName, fullName } =
|
||||||
|
useRequiredEnvelopeSigningContext();
|
||||||
|
|
||||||
|
const { isCompleted, isRejected, recipientSignature } = envelopeData;
|
||||||
|
|
||||||
|
// !: Not used at the moment, may be removed in the future.
|
||||||
|
// const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
|
||||||
|
const [hasFinishedInit, setHasFinishedInit] = useState(false);
|
||||||
|
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
|
||||||
|
const [isNameLocked, setIsNameLocked] = useState(false);
|
||||||
|
|
||||||
|
const onDocumentCompleted = (data: {
|
||||||
|
token: string;
|
||||||
|
documentId: number;
|
||||||
|
envelopeId: string;
|
||||||
|
recipientId: number;
|
||||||
|
}) => {
|
||||||
|
if (window.parent) {
|
||||||
|
window.parent.postMessage(
|
||||||
|
{
|
||||||
|
action: 'document-completed',
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
'*',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDocumentError = () => {
|
||||||
|
if (window.parent) {
|
||||||
|
window.parent.postMessage(
|
||||||
|
{
|
||||||
|
action: 'document-error',
|
||||||
|
data: null,
|
||||||
|
},
|
||||||
|
'*',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDocumentReady = () => {
|
||||||
|
if (window.parent) {
|
||||||
|
window.parent.postMessage(
|
||||||
|
{
|
||||||
|
action: 'document-ready',
|
||||||
|
data: null,
|
||||||
|
},
|
||||||
|
'*',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFieldSigned = (data: { fieldId?: number; value?: string; isBase64?: boolean }) => {
|
||||||
|
if (window.parent) {
|
||||||
|
window.parent.postMessage(
|
||||||
|
{
|
||||||
|
action: 'field-signed',
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
'*',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFieldUnsigned = (data: { fieldId?: number }) => {
|
||||||
|
if (window.parent) {
|
||||||
|
window.parent.postMessage(
|
||||||
|
{
|
||||||
|
action: 'field-unsigned',
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
'*',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDocumentRejected = (data: {
|
||||||
|
token: string;
|
||||||
|
documentId: number;
|
||||||
|
envelopeId: string;
|
||||||
|
recipientId: number;
|
||||||
|
reason?: string;
|
||||||
|
}) => {
|
||||||
|
if (window.parent) {
|
||||||
|
window.parent.postMessage(
|
||||||
|
{
|
||||||
|
action: 'document-rejected',
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
'*',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const hash = window.location.hash.slice(1);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = ZSignDocumentEmbedDataSchema.parse(JSON.parse(decodeURIComponent(atob(hash))));
|
||||||
|
|
||||||
|
if (!isCompleted && data.name) {
|
||||||
|
setFullName(data.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Since a recipient can be provided a name we can lock it without requiring
|
||||||
|
// a to be provided by the parent application, unlike direct templates.
|
||||||
|
setIsNameLocked(!!data.lockName);
|
||||||
|
setAllowDocumentRejection(!!data.allowDocumentRejection);
|
||||||
|
|
||||||
|
if (data.darkModeDisabled) {
|
||||||
|
document.documentElement.classList.add('dark-mode-disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowWhitelabelling) {
|
||||||
|
injectCss({
|
||||||
|
css: data.css,
|
||||||
|
cssVars: data.cssVars,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
setHasFinishedInit(true);
|
||||||
|
|
||||||
|
// !: While the setters are stable we still want to ensure we're avoiding
|
||||||
|
// !: re-renders.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [allowWhitelabelling]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasFinishedInit) {
|
||||||
|
onDocumentReady();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [hasFinishedInit]);
|
||||||
|
|
||||||
|
// Listen for document completion events from the envelope signing context
|
||||||
|
useEffect(() => {
|
||||||
|
if (isCompleted) {
|
||||||
|
onDocumentCompleted({
|
||||||
|
token: recipient.token,
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||||
|
recipientId: recipient.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isCompleted, envelope.id, recipient.id, recipient.token]);
|
||||||
|
|
||||||
|
// Listen for document rejection events
|
||||||
|
useEffect(() => {
|
||||||
|
if (isRejected) {
|
||||||
|
onDocumentRejected({
|
||||||
|
token: recipient.token,
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||||
|
recipientId: recipient.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isRejected, envelope.id, recipient.id, recipient.token]);
|
||||||
|
|
||||||
|
if (isRejected) {
|
||||||
|
return <EmbedDocumentRejected />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCompleted) {
|
||||||
|
return (
|
||||||
|
<EmbedDocumentCompleted
|
||||||
|
name={fullName}
|
||||||
|
signature={
|
||||||
|
recipientSignature
|
||||||
|
? {
|
||||||
|
id: 1,
|
||||||
|
fieldId: 1,
|
||||||
|
recipientId: recipient.id,
|
||||||
|
created: new Date(),
|
||||||
|
signatureImageAsBase64: recipientSignature.signatureImageAsBase64,
|
||||||
|
typedSignature: recipientSignature.typedSignature,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EmbedSigningProvider
|
||||||
|
isNameLocked={isNameLocked}
|
||||||
|
hidePoweredBy={hidePoweredBy}
|
||||||
|
allowDocumentRejection={allowDocumentRejection}
|
||||||
|
onDocumentCompleted={onDocumentCompleted}
|
||||||
|
onDocumentError={onDocumentError}
|
||||||
|
onDocumentRejected={onDocumentRejected}
|
||||||
|
onDocumentReady={onDocumentReady}
|
||||||
|
onFieldSigned={onFieldSigned}
|
||||||
|
onFieldUnsigned={onFieldUnsigned}
|
||||||
|
>
|
||||||
|
<div className="embed--Root relative">
|
||||||
|
{!hasFinishedInit && <EmbedClientLoading />}
|
||||||
|
|
||||||
|
<DocumentSigningPageViewV2 />
|
||||||
|
</div>
|
||||||
|
</EmbedSigningProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
101
apps/remix/app/components/embed/embed-signing-context.tsx
Normal file
101
apps/remix/app/components/embed/embed-signing-context.tsx
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
|
||||||
|
export type EmbedSigningContextValue = {
|
||||||
|
isEmbed: true;
|
||||||
|
allowDocumentRejection: boolean;
|
||||||
|
isNameLocked: boolean;
|
||||||
|
isEmailLocked: boolean;
|
||||||
|
hidePoweredBy: boolean;
|
||||||
|
onDocumentCompleted: (data: {
|
||||||
|
token: string;
|
||||||
|
documentId: number;
|
||||||
|
envelopeId: string;
|
||||||
|
recipientId: number;
|
||||||
|
}) => void;
|
||||||
|
onDocumentError: () => void;
|
||||||
|
onDocumentRejected: (data: {
|
||||||
|
token: string;
|
||||||
|
documentId: number;
|
||||||
|
envelopeId: string;
|
||||||
|
recipientId: number;
|
||||||
|
reason?: string;
|
||||||
|
}) => void;
|
||||||
|
onDocumentReady: () => void;
|
||||||
|
onFieldSigned: (data: { fieldId?: number; value?: string; isBase64?: boolean }) => void;
|
||||||
|
onFieldUnsigned: (data: { fieldId?: number }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EmbedSigningContext = createContext<EmbedSigningContextValue | null>(null);
|
||||||
|
|
||||||
|
export const useEmbedSigningContext = () => {
|
||||||
|
return useContext(EmbedSigningContext);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRequiredEmbedSigningContext = () => {
|
||||||
|
const context = useEmbedSigningContext();
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useRequiredEmbedSigningContext must be used within EmbedSigningProvider');
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EmbedSigningProviderProps = {
|
||||||
|
allowDocumentRejection?: boolean;
|
||||||
|
isNameLocked?: boolean;
|
||||||
|
isEmailLocked?: boolean;
|
||||||
|
hidePoweredBy?: boolean;
|
||||||
|
onDocumentCompleted: (data: {
|
||||||
|
token: string;
|
||||||
|
documentId: number;
|
||||||
|
envelopeId: string;
|
||||||
|
recipientId: number;
|
||||||
|
}) => void;
|
||||||
|
onDocumentError: () => void;
|
||||||
|
onDocumentRejected: (data: {
|
||||||
|
token: string;
|
||||||
|
documentId: number;
|
||||||
|
envelopeId: string;
|
||||||
|
recipientId: number;
|
||||||
|
reason?: string;
|
||||||
|
}) => void;
|
||||||
|
onDocumentReady: () => void;
|
||||||
|
onFieldSigned: (data: { fieldId?: number; value?: string; isBase64?: boolean }) => void;
|
||||||
|
onFieldUnsigned: (data: { fieldId?: number }) => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EmbedSigningProvider = ({
|
||||||
|
allowDocumentRejection = false,
|
||||||
|
isNameLocked = false,
|
||||||
|
isEmailLocked = true,
|
||||||
|
hidePoweredBy = false,
|
||||||
|
onDocumentCompleted,
|
||||||
|
onDocumentError,
|
||||||
|
onDocumentRejected,
|
||||||
|
onDocumentReady,
|
||||||
|
onFieldSigned,
|
||||||
|
onFieldUnsigned,
|
||||||
|
children,
|
||||||
|
}: EmbedSigningProviderProps) => {
|
||||||
|
return (
|
||||||
|
<EmbedSigningContext.Provider
|
||||||
|
value={{
|
||||||
|
isEmbed: true,
|
||||||
|
allowDocumentRejection,
|
||||||
|
isNameLocked,
|
||||||
|
isEmailLocked,
|
||||||
|
hidePoweredBy,
|
||||||
|
onDocumentCompleted,
|
||||||
|
onDocumentError,
|
||||||
|
onDocumentRejected,
|
||||||
|
onDocumentReady,
|
||||||
|
onFieldSigned,
|
||||||
|
onFieldUnsigned,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</EmbedSigningContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -212,7 +212,7 @@ export const MultiSignDocumentSigningView = ({
|
|||||||
{allowDocumentRejection && (
|
{allowDocumentRejection && (
|
||||||
<div className="embed--Actions mb-4 mt-8 flex w-full flex-row-reverse items-baseline justify-between">
|
<div className="embed--Actions mb-4 mt-8 flex w-full flex-row-reverse items-baseline justify-between">
|
||||||
<DocumentSigningRejectDialog
|
<DocumentSigningRejectDialog
|
||||||
document={document}
|
documentId={document.id}
|
||||||
token={token}
|
token={token}
|
||||||
onRejected={onRejected}
|
onRejected={onRejected}
|
||||||
/>
|
/>
|
||||||
@ -226,7 +226,9 @@ export const MultiSignDocumentSigningView = ({
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<PDFViewer
|
<PDFViewer
|
||||||
documentData={document.documentData}
|
envelopeItem={document.envelopeItems[0]}
|
||||||
|
token={token}
|
||||||
|
version="signed"
|
||||||
onDocumentLoad={() => {
|
onDocumentLoad={() => {
|
||||||
setHasDocumentLoaded(true);
|
setHasDocumentLoaded(true);
|
||||||
onDocumentReady?.();
|
onDocumentReady?.();
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import type { TeamGlobalSettings } from '@prisma/client';
|
import type { TeamGlobalSettings } from '@prisma/client';
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@ -29,6 +29,8 @@ import {
|
|||||||
} from '@documenso/ui/primitives/select';
|
} from '@documenso/ui/primitives/select';
|
||||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||||
|
|
||||||
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||||
const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
|
const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
|
||||||
|
|
||||||
@ -68,6 +70,9 @@ export function BrandingPreferencesForm({
|
|||||||
}: BrandingPreferencesFormProps) {
|
}: BrandingPreferencesFormProps) {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
const team = useOptionalCurrentTeam();
|
||||||
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
const [previewUrl, setPreviewUrl] = useState<string>('');
|
const [previewUrl, setPreviewUrl] = useState<string>('');
|
||||||
const [hasLoadedPreview, setHasLoadedPreview] = useState(false);
|
const [hasLoadedPreview, setHasLoadedPreview] = useState(false);
|
||||||
|
|
||||||
@ -88,14 +93,13 @@ export function BrandingPreferencesForm({
|
|||||||
const file = JSON.parse(settings.brandingLogo);
|
const file = JSON.parse(settings.brandingLogo);
|
||||||
|
|
||||||
if ('type' in file && 'data' in file) {
|
if ('type' in file && 'data' in file) {
|
||||||
void getFile(file).then((binaryData) => {
|
const logoUrl =
|
||||||
const objectUrl = URL.createObjectURL(new Blob([binaryData]));
|
context === 'Team'
|
||||||
|
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/team/${team?.id}`
|
||||||
|
: `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/organisation/${organisation?.id}`;
|
||||||
|
|
||||||
setPreviewUrl(objectUrl);
|
setPreviewUrl(logoUrl);
|
||||||
setHasLoadedPreview(true);
|
setHasLoadedPreview(true);
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,10 +3,11 @@ 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 type { TeamGlobalSettings } from '@prisma/client';
|
import type { TeamGlobalSettings } from '@prisma/client';
|
||||||
import { DocumentVisibility } from '@prisma/client';
|
import { DocumentVisibility, OrganisationType } from '@prisma/client';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { DATE_FORMATS } from '@documenso/lib/constants/date-formats';
|
import { DATE_FORMATS } from '@documenso/lib/constants/date-formats';
|
||||||
import { DOCUMENT_SIGNATURE_TYPES, DocumentSignatureType } from '@documenso/lib/constants/document';
|
import { DOCUMENT_SIGNATURE_TYPES, DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||||
@ -16,12 +17,12 @@ import {
|
|||||||
isValidLanguageCode,
|
isValidLanguageCode,
|
||||||
} from '@documenso/lib/constants/i18n';
|
} from '@documenso/lib/constants/i18n';
|
||||||
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
|
||||||
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
|
|
||||||
import {
|
import {
|
||||||
type TDocumentMetaDateFormat,
|
type TDocumentMetaDateFormat,
|
||||||
ZDocumentMetaTimezoneSchema,
|
ZDocumentMetaTimezoneSchema,
|
||||||
} from '@documenso/trpc/server/document-router/schema';
|
} from '@documenso/lib/types/document-meta';
|
||||||
|
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||||
|
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
|
||||||
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
|
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
|
||||||
import { Alert } from '@documenso/ui/primitives/alert';
|
import { Alert } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -86,8 +87,10 @@ export const DocumentPreferencesForm = ({
|
|||||||
}: DocumentPreferencesFormProps) => {
|
}: DocumentPreferencesFormProps) => {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const { user, organisations } = useSession();
|
const { user, organisations } = useSession();
|
||||||
|
const currentOrganisation = useCurrentOrganisation();
|
||||||
|
|
||||||
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
||||||
|
const isPersonalOrganisation = currentOrganisation.type === OrganisationType.PERSONAL;
|
||||||
|
|
||||||
const placeholderEmail = user.email ?? 'user@example.com';
|
const placeholderEmail = user.email ?? 'user@example.com';
|
||||||
|
|
||||||
@ -331,7 +334,7 @@ export const DocumentPreferencesForm = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!isPersonalLayoutMode && (
|
{!isPersonalLayoutMode && !isPersonalOrganisation && (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="includeSenderDetails"
|
name="includeSenderDetails"
|
||||||
|
|||||||
@ -0,0 +1,359 @@
|
|||||||
|
import { useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { t } from '@lingui/core/macro';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { PlusIcon, Trash } from 'lucide-react';
|
||||||
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { validateCheckboxLength } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
|
||||||
|
import {
|
||||||
|
type TCheckboxFieldMeta as CheckboxFieldMeta,
|
||||||
|
DEFAULT_FIELD_FONT_SIZE,
|
||||||
|
ZCheckboxFieldMeta,
|
||||||
|
} from '@documenso/lib/types/field-meta';
|
||||||
|
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||||
|
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||||
|
import {
|
||||||
|
checkboxValidationLength,
|
||||||
|
checkboxValidationRules,
|
||||||
|
checkboxValidationSigns,
|
||||||
|
} from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
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 {
|
||||||
|
EditorGenericFontSizeField,
|
||||||
|
EditorGenericReadOnlyField,
|
||||||
|
EditorGenericRequiredField,
|
||||||
|
} from './editor-field-generic-field-forms';
|
||||||
|
|
||||||
|
const ZCheckboxFieldFormSchema = ZCheckboxFieldMeta.pick({
|
||||||
|
label: true,
|
||||||
|
direction: true,
|
||||||
|
validationRule: true,
|
||||||
|
validationLength: true,
|
||||||
|
required: true,
|
||||||
|
values: true,
|
||||||
|
readOnly: true,
|
||||||
|
fontSize: true,
|
||||||
|
})
|
||||||
|
.extend({
|
||||||
|
validationLength: z.coerce.number().optional(),
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
// You need to specify both validation rule and length together
|
||||||
|
if (data.validationRule && !data.validationLength) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (data.validationLength && !data.validationRule) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: 'You need to specify both the validation rule and the number of options',
|
||||||
|
path: ['validationRule'],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
type TCheckboxFieldFormSchema = z.infer<typeof ZCheckboxFieldFormSchema>;
|
||||||
|
|
||||||
|
type EditorFieldCheckboxFormProps = {
|
||||||
|
value: CheckboxFieldMeta | undefined;
|
||||||
|
onValueChange: (value: CheckboxFieldMeta) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EditorFieldCheckboxForm = ({
|
||||||
|
value = {
|
||||||
|
type: 'checkbox',
|
||||||
|
direction: 'vertical',
|
||||||
|
},
|
||||||
|
onValueChange,
|
||||||
|
}: EditorFieldCheckboxFormProps) => {
|
||||||
|
const form = useForm<TCheckboxFieldFormSchema>({
|
||||||
|
resolver: zodResolver(ZCheckboxFieldFormSchema),
|
||||||
|
mode: 'onChange',
|
||||||
|
defaultValues: {
|
||||||
|
label: value.label || '',
|
||||||
|
direction: value.direction || 'vertical',
|
||||||
|
validationRule: value.validationRule || '',
|
||||||
|
validationLength: value.validationLength || 0,
|
||||||
|
values: value.values || [{ id: 1, checked: false, value: '' }],
|
||||||
|
required: value.required || false,
|
||||||
|
readOnly: value.readOnly || false,
|
||||||
|
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { control } = form;
|
||||||
|
|
||||||
|
const formValues = useWatch({
|
||||||
|
control,
|
||||||
|
});
|
||||||
|
|
||||||
|
const addValue = (numberOfValues: number = 1) => {
|
||||||
|
const currentValues = form.getValues('values') || [];
|
||||||
|
const currentMaxId = Math.max(...currentValues.map((val) => val.id));
|
||||||
|
|
||||||
|
const newValues = Array.from({ length: numberOfValues }, (_, index) => ({
|
||||||
|
id: currentMaxId + index + 1,
|
||||||
|
checked: false,
|
||||||
|
value: '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
form.setValue('values', [...currentValues, ...newValues]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeValue = (index: number) => {
|
||||||
|
const currentValues = form.getValues('values') || [];
|
||||||
|
|
||||||
|
if (currentValues.length === 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newValues = [...currentValues];
|
||||||
|
newValues.splice(index, 1);
|
||||||
|
|
||||||
|
form.setValue('values', newValues);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const validatedFormValues = ZCheckboxFieldFormSchema.safeParse(formValues);
|
||||||
|
|
||||||
|
if (validatedFormValues.success) {
|
||||||
|
onValueChange({
|
||||||
|
...value,
|
||||||
|
...validatedFormValues.data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [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 (
|
||||||
|
<Form {...form}>
|
||||||
|
<form>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-row items-center justify-start gap-x-4">
|
||||||
|
<div className="flex w-2/3 flex-col">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="validationRule"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Validation</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select {...field} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger className="text-muted-foreground bg-background w-full">
|
||||||
|
<SelectValue placeholder={t`Select at least`} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent position="popper">
|
||||||
|
{checkboxValidationRules.map((item, index) => (
|
||||||
|
<SelectItem key={index} value={item}>
|
||||||
|
{item}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex w-1/3 flex-col">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="validationLength"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Select
|
||||||
|
value={field.value ? String(field.value) : ''}
|
||||||
|
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">
|
||||||
|
<SelectValue placeholder={t`Pick a number`} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent position="popper">
|
||||||
|
{checkboxValidationLength.map((item, index) => (
|
||||||
|
<SelectItem key={index} value={String(item)}>
|
||||||
|
{item}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1">
|
||||||
|
<EditorGenericRequiredField formControl={form.control} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditorGenericReadOnlyField formControl={form.control} />
|
||||||
|
|
||||||
|
<section className="space-y-2">
|
||||||
|
<div className="-mx-4 mb-4 mt-2">
|
||||||
|
<Separator />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-row items-center justify-between gap-2">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
<Trans>Checkbox values</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button type="button" onClick={() => addValue()}>
|
||||||
|
<PlusIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{(formValues.values || []).map((value, index) => (
|
||||||
|
<li key={`checkbox-value-${index}`} className="flex flex-row items-center gap-2">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`values.${index}.checked`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Checkbox
|
||||||
|
className="data-[state=checked]:bg-primary border-foreground/30 h-5 w-5"
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`values.${index}.value`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Input className="w-full" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
onClick={() => removeValue(index)}
|
||||||
|
>
|
||||||
|
<Trash className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{!isValidationRuleMetForPreselectedValues && (
|
||||||
|
<Alert variant="warning">
|
||||||
|
<AlertDescription>
|
||||||
|
<Trans>
|
||||||
|
The preselected values will be ignored unless they meet the validation criteria.
|
||||||
|
</Trans>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
|
import type { z } from 'zod';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_FIELD_FONT_SIZE,
|
||||||
|
type TDateFieldMeta as DateFieldMeta,
|
||||||
|
ZDateFieldMeta,
|
||||||
|
} from '@documenso/lib/types/field-meta';
|
||||||
|
import { Form } from '@documenso/ui/primitives/form/form';
|
||||||
|
|
||||||
|
import {
|
||||||
|
EditorGenericFontSizeField,
|
||||||
|
EditorGenericTextAlignField,
|
||||||
|
} from './editor-field-generic-field-forms';
|
||||||
|
|
||||||
|
const ZDateFieldFormSchema = ZDateFieldMeta.pick({
|
||||||
|
fontSize: true,
|
||||||
|
textAlign: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
type TDateFieldFormSchema = z.infer<typeof ZDateFieldFormSchema>;
|
||||||
|
|
||||||
|
type EditorFieldDateFormProps = {
|
||||||
|
value: DateFieldMeta | undefined;
|
||||||
|
onValueChange: (value: DateFieldMeta) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EditorFieldDateForm = ({
|
||||||
|
value = {
|
||||||
|
type: 'date',
|
||||||
|
},
|
||||||
|
onValueChange,
|
||||||
|
}: EditorFieldDateFormProps) => {
|
||||||
|
const form = useForm<TDateFieldFormSchema>({
|
||||||
|
resolver: zodResolver(ZDateFieldFormSchema),
|
||||||
|
mode: 'onChange',
|
||||||
|
defaultValues: {
|
||||||
|
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
||||||
|
textAlign: value.textAlign || 'left',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { control } = form;
|
||||||
|
|
||||||
|
const formValues = useWatch({
|
||||||
|
control,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dupecode/Inefficient: Done because native isValid won't work for our usecase.
|
||||||
|
useEffect(() => {
|
||||||
|
const validatedFormValues = ZDateFieldFormSchema.safeParse(formValues);
|
||||||
|
|
||||||
|
if (validatedFormValues.success) {
|
||||||
|
onValueChange({
|
||||||
|
type: 'date',
|
||||||
|
...validatedFormValues.data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [formValues]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form>
|
||||||
|
<fieldset className="flex flex-col gap-2">
|
||||||
|
<EditorGenericFontSizeField formControl={form.control} />
|
||||||
|
|
||||||
|
<EditorGenericTextAlignField formControl={form.control} />
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,254 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react/macro';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { PlusIcon, Trash } from 'lucide-react';
|
||||||
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_FIELD_FONT_SIZE,
|
||||||
|
type TDropdownFieldMeta as DropdownFieldMeta,
|
||||||
|
} from '@documenso/lib/types/field-meta';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
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 {
|
||||||
|
EditorGenericFontSizeField,
|
||||||
|
EditorGenericReadOnlyField,
|
||||||
|
EditorGenericRequiredField,
|
||||||
|
} from './editor-field-generic-field-forms';
|
||||||
|
|
||||||
|
const ZDropdownFieldFormSchema = z.object({
|
||||||
|
defaultValue: z.string().optional(),
|
||||||
|
values: z
|
||||||
|
.object({
|
||||||
|
value: z.string().min(1, {
|
||||||
|
message: msg`Option value cannot be empty`.id,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.array()
|
||||||
|
.min(1, {
|
||||||
|
message: msg`Dropdown must have at least one option`.id,
|
||||||
|
})
|
||||||
|
.superRefine((values, ctx) => {
|
||||||
|
const seen = new Map<string, number[]>(); // value → indices
|
||||||
|
|
||||||
|
values.forEach((item, index) => {
|
||||||
|
const key = item.value;
|
||||||
|
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'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
required: z.boolean().optional(),
|
||||||
|
readOnly: z.boolean().optional(),
|
||||||
|
fontSize: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TDropdownFieldFormSchema = z.infer<typeof ZDropdownFieldFormSchema>;
|
||||||
|
|
||||||
|
type EditorFieldDropdownFormProps = {
|
||||||
|
value: DropdownFieldMeta | undefined;
|
||||||
|
onValueChange: (value: DropdownFieldMeta) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EditorFieldDropdownForm = ({
|
||||||
|
value = {
|
||||||
|
type: 'dropdown',
|
||||||
|
},
|
||||||
|
onValueChange,
|
||||||
|
}: EditorFieldDropdownFormProps) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
const form = useForm<TDropdownFieldFormSchema>({
|
||||||
|
resolver: zodResolver(ZDropdownFieldFormSchema),
|
||||||
|
mode: 'onChange',
|
||||||
|
defaultValues: {
|
||||||
|
defaultValue: value.defaultValue,
|
||||||
|
values: value.values || [{ value: 'Option 1' }],
|
||||||
|
required: value.required || false,
|
||||||
|
readOnly: value.readOnly || false,
|
||||||
|
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const formValues = useWatch({
|
||||||
|
control: form.control,
|
||||||
|
});
|
||||||
|
|
||||||
|
const addValue = () => {
|
||||||
|
const currentValues = form.getValues('values') || [];
|
||||||
|
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeValue = (index: number) => {
|
||||||
|
const currentValues = form.getValues('values') || [];
|
||||||
|
|
||||||
|
if (currentValues.length === 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newValues = [...currentValues];
|
||||||
|
newValues.splice(index, 1);
|
||||||
|
|
||||||
|
form.setValue('values', newValues);
|
||||||
|
|
||||||
|
if (form.getValues('defaultValue') === newValues[index].value) {
|
||||||
|
form.setValue('defaultValue', undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const validatedFormValues = ZDropdownFieldFormSchema.safeParse(formValues);
|
||||||
|
|
||||||
|
if (validatedFormValues.success) {
|
||||||
|
onValueChange({
|
||||||
|
type: 'dropdown',
|
||||||
|
...validatedFormValues.data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [formValues]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form>
|
||||||
|
<fieldset className="flex flex-col gap-2">
|
||||||
|
<EditorGenericFontSizeField formControl={form.control} />
|
||||||
|
|
||||||
|
{/* Todo: Envelopes This is buggy. */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="defaultValue"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Select default option</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? '-1'}
|
||||||
|
onValueChange={(value) => field.onChange(value === '-1' ? undefined : value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="text-muted-foreground bg-background w-full">
|
||||||
|
<SelectValue placeholder={t`Default Value`} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent position="popper">
|
||||||
|
{(formValues.values || [])
|
||||||
|
.filter((item) => item.value)
|
||||||
|
.map((item, index) => (
|
||||||
|
<SelectItem key={index} value={item.value || ''}>
|
||||||
|
{item.value}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<SelectItem value={'-1'}>
|
||||||
|
<Trans>Default Value</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-1">
|
||||||
|
<EditorGenericRequiredField formControl={form.control} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditorGenericReadOnlyField formControl={form.control} />
|
||||||
|
|
||||||
|
<section className="space-y-2">
|
||||||
|
<div className="-mx-4 mb-4 mt-2">
|
||||||
|
<Separator />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-row items-center justify-between gap-2">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
<Trans>Dropdown values</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button type="button" onClick={addValue}>
|
||||||
|
<PlusIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{(formValues.values || []).map((value, index) => (
|
||||||
|
<li key={`dropdown-value-${index}`} className="flex flex-row gap-2">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`values.${index}.value`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-full">
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
onClick={() => removeValue(index)}
|
||||||
|
>
|
||||||
|
<Trash className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
|
import type { z } from 'zod';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_FIELD_FONT_SIZE,
|
||||||
|
type TEmailFieldMeta as EmailFieldMeta,
|
||||||
|
ZEmailFieldMeta,
|
||||||
|
} from '@documenso/lib/types/field-meta';
|
||||||
|
import { Form } from '@documenso/ui/primitives/form/form';
|
||||||
|
|
||||||
|
import {
|
||||||
|
EditorGenericFontSizeField,
|
||||||
|
EditorGenericTextAlignField,
|
||||||
|
} from './editor-field-generic-field-forms';
|
||||||
|
|
||||||
|
const ZEmailFieldFormSchema = ZEmailFieldMeta.pick({
|
||||||
|
fontSize: true,
|
||||||
|
textAlign: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
type TEmailFieldFormSchema = z.infer<typeof ZEmailFieldFormSchema>;
|
||||||
|
|
||||||
|
type EditorFieldEmailFormProps = {
|
||||||
|
value: EmailFieldMeta | undefined;
|
||||||
|
onValueChange: (value: EmailFieldMeta) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EditorFieldEmailForm = ({
|
||||||
|
value = {
|
||||||
|
type: 'email',
|
||||||
|
},
|
||||||
|
onValueChange,
|
||||||
|
}: EditorFieldEmailFormProps) => {
|
||||||
|
const form = useForm<TEmailFieldFormSchema>({
|
||||||
|
resolver: zodResolver(ZEmailFieldFormSchema),
|
||||||
|
mode: 'onChange',
|
||||||
|
defaultValues: {
|
||||||
|
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
||||||
|
textAlign: value.textAlign || 'left',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { control } = form;
|
||||||
|
|
||||||
|
const formValues = useWatch({
|
||||||
|
control,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dupecode/Inefficient: Done because native isValid won't work for our usecase.
|
||||||
|
useEffect(() => {
|
||||||
|
const validatedFormValues = ZEmailFieldFormSchema.safeParse(formValues);
|
||||||
|
|
||||||
|
if (validatedFormValues.success) {
|
||||||
|
onValueChange({
|
||||||
|
type: 'email',
|
||||||
|
...validatedFormValues.data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [formValues]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form>
|
||||||
|
<fieldset className="flex flex-col gap-2">
|
||||||
|
<EditorGenericFontSizeField formControl={form.control} />
|
||||||
|
|
||||||
|
<EditorGenericTextAlignField formControl={form.control} />
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,222 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
|
import { type Control, useFormContext } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@documenso/ui/primitives/select';
|
||||||
|
|
||||||
|
// Can't seem to get the non-any type to work with correct types.
|
||||||
|
// Eg Control<{ fontSize?: number } doesn't seem to work when there are required items.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
type FormControlType = Control<any>;
|
||||||
|
|
||||||
|
export const EditorGenericFontSizeField = ({
|
||||||
|
formControl,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
formControl: FormControlType;
|
||||||
|
className?: string;
|
||||||
|
}) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormField
|
||||||
|
control={formControl}
|
||||||
|
name="fontSize"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className={className}>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Font Size</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={8}
|
||||||
|
max={96}
|
||||||
|
className="bg-background"
|
||||||
|
placeholder={t`Field font size`}
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => {
|
||||||
|
field.onChange(Number(e.target.value));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EditorGenericTextAlignField = ({
|
||||||
|
formControl,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
formControl: FormControlType;
|
||||||
|
className?: string;
|
||||||
|
}) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormField
|
||||||
|
control={formControl}
|
||||||
|
name="textAlign"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className={className}>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Text Align</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select {...field} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={t`Select text align`} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="left">
|
||||||
|
<Trans>Left</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="center">
|
||||||
|
<Trans>Center</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="right">
|
||||||
|
<Trans>Right</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EditorGenericRequiredField = ({
|
||||||
|
formControl,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
formControl: FormControlType;
|
||||||
|
className?: string;
|
||||||
|
}) => {
|
||||||
|
const { watch, setValue } = useFormContext();
|
||||||
|
|
||||||
|
const readOnly = watch('readOnly');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (readOnly) {
|
||||||
|
setValue('required', false);
|
||||||
|
}
|
||||||
|
}, [readOnly]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormField
|
||||||
|
control={formControl}
|
||||||
|
name="required"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className={cn('flex items-center space-x-2', className)}>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Checkbox
|
||||||
|
id="field-required"
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label className="text-muted-foreground ml-2 text-sm" htmlFor="field-required">
|
||||||
|
<Trans>Required Field</Trans>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EditorGenericReadOnlyField = ({
|
||||||
|
formControl,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
formControl: FormControlType;
|
||||||
|
className?: string;
|
||||||
|
}) => {
|
||||||
|
const { watch, setValue } = useFormContext();
|
||||||
|
|
||||||
|
const required = watch('required');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (required) {
|
||||||
|
setValue('readOnly', false);
|
||||||
|
}
|
||||||
|
}, [required]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormField
|
||||||
|
control={formControl}
|
||||||
|
name="readOnly"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className={cn('flex items-center space-x-2', className)}>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Checkbox
|
||||||
|
id="field-read-only"
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label className="text-muted-foreground ml-2 text-sm" htmlFor="field-read-only">
|
||||||
|
<Trans>Read Only</Trans>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EditorGenericLabelField = ({
|
||||||
|
formControl,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
formControl: FormControlType;
|
||||||
|
className?: string;
|
||||||
|
}) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormField
|
||||||
|
control={formControl}
|
||||||
|
name="label"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className={className}>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Label</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={t`Field label`} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
|
import type { z } from 'zod';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_FIELD_FONT_SIZE,
|
||||||
|
type TInitialsFieldMeta as InitialsFieldMeta,
|
||||||
|
ZInitialsFieldMeta,
|
||||||
|
} from '@documenso/lib/types/field-meta';
|
||||||
|
import { Form } from '@documenso/ui/primitives/form/form';
|
||||||
|
|
||||||
|
import {
|
||||||
|
EditorGenericFontSizeField,
|
||||||
|
EditorGenericTextAlignField,
|
||||||
|
} from './editor-field-generic-field-forms';
|
||||||
|
|
||||||
|
const ZInitialsFieldFormSchema = ZInitialsFieldMeta.pick({
|
||||||
|
fontSize: true,
|
||||||
|
textAlign: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
type TInitialsFieldFormSchema = z.infer<typeof ZInitialsFieldFormSchema>;
|
||||||
|
|
||||||
|
type EditorFieldInitialsFormProps = {
|
||||||
|
value: InitialsFieldMeta | undefined;
|
||||||
|
onValueChange: (value: InitialsFieldMeta) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EditorFieldInitialsForm = ({
|
||||||
|
value = {
|
||||||
|
type: 'initials',
|
||||||
|
},
|
||||||
|
onValueChange,
|
||||||
|
}: EditorFieldInitialsFormProps) => {
|
||||||
|
const form = useForm<TInitialsFieldFormSchema>({
|
||||||
|
resolver: zodResolver(ZInitialsFieldFormSchema),
|
||||||
|
mode: 'onChange',
|
||||||
|
defaultValues: {
|
||||||
|
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
||||||
|
textAlign: value.textAlign || 'left',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { control } = form;
|
||||||
|
|
||||||
|
const formValues = useWatch({
|
||||||
|
control,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dupecode/Inefficient: Done because native isValid won't work for our usecase.
|
||||||
|
useEffect(() => {
|
||||||
|
const validatedFormValues = ZInitialsFieldFormSchema.safeParse(formValues);
|
||||||
|
|
||||||
|
if (validatedFormValues.success) {
|
||||||
|
onValueChange({
|
||||||
|
type: 'initials',
|
||||||
|
...validatedFormValues.data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [formValues]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form>
|
||||||
|
<fieldset className="flex flex-col gap-2">
|
||||||
|
<EditorGenericFontSizeField formControl={form.control} />
|
||||||
|
|
||||||
|
<EditorGenericTextAlignField formControl={form.control} />
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
|
import type { z } from 'zod';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_FIELD_FONT_SIZE,
|
||||||
|
type TNameFieldMeta as NameFieldMeta,
|
||||||
|
ZNameFieldMeta,
|
||||||
|
} from '@documenso/lib/types/field-meta';
|
||||||
|
import { Form } from '@documenso/ui/primitives/form/form';
|
||||||
|
|
||||||
|
import {
|
||||||
|
EditorGenericFontSizeField,
|
||||||
|
EditorGenericTextAlignField,
|
||||||
|
} from './editor-field-generic-field-forms';
|
||||||
|
|
||||||
|
const ZNameFieldFormSchema = ZNameFieldMeta.pick({
|
||||||
|
fontSize: true,
|
||||||
|
textAlign: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
type TNameFieldFormSchema = z.infer<typeof ZNameFieldFormSchema>;
|
||||||
|
|
||||||
|
type EditorFieldNameFormProps = {
|
||||||
|
value: NameFieldMeta | undefined;
|
||||||
|
onValueChange: (value: NameFieldMeta) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EditorFieldNameForm = ({
|
||||||
|
value = {
|
||||||
|
type: 'name',
|
||||||
|
},
|
||||||
|
onValueChange,
|
||||||
|
}: EditorFieldNameFormProps) => {
|
||||||
|
const form = useForm<TNameFieldFormSchema>({
|
||||||
|
resolver: zodResolver(ZNameFieldFormSchema),
|
||||||
|
mode: 'onChange',
|
||||||
|
defaultValues: {
|
||||||
|
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
||||||
|
textAlign: value.textAlign || 'left',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { control } = form;
|
||||||
|
|
||||||
|
const formValues = useWatch({
|
||||||
|
control,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dupecode/Inefficient: Done because native isValid won't work for our usecase.
|
||||||
|
useEffect(() => {
|
||||||
|
const validatedFormValues = ZNameFieldFormSchema.safeParse(formValues);
|
||||||
|
|
||||||
|
if (validatedFormValues.success) {
|
||||||
|
onValueChange({
|
||||||
|
type: 'name',
|
||||||
|
...validatedFormValues.data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [formValues]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form>
|
||||||
|
<fieldset className="flex flex-col gap-2">
|
||||||
|
<EditorGenericFontSizeField formControl={form.control} />
|
||||||
|
|
||||||
|
<EditorGenericTextAlignField formControl={form.control} />
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,277 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
|
import type { z } from 'zod';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type TNumberFieldMeta as NumberFieldMeta,
|
||||||
|
ZNumberFieldMeta,
|
||||||
|
} from '@documenso/lib/types/field-meta';
|
||||||
|
import { numberFormatValues } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
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 {
|
||||||
|
EditorGenericFontSizeField,
|
||||||
|
EditorGenericLabelField,
|
||||||
|
EditorGenericReadOnlyField,
|
||||||
|
EditorGenericRequiredField,
|
||||||
|
EditorGenericTextAlignField,
|
||||||
|
} from './editor-field-generic-field-forms';
|
||||||
|
|
||||||
|
const ZNumberFieldFormSchema = ZNumberFieldMeta.pick({
|
||||||
|
label: true,
|
||||||
|
placeholder: true,
|
||||||
|
value: true,
|
||||||
|
numberFormat: true,
|
||||||
|
fontSize: true,
|
||||||
|
textAlign: true,
|
||||||
|
required: true,
|
||||||
|
readOnly: true,
|
||||||
|
minValue: true,
|
||||||
|
maxValue: true,
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
// Minimum value cannot be greater than maximum value
|
||||||
|
if (typeof data.minValue === 'number' && typeof data.maxValue === 'number') {
|
||||||
|
return data.minValue <= data.maxValue;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: 'Minimum value cannot be greater than maximum value',
|
||||||
|
path: ['minValue'],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
// A read-only field must have a value greater than 0
|
||||||
|
if (data.readOnly && data.value !== undefined && data.value !== '') {
|
||||||
|
const numberValue = parseFloat(data.value);
|
||||||
|
return !isNaN(numberValue) && numberValue > 0;
|
||||||
|
}
|
||||||
|
return !data.readOnly || (data.value !== undefined && data.value !== '');
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: 'A read-only field must have a value greater than 0',
|
||||||
|
path: ['value'],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
type TNumberFieldFormSchema = z.infer<typeof ZNumberFieldFormSchema>;
|
||||||
|
|
||||||
|
type EditorFieldNumberFormProps = {
|
||||||
|
value: NumberFieldMeta | undefined;
|
||||||
|
onValueChange: (value: NumberFieldMeta) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EditorFieldNumberForm = ({
|
||||||
|
value = {
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
onValueChange,
|
||||||
|
}: EditorFieldNumberFormProps) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
const form = useForm<TNumberFieldFormSchema>({
|
||||||
|
resolver: zodResolver(ZNumberFieldFormSchema),
|
||||||
|
mode: 'onChange',
|
||||||
|
defaultValues: {
|
||||||
|
label: value.label || '',
|
||||||
|
placeholder: value.placeholder || '',
|
||||||
|
value: value.value || '',
|
||||||
|
numberFormat: value.numberFormat || null,
|
||||||
|
fontSize: value.fontSize || 14,
|
||||||
|
textAlign: value.textAlign || 'left',
|
||||||
|
required: value.required || false,
|
||||||
|
readOnly: value.readOnly || false,
|
||||||
|
minValue: value.minValue,
|
||||||
|
maxValue: value.maxValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { control } = form;
|
||||||
|
|
||||||
|
const formValues = useWatch({
|
||||||
|
control,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dupecode/Inefficient: Done because native isValid won't work for our usecase.
|
||||||
|
useEffect(() => {
|
||||||
|
const validatedFormValues = ZNumberFieldFormSchema.safeParse(formValues);
|
||||||
|
|
||||||
|
if (validatedFormValues.success) {
|
||||||
|
onValueChange({
|
||||||
|
type: 'number',
|
||||||
|
...validatedFormValues.data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [formValues]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form>
|
||||||
|
<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} />
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="placeholder"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Placeholder</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input className="bg-background" placeholder={t`Placeholder`} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="value"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Value</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input className="bg-background" placeholder={t`Value`} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="numberFormat"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Number format</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select
|
||||||
|
value={field.value === null ? '-1' : field.value}
|
||||||
|
onValueChange={(value) => field.onChange(value === '-1' ? null : value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="text-muted-foreground bg-background w-full">
|
||||||
|
<SelectValue placeholder={t`Field format`} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent position="popper">
|
||||||
|
{numberFormatValues.map((item, index) => (
|
||||||
|
<SelectItem key={index} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<SelectItem value={'-1'}>
|
||||||
|
<Trans>None</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-1">
|
||||||
|
<EditorGenericRequiredField formControl={form.control} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditorGenericReadOnlyField formControl={form.control} />
|
||||||
|
|
||||||
|
{/* Validation section */}
|
||||||
|
<section className="space-y-2">
|
||||||
|
<div className="-mx-4 mb-4 mt-2">
|
||||||
|
<Separator />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
<Trans>Validation</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-row gap-x-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="minValue"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Min</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className="bg-background"
|
||||||
|
placeholder="E.g. 0"
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
field.onChange(e.target.value === '' ? null : e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="maxValue"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Max</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className="bg-background"
|
||||||
|
placeholder="E.g. 100"
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
field.onChange(e.target.value === '' ? null : e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,240 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
|
import { PlusIcon, Trash } from 'lucide-react';
|
||||||
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
|
import type { z } from 'zod';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_FIELD_FONT_SIZE,
|
||||||
|
type TRadioFieldMeta as RadioFieldMeta,
|
||||||
|
ZRadioFieldMeta,
|
||||||
|
} from '@documenso/lib/types/field-meta';
|
||||||
|
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
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 {
|
||||||
|
EditorGenericFontSizeField,
|
||||||
|
EditorGenericReadOnlyField,
|
||||||
|
EditorGenericRequiredField,
|
||||||
|
} from './editor-field-generic-field-forms';
|
||||||
|
|
||||||
|
const ZRadioFieldFormSchema = ZRadioFieldMeta.pick({
|
||||||
|
label: true,
|
||||||
|
direction: true,
|
||||||
|
values: true,
|
||||||
|
required: true,
|
||||||
|
readOnly: true,
|
||||||
|
fontSize: true,
|
||||||
|
}).refine(
|
||||||
|
(data) => {
|
||||||
|
// There cannot be more than one checked option
|
||||||
|
if (data.values) {
|
||||||
|
const checkedValues = data.values.filter((option) => option.checked);
|
||||||
|
return checkedValues.length <= 1;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: 'There cannot be more than one checked option',
|
||||||
|
path: ['values'],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
type TRadioFieldFormSchema = z.infer<typeof ZRadioFieldFormSchema>;
|
||||||
|
|
||||||
|
export type EditorFieldRadioFormProps = {
|
||||||
|
value: RadioFieldMeta | undefined;
|
||||||
|
onValueChange: (value: RadioFieldMeta) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EditorFieldRadioForm = ({
|
||||||
|
value = {
|
||||||
|
type: 'radio',
|
||||||
|
direction: 'vertical',
|
||||||
|
},
|
||||||
|
onValueChange,
|
||||||
|
}: EditorFieldRadioFormProps) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
const form = useForm<TRadioFieldFormSchema>({
|
||||||
|
resolver: zodResolver(ZRadioFieldFormSchema),
|
||||||
|
mode: 'onChange',
|
||||||
|
defaultValues: {
|
||||||
|
label: value.label || '',
|
||||||
|
values: value.values || [{ id: 1, checked: false, value: 'Default value' }],
|
||||||
|
required: value.required || false,
|
||||||
|
readOnly: value.readOnly || false,
|
||||||
|
direction: value.direction || 'vertical',
|
||||||
|
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const formValues = useWatch({
|
||||||
|
control: form.control,
|
||||||
|
});
|
||||||
|
|
||||||
|
const addValue = () => {
|
||||||
|
const currentValues = form.getValues('values') || [];
|
||||||
|
const newId =
|
||||||
|
currentValues.length > 0 ? Math.max(...currentValues.map((val) => val.id)) + 1 : 1;
|
||||||
|
|
||||||
|
const newValues = [...currentValues, { id: newId, checked: false, value: '' }];
|
||||||
|
form.setValue('values', newValues);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeValue = (index: number) => {
|
||||||
|
const currentValues = form.getValues('values') || [];
|
||||||
|
|
||||||
|
if (currentValues.length === 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newValues = [...currentValues];
|
||||||
|
newValues.splice(index, 1);
|
||||||
|
|
||||||
|
form.setValue('values', newValues);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const validatedFormValues = ZRadioFieldFormSchema.safeParse(formValues);
|
||||||
|
|
||||||
|
if (validatedFormValues.success) {
|
||||||
|
onValueChange({
|
||||||
|
type: 'radio',
|
||||||
|
...validatedFormValues.data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [formValues]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form>
|
||||||
|
<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} />
|
||||||
|
|
||||||
|
<EditorGenericReadOnlyField formControl={form.control} />
|
||||||
|
|
||||||
|
<section className="space-y-2">
|
||||||
|
<div className="-mx-4 mb-4 mt-2">
|
||||||
|
<Separator />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-row items-center justify-between gap-2">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
<Trans>Radio values</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button type="button" onClick={addValue}>
|
||||||
|
<PlusIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{(formValues.values || []).map((value, index) => (
|
||||||
|
<li key={`radio-value-${index}`} className="flex flex-row items-center gap-2">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`values.${index}.checked`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Checkbox
|
||||||
|
className="data-[state=checked]:bg-primary border-foreground/30 h-5 w-5"
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={(value) => {
|
||||||
|
// Uncheck all other values.
|
||||||
|
const currentValues = form.getValues('values') || [];
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
const newValues = currentValues.map((val) => ({
|
||||||
|
...val,
|
||||||
|
checked: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
form.setValue('values', newValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
field.onChange(value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`values.${index}.value`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Input className="w-full" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
onClick={() => removeValue(index)}
|
||||||
|
>
|
||||||
|
<Trash className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,218 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_FIELD_FONT_SIZE,
|
||||||
|
type TTextFieldMeta as TextFieldMeta,
|
||||||
|
} from '@documenso/lib/types/field-meta';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||||
|
|
||||||
|
import {
|
||||||
|
EditorGenericFontSizeField,
|
||||||
|
EditorGenericReadOnlyField,
|
||||||
|
EditorGenericRequiredField,
|
||||||
|
EditorGenericTextAlignField,
|
||||||
|
} from './editor-field-generic-field-forms';
|
||||||
|
|
||||||
|
const ZTextFieldFormSchema = z
|
||||||
|
.object({
|
||||||
|
label: z.string().optional(),
|
||||||
|
placeholder: z.string().optional(),
|
||||||
|
text: z.string().optional(),
|
||||||
|
characterLimit: z.coerce.number().min(0).optional(),
|
||||||
|
fontSize: z.coerce.number().min(8).max(96).optional(),
|
||||||
|
textAlign: z.enum(['left', 'center', 'right']).optional(),
|
||||||
|
required: z.boolean().optional(),
|
||||||
|
readOnly: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
// A read-only field must have text
|
||||||
|
return !data.readOnly || (data.text && data.text.length > 0);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: 'A read-only field must have text',
|
||||||
|
path: ['text'],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
type TTextFieldFormSchema = z.infer<typeof ZTextFieldFormSchema>;
|
||||||
|
|
||||||
|
type EditorFieldTextFormProps = {
|
||||||
|
value: TextFieldMeta | undefined;
|
||||||
|
onValueChange: (value: TextFieldMeta) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EditorFieldTextForm = ({
|
||||||
|
value = {
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
onValueChange,
|
||||||
|
}: EditorFieldTextFormProps) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
const form = useForm<TTextFieldFormSchema>({
|
||||||
|
resolver: zodResolver(ZTextFieldFormSchema),
|
||||||
|
mode: 'onChange',
|
||||||
|
defaultValues: {
|
||||||
|
label: value.label || '',
|
||||||
|
placeholder: value.placeholder || '',
|
||||||
|
text: value.text || '',
|
||||||
|
characterLimit: value.characterLimit || 0,
|
||||||
|
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
||||||
|
textAlign: value.textAlign || 'left',
|
||||||
|
required: value.required || false,
|
||||||
|
readOnly: value.readOnly || false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { control } = form;
|
||||||
|
|
||||||
|
const formValues = useWatch({
|
||||||
|
control,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dupecode/Inefficient: Done because native isValid won't work for our usecase.
|
||||||
|
useEffect(() => {
|
||||||
|
const validatedFormValues = ZTextFieldFormSchema.safeParse(formValues);
|
||||||
|
|
||||||
|
if (validatedFormValues.success) {
|
||||||
|
onValueChange({
|
||||||
|
type: 'text',
|
||||||
|
...validatedFormValues.data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [formValues]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form>
|
||||||
|
<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
|
||||||
|
control={form.control}
|
||||||
|
name="label"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Label</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={t`Field label`} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="placeholder"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Placeholder</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={t`Field placeholder`} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="text"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Add text</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
className="h-auto"
|
||||||
|
placeholder={t`Add text to the field`}
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => {
|
||||||
|
const values = form.getValues();
|
||||||
|
const characterLimit = values.characterLimit || 0;
|
||||||
|
let textValue = e.target.value;
|
||||||
|
|
||||||
|
if (characterLimit > 0 && textValue.length > characterLimit) {
|
||||||
|
textValue = textValue.slice(0, characterLimit);
|
||||||
|
}
|
||||||
|
|
||||||
|
e.target.value = textValue;
|
||||||
|
field.onChange(e);
|
||||||
|
}}
|
||||||
|
rows={1}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="characterLimit"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Character Limit</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
className="bg-background"
|
||||||
|
placeholder={t`Field character limit`}
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => {
|
||||||
|
field.onChange(e);
|
||||||
|
|
||||||
|
const values = form.getValues();
|
||||||
|
const characterLimit = parseInt(e.target.value, 10) || 0;
|
||||||
|
|
||||||
|
const textValue = values.text || '';
|
||||||
|
|
||||||
|
if (characterLimit > 0 && textValue.length > characterLimit) {
|
||||||
|
form.setValue('text', textValue.slice(0, characterLimit));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-1">
|
||||||
|
<EditorGenericRequiredField formControl={form.control} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditorGenericReadOnlyField formControl={form.control} />
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -70,6 +70,7 @@ export type SignInFormProps = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
initialEmail?: string;
|
initialEmail?: string;
|
||||||
isGoogleSSOEnabled?: boolean;
|
isGoogleSSOEnabled?: boolean;
|
||||||
|
isMicrosoftSSOEnabled?: boolean;
|
||||||
isOIDCSSOEnabled?: boolean;
|
isOIDCSSOEnabled?: boolean;
|
||||||
oidcProviderLabel?: string;
|
oidcProviderLabel?: string;
|
||||||
returnTo?: string;
|
returnTo?: string;
|
||||||
@ -79,6 +80,7 @@ export const SignInForm = ({
|
|||||||
className,
|
className,
|
||||||
initialEmail,
|
initialEmail,
|
||||||
isGoogleSSOEnabled,
|
isGoogleSSOEnabled,
|
||||||
|
isMicrosoftSSOEnabled,
|
||||||
isOIDCSSOEnabled,
|
isOIDCSSOEnabled,
|
||||||
oidcProviderLabel,
|
oidcProviderLabel,
|
||||||
returnTo,
|
returnTo,
|
||||||
@ -90,11 +92,14 @@ 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'
|
||||||
>('totp');
|
>('totp');
|
||||||
|
|
||||||
|
const hasSocialAuthEnabled = isGoogleSSOEnabled || isMicrosoftSSOEnabled || isOIDCSSOEnabled;
|
||||||
|
|
||||||
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
|
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
|
||||||
|
|
||||||
const redirectPath = useMemo(() => {
|
const redirectPath = useMemo(() => {
|
||||||
@ -271,6 +276,22 @@ export const SignInForm = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onSignInWithMicrosoftClick = async () => {
|
||||||
|
try {
|
||||||
|
await authClient.microsoft.signIn({
|
||||||
|
redirectPath,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: _(msg`An unknown error occurred`),
|
||||||
|
description: _(
|
||||||
|
msg`We encountered an unknown error while attempting to sign you In. Please try again later.`,
|
||||||
|
),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onSignInWithOIDCClick = async () => {
|
const onSignInWithOIDCClick = async () => {
|
||||||
try {
|
try {
|
||||||
await authClient.oidc.signIn({
|
await authClient.oidc.signIn({
|
||||||
@ -297,6 +318,8 @@ export const SignInForm = ({
|
|||||||
if (email) {
|
if (email) {
|
||||||
form.setValue('email', email);
|
form.setValue('email', email);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setIsEmbeddedRedirect(params.get('embedded') === 'true');
|
||||||
}, [form]);
|
}, [form]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -363,7 +386,9 @@ export const SignInForm = ({
|
|||||||
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
|
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{(isGoogleSSOEnabled || isOIDCSSOEnabled) && (
|
{!isEmbeddedRedirect && (
|
||||||
|
<>
|
||||||
|
{hasSocialAuthEnabled && (
|
||||||
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
||||||
<div className="bg-border h-px flex-1" />
|
<div className="bg-border h-px flex-1" />
|
||||||
<span className="text-muted-foreground bg-transparent">
|
<span className="text-muted-foreground bg-transparent">
|
||||||
@ -387,6 +412,24 @@ export const SignInForm = ({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isMicrosoftSSOEnabled && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="lg"
|
||||||
|
variant="outline"
|
||||||
|
className="bg-background text-muted-foreground border"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onClick={onSignInWithMicrosoftClick}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
className="mr-2 h-4 w-4"
|
||||||
|
alt="Microsoft Logo"
|
||||||
|
src={'/static/microsoft.svg'}
|
||||||
|
/>
|
||||||
|
Microsoft
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
{isOIDCSSOEnabled && (
|
{isOIDCSSOEnabled && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@ -400,6 +443,8 @@ export const SignInForm = ({
|
|||||||
{oidcProviderLabel || 'OIDC'}
|
{oidcProviderLabel || 'OIDC'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@ -66,14 +66,18 @@ export type SignUpFormProps = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
initialEmail?: string;
|
initialEmail?: string;
|
||||||
isGoogleSSOEnabled?: boolean;
|
isGoogleSSOEnabled?: boolean;
|
||||||
|
isMicrosoftSSOEnabled?: boolean;
|
||||||
isOIDCSSOEnabled?: boolean;
|
isOIDCSSOEnabled?: boolean;
|
||||||
|
returnTo?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SignUpForm = ({
|
export const SignUpForm = ({
|
||||||
className,
|
className,
|
||||||
initialEmail,
|
initialEmail,
|
||||||
isGoogleSSOEnabled,
|
isGoogleSSOEnabled,
|
||||||
|
isMicrosoftSSOEnabled,
|
||||||
isOIDCSSOEnabled,
|
isOIDCSSOEnabled,
|
||||||
|
returnTo,
|
||||||
}: SignUpFormProps) => {
|
}: SignUpFormProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@ -84,6 +88,8 @@ export const SignUpForm = ({
|
|||||||
|
|
||||||
const utmSrc = searchParams.get('utm_source') ?? null;
|
const utmSrc = searchParams.get('utm_source') ?? null;
|
||||||
|
|
||||||
|
const hasSocialAuthEnabled = isGoogleSSOEnabled || isMicrosoftSSOEnabled || isOIDCSSOEnabled;
|
||||||
|
|
||||||
const form = useForm<TSignUpFormSchema>({
|
const form = useForm<TSignUpFormSchema>({
|
||||||
values: {
|
values: {
|
||||||
name: '',
|
name: '',
|
||||||
@ -106,7 +112,7 @@ export const SignUpForm = ({
|
|||||||
signature,
|
signature,
|
||||||
});
|
});
|
||||||
|
|
||||||
await navigate(`/unverified-account`);
|
await navigate(returnTo ? returnTo : '/unverified-account');
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Registration Successful`),
|
title: _(msg`Registration Successful`),
|
||||||
@ -148,6 +154,20 @@ export const SignUpForm = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onSignUpWithMicrosoftClick = async () => {
|
||||||
|
try {
|
||||||
|
await authClient.microsoft.signIn();
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: _(msg`An unknown error occurred`),
|
||||||
|
description: _(
|
||||||
|
msg`We encountered an unknown error while attempting to sign you Up. Please try again later.`,
|
||||||
|
),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onSignUpWithOIDCClick = async () => {
|
const onSignUpWithOIDCClick = async () => {
|
||||||
try {
|
try {
|
||||||
await authClient.oidc.signIn();
|
await authClient.oidc.signIn();
|
||||||
@ -227,7 +247,7 @@ export const SignUpForm = ({
|
|||||||
<fieldset
|
<fieldset
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-[550px] w-full flex-col gap-y-4',
|
'flex h-[550px] w-full flex-col gap-y-4',
|
||||||
(isGoogleSSOEnabled || isOIDCSSOEnabled) && 'h-[650px]',
|
hasSocialAuthEnabled && 'h-[650px]',
|
||||||
)}
|
)}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
@ -302,7 +322,7 @@ export const SignUpForm = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{(isGoogleSSOEnabled || isOIDCSSOEnabled) && (
|
{hasSocialAuthEnabled && (
|
||||||
<>
|
<>
|
||||||
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
||||||
<div className="bg-border h-px flex-1" />
|
<div className="bg-border h-px flex-1" />
|
||||||
@ -330,6 +350,26 @@ export const SignUpForm = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isMicrosoftSSOEnabled && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="lg"
|
||||||
|
variant={'outline'}
|
||||||
|
className="bg-background text-muted-foreground border"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onClick={onSignUpWithMicrosoftClick}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
className="mr-2 h-4 w-4"
|
||||||
|
alt="Microsoft Logo"
|
||||||
|
src={'/static/microsoft.svg'}
|
||||||
|
/>
|
||||||
|
<Trans>Sign Up with Microsoft</Trans>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{isOIDCSSOEnabled && (
|
{isOIDCSSOEnabled && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
|
|||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { Theme, useTheme } from 'remix-themes';
|
import { Theme, useTheme } from 'remix-themes';
|
||||||
|
|
||||||
|
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
|
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
|
||||||
import {
|
import {
|
||||||
@ -63,10 +64,12 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [pages, setPages] = useState<string[]>([]);
|
const [pages, setPages] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const debouncedSearch = useDebouncedValue(search, 200);
|
||||||
|
|
||||||
const { data: searchDocumentsData, isPending: isSearchingDocuments } =
|
const { data: searchDocumentsData, isPending: isSearchingDocuments } =
|
||||||
trpcReact.document.search.useQuery(
|
trpcReact.document.search.useQuery(
|
||||||
{
|
{
|
||||||
query: search,
|
query: debouncedSearch,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
placeholderData: (previousData) => previousData,
|
placeholderData: (previousData) => previousData,
|
||||||
@ -232,6 +235,7 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
|
|||||||
<Trans>No results found.</Trans>
|
<Trans>No results found.</Trans>
|
||||||
</CommandEmpty>
|
</CommandEmpty>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!currentPage && (
|
{!currentPage && (
|
||||||
<>
|
<>
|
||||||
{documentPageLinks.length > 0 && (
|
{documentPageLinks.length > 0 && (
|
||||||
@ -239,14 +243,17 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
|
|||||||
<Commands push={push} pages={documentPageLinks} />
|
<Commands push={push} pages={documentPageLinks} />
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{templatePageLinks.length > 0 && (
|
{templatePageLinks.length > 0 && (
|
||||||
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Templates`)}>
|
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Templates`)}>
|
||||||
<Commands push={push} pages={templatePageLinks} />
|
<Commands push={push} pages={templatePageLinks} />
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Settings`)}>
|
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Settings`)}>
|
||||||
<Commands push={push} pages={SETTINGS_PAGES} />
|
<Commands push={push} pages={SETTINGS_PAGES} />
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
|
|
||||||
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Preferences`)}>
|
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Preferences`)}>
|
||||||
<CommandItem className="-mx-2 -my-1 rounded-lg" onSelect={() => addPage('language')}>
|
<CommandItem className="-mx-2 -my-1 rounded-lg" onSelect={() => addPage('language')}>
|
||||||
Change language
|
Change language
|
||||||
@ -255,6 +262,7 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
|
|||||||
Change theme
|
Change theme
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
|
|
||||||
{searchResults.length > 0 && (
|
{searchResults.length > 0 && (
|
||||||
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Your documents`)}>
|
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Your documents`)}>
|
||||||
<Commands push={push} pages={searchResults} />
|
<Commands push={push} pages={searchResults} />
|
||||||
|
|||||||
@ -60,7 +60,7 @@ export const Header = ({ className, ...props }: HeaderProps) => {
|
|||||||
>
|
>
|
||||||
<div className="mx-auto flex w-full max-w-screen-xl items-center justify-between gap-x-4 px-4 md:justify-normal md:px-8">
|
<div className="mx-auto flex w-full max-w-screen-xl items-center justify-between gap-x-4 px-4 md:justify-normal md:px-8">
|
||||||
<Link
|
<Link
|
||||||
to={`${getRootHref(params, { returnEmptyRootString: true })}`}
|
to={getRootHref(params)}
|
||||||
className="focus-visible:ring-ring ring-offset-background hidden rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 md:inline"
|
className="focus-visible:ring-ring ring-offset-background hidden rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 md:inline"
|
||||||
>
|
>
|
||||||
<BrandingLogo className="h-6 w-auto" />
|
<BrandingLogo className="h-6 w-auto" />
|
||||||
|
|||||||
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,
|
||||||
@ -149,7 +153,9 @@ export const DirectTemplatePageView = ({
|
|||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<PDFViewer
|
<PDFViewer
|
||||||
key={template.id}
|
key={template.id}
|
||||||
documentData={template.templateDocumentData}
|
envelopeItem={template.envelopeItems[0]}
|
||||||
|
token={directTemplateRecipient.token}
|
||||||
|
version="signed"
|
||||||
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@ -55,10 +55,13 @@ import { DocumentSigningRecipientProvider } from '../document-signing/document-s
|
|||||||
|
|
||||||
export type DirectTemplateSigningFormProps = {
|
export type DirectTemplateSigningFormProps = {
|
||||||
flowStep: DocumentFlowStep;
|
flowStep: DocumentFlowStep;
|
||||||
directRecipient: Recipient;
|
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={handleSubmit}
|
onSignatureComplete={async (nextSigner) => handleSubmit(nextSigner)}
|
||||||
documentTitle={template.title}
|
documentTitle={template.title}
|
||||||
fields={localFields}
|
fields={localFields}
|
||||||
fieldsValidated={fieldsValidated}
|
fieldsValidated={fieldsValidated}
|
||||||
role={directRecipient.role}
|
recipient={directRecipient}
|
||||||
|
allowDictateNextSigner={nextRecipient && template.templateMeta?.allowDictateNextSigner}
|
||||||
|
defaultNextSigner={
|
||||||
|
nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</DocumentFlowFormContainerFooter>
|
</DocumentFlowFormContainerFooter>
|
||||||
|
|||||||
@ -0,0 +1,312 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { ArrowLeftIcon, KeyIcon, MailIcon } from 'lucide-react';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Alert } from '@documenso/ui/primitives/alert';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Form, FormField, FormItem } from '@documenso/ui/primitives/form/form';
|
||||||
|
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||||
|
|
||||||
|
type FormStep = 'method-selection' | 'code-input';
|
||||||
|
type TwoFactorMethod = 'email' | 'authenticator';
|
||||||
|
|
||||||
|
const ZAccessAuth2FAFormSchema = z.object({
|
||||||
|
token: z.string().length(6, { message: 'Token must be 6 characters long' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TAccessAuth2FAFormSchema = z.infer<typeof ZAccessAuth2FAFormSchema>;
|
||||||
|
|
||||||
|
export type AccessAuth2FAFormProps = {
|
||||||
|
onSubmit: (accessAuthOptions: TRecipientAccessAuth) => void;
|
||||||
|
token: string;
|
||||||
|
error?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AccessAuth2FAForm = ({ onSubmit, token, error }: AccessAuth2FAFormProps) => {
|
||||||
|
const [step, setStep] = useState<FormStep>('method-selection');
|
||||||
|
const [selectedMethod, setSelectedMethod] = useState<TwoFactorMethod | null>(null);
|
||||||
|
|
||||||
|
const [expiresAt, setExpiresAt] = useState<Date | null>(null);
|
||||||
|
const [millisecondsRemaining, setMillisecondsRemaining] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { user } = useRequiredDocumentSigningAuthContext();
|
||||||
|
|
||||||
|
const { mutateAsync: request2FAEmail, isPending: isRequesting2FAEmail } =
|
||||||
|
trpc.document.accessAuth.request2FAEmail.useMutation();
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
resolver: zodResolver(ZAccessAuth2FAFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
token: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasAuthenticatorEnabled = user?.twoFactorEnabled === true;
|
||||||
|
|
||||||
|
const onMethodSelect = async (method: TwoFactorMethod) => {
|
||||||
|
setSelectedMethod(method);
|
||||||
|
|
||||||
|
if (method === 'email') {
|
||||||
|
try {
|
||||||
|
const result = await request2FAEmail({
|
||||||
|
token: token,
|
||||||
|
});
|
||||||
|
|
||||||
|
setExpiresAt(result.expiresAt);
|
||||||
|
setMillisecondsRemaining(result.expiresAt.valueOf() - Date.now());
|
||||||
|
|
||||||
|
setStep('code-input');
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: _(msg`An error occurred`),
|
||||||
|
description: _(
|
||||||
|
msg`We encountered an unknown error while attempting to request the two-factor authentication code. Please try again later.`,
|
||||||
|
),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setStep('code-input');
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFormSubmit = (data: TAccessAuth2FAFormSchema) => {
|
||||||
|
if (!selectedMethod) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare the auth options for the completion attempt
|
||||||
|
const accessAuthOptions: TRecipientAccessAuth = {
|
||||||
|
type: 'TWO_FACTOR_AUTH',
|
||||||
|
token: data.token, // Just the user's code - backend will validate using method type
|
||||||
|
method: selectedMethod,
|
||||||
|
};
|
||||||
|
|
||||||
|
onSubmit(accessAuthOptions);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onGoBack = () => {
|
||||||
|
setStep('method-selection');
|
||||||
|
setSelectedMethod(null);
|
||||||
|
setExpiresAt(null);
|
||||||
|
setMillisecondsRemaining(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onResendEmail = async () => {
|
||||||
|
if (selectedMethod !== 'email') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await request2FAEmail({
|
||||||
|
token: token,
|
||||||
|
});
|
||||||
|
|
||||||
|
setExpiresAt(result.expiresAt);
|
||||||
|
setMillisecondsRemaining(result.expiresAt.valueOf() - Date.now());
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: _(msg`An error occurred`),
|
||||||
|
description: _(
|
||||||
|
msg`We encountered an unknown error while attempting to request the two-factor authentication code. Please try again later.`,
|
||||||
|
),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (expiresAt) {
|
||||||
|
setMillisecondsRemaining(expiresAt.valueOf() - Date.now());
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [expiresAt]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-4">
|
||||||
|
{step === 'method-selection' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">
|
||||||
|
<Trans>Choose verification method</Trans>
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
<Trans>Please select how you'd like to receive your verification code.</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive" padding="tight" className="text-sm">
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="flex h-auto w-full justify-start gap-3 p-4"
|
||||||
|
onClick={async () => onMethodSelect('email')}
|
||||||
|
disabled={isRequesting2FAEmail}
|
||||||
|
>
|
||||||
|
<MailIcon className="h-5 w-5" />
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="font-medium">
|
||||||
|
<Trans>Email verification</Trans>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
<Trans>We'll send a 6-digit code to your email</Trans>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{hasAuthenticatorEnabled && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="flex h-auto w-full justify-start gap-3 p-4"
|
||||||
|
onClick={async () => onMethodSelect('authenticator')}
|
||||||
|
disabled={isRequesting2FAEmail}
|
||||||
|
>
|
||||||
|
<KeyIcon className="h-5 w-5" />
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="font-medium">
|
||||||
|
<Trans>Authenticator app</Trans>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
<Trans>Use your authenticator app to generate a code</Trans>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'code-input' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={onGoBack}>
|
||||||
|
<ArrowLeftIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-semibold">
|
||||||
|
<Trans>Enter verification code</Trans>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
{selectedMethod === 'email' ? (
|
||||||
|
<Trans>
|
||||||
|
We've sent a 6-digit verification code to your email. Please enter it below to
|
||||||
|
complete the document.
|
||||||
|
</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>
|
||||||
|
Please open your authenticator app and enter the 6-digit code for this document.
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
id="access-auth-2fa-form"
|
||||||
|
className="space-y-4"
|
||||||
|
onSubmit={form.handleSubmit(onFormSubmit)}
|
||||||
|
>
|
||||||
|
<fieldset disabled={isRequesting2FAEmail || form.formState.isSubmitting}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="token"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1 items-center justify-center">
|
||||||
|
<PinInput
|
||||||
|
{...field}
|
||||||
|
maxLength={6}
|
||||||
|
autoFocus
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="^\d+$"
|
||||||
|
aria-label="2FA code"
|
||||||
|
containerClassName="h-12 justify-center"
|
||||||
|
>
|
||||||
|
<PinInputGroup>
|
||||||
|
<PinInputSlot className="h-12 w-12 text-lg" index={0} />
|
||||||
|
<PinInputSlot className="h-12 w-12 text-lg" index={1} />
|
||||||
|
<PinInputSlot className="h-12 w-12 text-lg" index={2} />
|
||||||
|
<PinInputSlot className="h-12 w-12 text-lg" index={3} />
|
||||||
|
<PinInputSlot className="h-12 w-12 text-lg" index={4} />
|
||||||
|
<PinInputSlot className="h-12 w-12 text-lg" index={5} />
|
||||||
|
</PinInputGroup>
|
||||||
|
</PinInput>
|
||||||
|
|
||||||
|
{expiresAt && millisecondsRemaining !== null && (
|
||||||
|
<div
|
||||||
|
className={cn('text-muted-foreground mt-2 text-center text-sm', {
|
||||||
|
'text-destructive': millisecondsRemaining <= 0,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Trans>
|
||||||
|
Expires in{' '}
|
||||||
|
{DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat(
|
||||||
|
'mm:ss',
|
||||||
|
)}
|
||||||
|
</Trans>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="access-auth-2fa-form"
|
||||||
|
className="w-full"
|
||||||
|
disabled={!form.formState.isValid}
|
||||||
|
loading={isRequesting2FAEmail || form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
<Trans>Verify & Complete</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{selectedMethod === 'email' && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
onClick={onResendEmail}
|
||||||
|
loading={isRequesting2FAEmail}
|
||||||
|
>
|
||||||
|
<Trans>Resend code</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,83 @@
|
|||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { ExternalLink, PaperclipIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
|
||||||
|
|
||||||
|
export type DocumentSigningAttachmentsPopoverProps = {
|
||||||
|
envelopeId: string;
|
||||||
|
token: string;
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DocumentSigningAttachmentsPopover = ({
|
||||||
|
envelopeId,
|
||||||
|
token,
|
||||||
|
trigger,
|
||||||
|
}: DocumentSigningAttachmentsPopoverProps) => {
|
||||||
|
const { data: attachments } = trpc.envelope.attachment.find.useQuery({
|
||||||
|
envelopeId,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!attachments || attachments.data.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
{trigger ?? (
|
||||||
|
<Button variant="outline" className="gap-2">
|
||||||
|
<PaperclipIcon className="h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
<Trans>Attachments</Trans>{' '}
|
||||||
|
{attachments && attachments.data.length > 0 && (
|
||||||
|
<span className="ml-1">({attachments.data.length})</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</PopoverTrigger>
|
||||||
|
|
||||||
|
<PopoverContent className="w-96" align="start">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium">
|
||||||
|
<Trans>Attachments</Trans>
|
||||||
|
</h4>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
|
<Trans>Documents and resources related to this envelope.</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{attachments?.data.map((attachment) => (
|
||||||
|
<a
|
||||||
|
key={attachment.id}
|
||||||
|
href={attachment.data}
|
||||||
|
title={attachment.data}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="border-border hover:bg-muted/50 group flex items-center justify-between rounded-md border px-3 py-2.5 transition duration-200"
|
||||||
|
>
|
||||||
|
<div className="flex flex-1 items-center gap-2.5">
|
||||||
|
<div className="bg-muted rounded p-2">
|
||||||
|
<PaperclipIcon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="text-muted-foreground hover:text-foreground block truncate text-sm underline">
|
||||||
|
{attachment.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ExternalLink className="h-4 w-4 opacity-0 transition duration-200 group-hover:opacity-100" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -22,7 +22,7 @@ export const DocumentSigningAuthAccount = ({
|
|||||||
actionVerb = 'sign',
|
actionVerb = 'sign',
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: DocumentSigningAuthAccountProps) => {
|
}: DocumentSigningAuthAccountProps) => {
|
||||||
const { recipient } = useRequiredDocumentSigningAuthContext();
|
const { recipient, isDirectTemplate } = useRequiredDocumentSigningAuthContext();
|
||||||
|
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
@ -34,8 +34,10 @@ 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#email=${email}`,
|
redirectPath: `/signin?returnTo=${encodeURIComponent(currentPath)}#embedded=true&email=${isDirectTemplate ? '' : email}`,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
setIsSigningOut(false);
|
setIsSigningOut(false);
|
||||||
@ -55,16 +57,28 @@ export const DocumentSigningAuthAccount = ({
|
|||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
{actionTarget === 'DOCUMENT' && recipient.role === RecipientRole.VIEWER ? (
|
{actionTarget === 'DOCUMENT' && recipient.role === RecipientRole.VIEWER ? (
|
||||||
<span>
|
<span>
|
||||||
|
{isDirectTemplate ? (
|
||||||
|
<Trans>To mark this document as viewed, you need to be logged in.</Trans>
|
||||||
|
) : (
|
||||||
<Trans>
|
<Trans>
|
||||||
To mark this document as viewed, you need to be logged in as{' '}
|
To mark this document as viewed, you need to be logged in as{' '}
|
||||||
<strong>{recipient.email}</strong>
|
<strong>{recipient.email}</strong>
|
||||||
</Trans>
|
</Trans>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span>
|
<span>
|
||||||
{/* Todo: Translate */}
|
{isDirectTemplate ? (
|
||||||
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be logged
|
<Trans>
|
||||||
in as <strong>{recipient.email}</strong>
|
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be
|
||||||
|
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,7 +47,8 @@ export const DocumentSigningAuthDialog = ({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
onReauthFormSubmit,
|
onReauthFormSubmit,
|
||||||
}: DocumentSigningAuthDialogProps) => {
|
}: DocumentSigningAuthDialogProps) => {
|
||||||
const { recipient, user, isCurrentlyAuthenticating } = useRequiredDocumentSigningAuthContext();
|
const { recipient, user, isCurrentlyAuthenticating, isDirectTemplate } =
|
||||||
|
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(
|
||||||
@ -168,7 +169,11 @@ 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">
|
||||||
|
{email ? (
|
||||||
<Trans>
|
<Trans>
|
||||||
You need to be logged in as <strong>{email}</strong> to view this page.
|
You need to be logged in as <strong>{email}</strong> to view this page.
|
||||||
</Trans>
|
</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>You need to be logged in to view this page.</Trans>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { type Document, FieldType, type Passkey, type Recipient } from '@prisma/client';
|
import { type Envelope, FieldType, type Passkey, type Recipient } from '@prisma/client';
|
||||||
|
|
||||||
import type { SessionUser } from '@documenso/auth/server/lib/session/session';
|
import type { SessionUser } from '@documenso/auth/server/lib/session/session';
|
||||||
import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth';
|
import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth';
|
||||||
@ -24,17 +24,23 @@ type PasskeyData = {
|
|||||||
isError: boolean;
|
isError: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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>;
|
||||||
documentAuthOptions: Document['authOptions'];
|
documentAuthOptions: Envelope['authOptions'];
|
||||||
documentAuthOption: TDocumentAuthOptions;
|
documentAuthOption: TDocumentAuthOptions;
|
||||||
setDocumentAuthOptions: (_value: Document['authOptions']) => void;
|
setDocumentAuthOptions: (_value: Envelope['authOptions']) => void;
|
||||||
recipient: Recipient;
|
recipient: SigningAuthRecipient;
|
||||||
recipientAuthOption: TRecipientAuthOptions;
|
recipientAuthOption: TRecipientAuthOptions;
|
||||||
setRecipient: (_value: Recipient) => void;
|
setRecipient: (_value: SigningAuthRecipient) => void;
|
||||||
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;
|
||||||
@ -61,8 +67,9 @@ export const useRequiredDocumentSigningAuthContext = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export interface DocumentSigningAuthProviderProps {
|
export interface DocumentSigningAuthProviderProps {
|
||||||
documentAuthOptions: Document['authOptions'];
|
documentAuthOptions: Envelope['authOptions'];
|
||||||
recipient: Recipient;
|
recipient: SigningAuthRecipient;
|
||||||
|
isDirectTemplate?: boolean;
|
||||||
user?: SessionUser | null;
|
user?: SessionUser | null;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
@ -70,6 +77,7 @@ 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) => {
|
||||||
@ -199,6 +207,7 @@ export const DocumentSigningAuthProvider = ({
|
|||||||
derivedRecipientAccessAuth,
|
derivedRecipientAccessAuth,
|
||||||
derivedRecipientActionAuth,
|
derivedRecipientActionAuth,
|
||||||
isAuthRedirectRequired,
|
isAuthRedirectRequired,
|
||||||
|
isDirectTemplate,
|
||||||
isCurrentlyAuthenticating,
|
isCurrentlyAuthenticating,
|
||||||
setIsCurrentlyAuthenticating,
|
setIsCurrentlyAuthenticating,
|
||||||
passkeyData,
|
passkeyData,
|
||||||
|
|||||||
@ -1,19 +1,26 @@
|
|||||||
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 } 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';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import {
|
||||||
|
type TRecipientAccessAuth,
|
||||||
|
ZDocumentAccessAuthSchema,
|
||||||
|
} from '@documenso/lib/types/document-auth';
|
||||||
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
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';
|
||||||
@ -27,43 +34,76 @@ import {
|
|||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
|
||||||
|
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
|
||||||
|
import { AccessAuth2FAForm } from '~/components/general/document-signing/access-auth-2fa-form';
|
||||||
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
|
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
|
||||||
|
|
||||||
|
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||||
|
|
||||||
export type DocumentSigningCompleteDialogProps = {
|
export type DocumentSigningCompleteDialogProps = {
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
documentTitle: string;
|
documentTitle: string;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
fieldsValidated: () => void | Promise<void>;
|
fieldsValidated: () => void | Promise<void>;
|
||||||
onSignatureComplete: (nextSigner?: { name: string; email: string }) => void | Promise<void>;
|
onSignatureComplete: (
|
||||||
role: RecipientRole;
|
nextSigner?: { name: string; email: string },
|
||||||
|
accessAuthOptions?: TRecipientAccessAuth,
|
||||||
|
directRecipient?: { name: string; email: string },
|
||||||
|
) => void | Promise<void>;
|
||||||
|
recipient: Pick<Recipient, 'name' | 'email' | 'role' | 'token'>;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
allowDictateNextSigner?: boolean;
|
allowDictateNextSigner?: boolean;
|
||||||
defaultNextSigner?: {
|
defaultNextSigner?: {
|
||||||
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({
|
||||||
name: z.string().min(1, 'Name is required'),
|
name: z.string().min(1, 'Name is required'),
|
||||||
email: z.string().email('Invalid email address'),
|
email: z.string().email('Invalid email address'),
|
||||||
|
accessAuthOptions: ZDocumentAccessAuthSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
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,
|
||||||
fields,
|
fields,
|
||||||
fieldsValidated,
|
fieldsValidated,
|
||||||
onSignatureComplete,
|
onSignatureComplete,
|
||||||
role,
|
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 [twoFactorValidationError, setTwoFactorValidationError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { derivedRecipientAccessAuth } = useRequiredDocumentSigningAuthContext();
|
||||||
|
|
||||||
|
const { isNameLocked, isEmailLocked } = useEmbedSigningContext() || {};
|
||||||
|
|
||||||
const form = useForm<TNextSignerFormSchema>({
|
const form = useForm<TNextSignerFormSchema>({
|
||||||
resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined,
|
resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined,
|
||||||
@ -73,8 +113,21 @@ 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(
|
||||||
|
() => derivedRecipientAccessAuth.includes('TWO_FACTOR_AUTH'),
|
||||||
|
[derivedRecipientAccessAuth],
|
||||||
|
);
|
||||||
|
|
||||||
const handleOpenChange = (open: boolean) => {
|
const handleOpenChange = (open: boolean) => {
|
||||||
if (form.formState.isSubmitting || !isComplete) {
|
if (form.formState.isSubmitting || !isComplete) {
|
||||||
return;
|
return;
|
||||||
@ -87,23 +140,59 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsEditingNextSigner(false);
|
|
||||||
setShowDialog(open);
|
setShowDialog(open);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFormSubmit = async (data: TNextSignerFormSchema) => {
|
const onFormSubmit = async (data: TNextSignerFormSchema) => {
|
||||||
try {
|
try {
|
||||||
if (allowDictateNextSigner && data.name && data.email) {
|
let directRecipient: { name: string; email: string } | undefined;
|
||||||
await onSignatureComplete({ name: data.name, email: data.email });
|
|
||||||
} else {
|
if (directTemplatePayload && !directTemplatePayload.email) {
|
||||||
await onSignatureComplete();
|
const isFormValid = await directRecipientForm.trigger();
|
||||||
|
|
||||||
|
if (!isFormValid) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
directRecipient = directRecipientForm.getValues();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if 2FA is required
|
||||||
|
if (completionRequires2FA && !data.accessAuthOptions) {
|
||||||
|
setShowTwoFactorForm(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextSigner =
|
||||||
|
allowDictateNextSigner && data.name && data.email
|
||||||
|
? { name: data.name, email: data.email }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
await onSignatureComplete(nextSigner, data.accessAuthOptions, directRecipient);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error completing signature:', error);
|
const err = AppError.parseError(error);
|
||||||
|
|
||||||
|
if (AppErrorCode.TWO_FACTOR_AUTH_FAILED === err.code) {
|
||||||
|
// This was a 2FA validation failure - show the 2FA dialog again with error
|
||||||
|
form.setValue('accessAuthOptions', undefined);
|
||||||
|
|
||||||
|
setTwoFactorValidationError('Invalid verification code. Please try again.');
|
||||||
|
setShowTwoFactorForm(true);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isNextSignerValid = !allowDictateNextSigner || (form.watch('name') && form.watch('email'));
|
const onTwoFactorFormSubmit = (validatedAuthOptions: TRecipientAccessAuth) => {
|
||||||
|
form.setValue('accessAuthOptions', validatedAuthOptions);
|
||||||
|
|
||||||
|
setShowTwoFactorForm(false);
|
||||||
|
setTwoFactorValidationError(null);
|
||||||
|
|
||||||
|
// Now trigger the form submission with auth options
|
||||||
|
void form.handleSubmit(onFormSubmit)();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={showDialog} onOpenChange={handleOpenChange}>
|
<Dialog open={showDialog} onOpenChange={handleOpenChange}>
|
||||||
@ -111,13 +200,13 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
<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 })
|
{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>
|
||||||
@ -127,105 +216,103 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent position={position}>
|
||||||
<Form {...form}>
|
<DialogHeader>
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
|
||||||
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
|
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<div className="text-foreground text-xl font-semibold">
|
<Trans>Are you sure?</Trans>
|
||||||
{match(role)
|
|
||||||
.with(RecipientRole.VIEWER, () => <Trans>Complete Viewing</Trans>)
|
|
||||||
.with(RecipientRole.SIGNER, () => <Trans>Complete Signing</Trans>)
|
|
||||||
.with(RecipientRole.APPROVER, () => <Trans>Complete Approval</Trans>)
|
|
||||||
.with(RecipientRole.CC, () => <Trans>Complete Viewing</Trans>)
|
|
||||||
.with(RecipientRole.ASSISTANT, () => <Trans>Complete Assisting</Trans>)
|
|
||||||
.exhaustive()}
|
|
||||||
</div>
|
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
<div className="text-muted-foreground max-w-[50ch]">
|
<div className="text-muted-foreground max-w-[50ch]">
|
||||||
{match(role)
|
{match(recipient.role)
|
||||||
.with(RecipientRole.VIEWER, () => (
|
.with(RecipientRole.VIEWER, () => (
|
||||||
<span>
|
|
||||||
<Trans>
|
|
||||||
<span className="inline-flex flex-wrap">
|
<span className="inline-flex flex-wrap">
|
||||||
You are about to complete viewing "
|
<Trans>You are about to complete viewing the following document</Trans>
|
||||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
|
||||||
{documentTitle}
|
|
||||||
</span>
|
|
||||||
".
|
|
||||||
</span>
|
|
||||||
<br /> Are you sure?
|
|
||||||
</Trans>
|
|
||||||
</span>
|
</span>
|
||||||
))
|
))
|
||||||
.with(RecipientRole.SIGNER, () => (
|
.with(RecipientRole.SIGNER, () => (
|
||||||
<span>
|
|
||||||
<Trans>
|
|
||||||
<span className="inline-flex flex-wrap">
|
<span className="inline-flex flex-wrap">
|
||||||
You are about to complete signing "
|
<Trans>You are about to complete signing the following document</Trans>
|
||||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
|
||||||
{documentTitle}
|
|
||||||
</span>
|
|
||||||
".
|
|
||||||
</span>
|
|
||||||
<br /> Are you sure?
|
|
||||||
</Trans>
|
|
||||||
</span>
|
</span>
|
||||||
))
|
))
|
||||||
.with(RecipientRole.APPROVER, () => (
|
.with(RecipientRole.APPROVER, () => (
|
||||||
<span>
|
|
||||||
<Trans>
|
|
||||||
<span className="inline-flex flex-wrap">
|
<span className="inline-flex flex-wrap">
|
||||||
You are about to complete approving{' '}
|
<Trans>You are about to complete approving the following document</Trans>
|
||||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
|
||||||
"{documentTitle}"
|
|
||||||
</span>
|
|
||||||
.
|
|
||||||
</span>
|
|
||||||
<br /> Are you sure?
|
|
||||||
</Trans>
|
|
||||||
</span>
|
</span>
|
||||||
))
|
))
|
||||||
.otherwise(() => (
|
.with(RecipientRole.ASSISTANT, () => (
|
||||||
<span>
|
|
||||||
<Trans>
|
|
||||||
<span className="inline-flex flex-wrap">
|
<span className="inline-flex flex-wrap">
|
||||||
You are about to complete viewing "
|
<Trans>You are about to complete assisting the following document</Trans>
|
||||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
|
||||||
{documentTitle}
|
|
||||||
</span>
|
</span>
|
||||||
".
|
))
|
||||||
</span>
|
.with(RecipientRole.CC, () => null)
|
||||||
<br /> Are you sure?
|
.exhaustive()}
|
||||||
</Trans>
|
</div>
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
{allowDictateNextSigner && (
|
{!showTwoFactorForm && (
|
||||||
<div className="mt-4 flex flex-col gap-4">
|
<>
|
||||||
{!isEditingNextSigner && (
|
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
|
||||||
<div>
|
{directTemplatePayload && !directTemplatePayload.email && (
|
||||||
<p className="text-muted-foreground text-sm">
|
<Form {...directRecipientForm}>
|
||||||
The next recipient to sign this document will be{' '}
|
<div className="mb-4 flex flex-col gap-4">
|
||||||
<span className="font-semibold">{form.watch('name')}</span> (
|
<div className="flex flex-col gap-4 md:flex-row">
|
||||||
<span className="font-semibold">{form.watch('email')}</span>).
|
<FormField
|
||||||
</p>
|
control={directRecipientForm.control}
|
||||||
|
name="name"
|
||||||
<Button
|
render={({ field }) => (
|
||||||
type="button"
|
<FormItem className="flex-1">
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Your Name</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
className="mt-2"
|
className="mt-2"
|
||||||
variant="outline"
|
placeholder={t`Enter your name`}
|
||||||
size="sm"
|
disabled={isNameLocked}
|
||||||
onClick={() => setIsEditingNextSigner((prev) => !prev)}
|
/>
|
||||||
>
|
</FormControl>
|
||||||
<Trans>Update Recipient</Trans>
|
|
||||||
</Button>
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={directRecipientForm.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Your Email</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="email"
|
||||||
|
className="mt-2"
|
||||||
|
placeholder={t`Enter your email`}
|
||||||
|
disabled={!!field.value && isEmailLocked}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isEditingNextSigner && (
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
|
{allowDictateNextSigner && defaultNextSigner && (
|
||||||
|
<div className="mb-4 flex flex-col gap-4">
|
||||||
<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}
|
||||||
@ -233,13 +320,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>
|
||||||
|
|
||||||
@ -254,14 +341,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 />
|
||||||
@ -269,17 +356,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}
|
||||||
@ -289,11 +373,10 @@ 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(role)
|
{match(recipient.role)
|
||||||
.with(RecipientRole.VIEWER, () => <Trans>Mark as Viewed</Trans>)
|
.with(RecipientRole.VIEWER, () => <Trans>Mark as Viewed</Trans>)
|
||||||
.with(RecipientRole.SIGNER, () => <Trans>Sign</Trans>)
|
.with(RecipientRole.SIGNER, () => <Trans>Sign</Trans>)
|
||||||
.with(RecipientRole.APPROVER, () => <Trans>Approve</Trans>)
|
.with(RecipientRole.APPROVER, () => <Trans>Approve</Trans>)
|
||||||
@ -301,11 +384,20 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
.with(RecipientRole.ASSISTANT, () => <Trans>Complete</Trans>)
|
.with(RecipientRole.ASSISTANT, () => <Trans>Complete</Trans>)
|
||||||
.exhaustive()}
|
.exhaustive()}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</fieldset>
|
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
</fieldset>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showTwoFactorForm && (
|
||||||
|
<AccessAuth2FAForm
|
||||||
|
token={recipient.token}
|
||||||
|
error={twoFactorValidationError}
|
||||||
|
onSubmit={onTwoFactorFormSubmit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import { Controller, useForm } from 'react-hook-form';
|
|||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
|
||||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
import { sortFieldsByPosition } from '@documenso/lib/utils/fields';
|
import { sortFieldsByPosition } from '@documenso/lib/utils/fields';
|
||||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||||
@ -34,10 +34,10 @@ export type DocumentSigningFormProps = {
|
|||||||
isRecipientsTurn: boolean;
|
isRecipientsTurn: boolean;
|
||||||
allRecipients?: RecipientWithFields[];
|
allRecipients?: RecipientWithFields[];
|
||||||
setSelectedSignerId?: (id: number | null) => void;
|
setSelectedSignerId?: (id: number | null) => void;
|
||||||
completeDocument: (
|
completeDocument: (options: {
|
||||||
authOptions?: TRecipientActionAuth,
|
accessAuthOptions?: TRecipientAccessAuth;
|
||||||
nextSigner?: { email: string; name: string },
|
nextSigner?: { email: string; name: string };
|
||||||
) => Promise<void>;
|
}) => Promise<void>;
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
fieldsValidated: () => void;
|
fieldsValidated: () => void;
|
||||||
nextRecipient?: RecipientWithFields;
|
nextRecipient?: RecipientWithFields;
|
||||||
@ -105,7 +105,7 @@ export const DocumentSigningForm = ({
|
|||||||
setIsAssistantSubmitting(true);
|
setIsAssistantSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await completeDocument(undefined, nextSigner);
|
await completeDocument({ nextSigner });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
@ -149,10 +149,10 @@ export const DocumentSigningForm = ({
|
|||||||
documentTitle={document.title}
|
documentTitle={document.title}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
fieldsValidated={localFieldsValidated}
|
fieldsValidated={localFieldsValidated}
|
||||||
onSignatureComplete={async (nextSigner) => {
|
onSignatureComplete={async (nextSigner, accessAuthOptions) =>
|
||||||
await completeDocument(undefined, nextSigner);
|
completeDocument({ nextSigner, accessAuthOptions })
|
||||||
}}
|
}
|
||||||
role={recipient.role}
|
recipient={recipient}
|
||||||
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
|
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
|
||||||
defaultNextSigner={
|
defaultNextSigner={
|
||||||
nextRecipient
|
nextRecipient
|
||||||
@ -309,10 +309,13 @@ export const DocumentSigningForm = ({
|
|||||||
fields={fields}
|
fields={fields}
|
||||||
fieldsValidated={localFieldsValidated}
|
fieldsValidated={localFieldsValidated}
|
||||||
disabled={!isRecipientsTurn}
|
disabled={!isRecipientsTurn}
|
||||||
onSignatureComplete={async (nextSigner) => {
|
onSignatureComplete={async (nextSigner, accessAuthOptions) =>
|
||||||
await completeDocument(undefined, nextSigner);
|
completeDocument({
|
||||||
}}
|
accessAuthOptions,
|
||||||
role={recipient.role}
|
nextSigner,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
recipient={recipient}
|
||||||
allowDictateNextSigner={
|
allowDictateNextSigner={
|
||||||
nextRecipient && document.documentMeta?.allowDictateNextSigner
|
nextRecipient && document.documentMeta?.allowDictateNextSigner
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,135 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { Plural, Trans } from '@lingui/react/macro';
|
||||||
|
import { RecipientRole } from '@prisma/client';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
|
||||||
|
|
||||||
|
import { BrandingLogo } from '../branding-logo';
|
||||||
|
import EnvelopeSignerForm from '../envelope-signing/envelope-signer-form';
|
||||||
|
import { EnvelopeSignerCompleteDialog } from '../envelope-signing/envelope-signing-complete-dialog';
|
||||||
|
import { useRequiredEnvelopeSigningContext } from './envelope-signing-provider';
|
||||||
|
|
||||||
|
export const DocumentSigningMobileWidget = () => {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
|
const { hidePoweredBy = true } = useEmbedSigningContext() || {};
|
||||||
|
|
||||||
|
const { recipientFieldsRemaining, recipient, requiredRecipientFields } =
|
||||||
|
useRequiredEnvelopeSigningContext();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre open the widget for assistants to let them know it's there.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (recipient.role === RecipientRole.ASSISTANT) {
|
||||||
|
setIsExpanded(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none fixed bottom-0 left-0 right-0 z-50 flex justify-center px-2 pb-2 sm:px-4 sm:pb-6">
|
||||||
|
<div className="pointer-events-auto w-full max-w-[760px]">
|
||||||
|
<div className="bg-card border-border overflow-hidden rounded-xl border shadow-2xl">
|
||||||
|
{/* Main Header Bar */}
|
||||||
|
<div className="flex items-center justify-between gap-4 p-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{recipient.role !== RecipientRole.VIEWER && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
className="flex h-8 w-8 items-center justify-center"
|
||||||
|
aria-label={isExpanded ? 'Collapse' : 'Expand'}
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<LucideChevronDown className="text-muted-foreground h-5 w-5 flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<LucideChevronUp className="text-muted-foreground h-5 w-5 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-foreground text-lg font-semibold">
|
||||||
|
{match(recipient.role)
|
||||||
|
.with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>)
|
||||||
|
.with(RecipientRole.SIGNER, () => <Trans>Sign Document</Trans>)
|
||||||
|
.with(RecipientRole.APPROVER, () => <Trans>Approve Document</Trans>)
|
||||||
|
.with(RecipientRole.ASSISTANT, () => <Trans>Assist Document</Trans>)
|
||||||
|
.otherwise(() => null)}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground -mt-0.5 text-sm">
|
||||||
|
{recipientFieldsRemaining.length === 0 ? (
|
||||||
|
match(recipient.role)
|
||||||
|
.with(RecipientRole.VIEWER, () => (
|
||||||
|
<Trans>Please mark as viewed to complete</Trans>
|
||||||
|
))
|
||||||
|
.with(RecipientRole.SIGNER, () => (
|
||||||
|
<Trans>Please complete the document once reviewed</Trans>
|
||||||
|
))
|
||||||
|
.with(RecipientRole.APPROVER, () => (
|
||||||
|
<Trans>Please complete the document once reviewed</Trans>
|
||||||
|
))
|
||||||
|
.with(RecipientRole.ASSISTANT, () => (
|
||||||
|
<Trans>Please complete the document once reviewed</Trans>
|
||||||
|
))
|
||||||
|
.otherwise(() => null)
|
||||||
|
) : (
|
||||||
|
<Plural
|
||||||
|
value={recipientFieldsRemaining.length}
|
||||||
|
one="1 Field Remaining"
|
||||||
|
other="# Fields Remaining"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<EnvelopeSignerCompleteDialog />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
{recipient.role !== RecipientRole.VIEWER &&
|
||||||
|
recipient.role !== RecipientRole.ASSISTANT && (
|
||||||
|
<div className="px-4 pb-3">
|
||||||
|
<div className="bg-muted relative h-[4px] rounded-md">
|
||||||
|
<motion.div
|
||||||
|
layout="size"
|
||||||
|
layoutId="document-signing-mobile-widget-progress-bar"
|
||||||
|
className="bg-documenso absolute inset-y-0 left-0"
|
||||||
|
style={{
|
||||||
|
width: `${100 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Expandable Content */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="border-border animate-in slide-in-from-bottom-2 border-t p-4 duration-200">
|
||||||
|
<EnvelopeSignerForm />
|
||||||
|
|
||||||
|
{!hidePoweredBy && (
|
||||||
|
<div className="bg-primary text-primary-foreground mt-2 inline-block rounded px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100 lg:hidden">
|
||||||
|
<span>Powered by</span>
|
||||||
|
<BrandingLogo className="ml-2 inline-block h-[14px]" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -12,7 +12,7 @@ import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-form
|
|||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||||
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
|
||||||
import {
|
import {
|
||||||
ZCheckboxFieldMeta,
|
ZCheckboxFieldMeta,
|
||||||
ZDropdownFieldMeta,
|
ZDropdownFieldMeta,
|
||||||
@ -32,6 +32,7 @@ import { Card, CardContent } from '@documenso/ui/primitives/card';
|
|||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||||
|
|
||||||
|
import { DocumentSigningAttachmentsPopover } from '~/components/general/document-signing/document-signing-attachments-popover';
|
||||||
import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign';
|
import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign';
|
||||||
import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field';
|
import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field';
|
||||||
import { DocumentSigningDateField } from '~/components/general/document-signing/document-signing-date-field';
|
import { DocumentSigningDateField } from '~/components/general/document-signing/document-signing-date-field';
|
||||||
@ -46,10 +47,11 @@ import { DocumentSigningRejectDialog } from '~/components/general/document-signi
|
|||||||
import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field';
|
import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field';
|
||||||
import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field';
|
import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field';
|
||||||
|
|
||||||
|
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||||
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
|
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
|
||||||
import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider';
|
import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider';
|
||||||
|
|
||||||
export type DocumentSigningPageViewProps = {
|
export type DocumentSigningPageViewV1Props = {
|
||||||
recipient: RecipientWithFields;
|
recipient: RecipientWithFields;
|
||||||
document: DocumentAndSender;
|
document: DocumentAndSender;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
@ -59,7 +61,7 @@ export type DocumentSigningPageViewProps = {
|
|||||||
includeSenderDetails: boolean;
|
includeSenderDetails: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentSigningPageView = ({
|
export const DocumentSigningPageViewV1 = ({
|
||||||
recipient,
|
recipient,
|
||||||
document,
|
document,
|
||||||
fields,
|
fields,
|
||||||
@ -67,9 +69,15 @@ export const DocumentSigningPageView = ({
|
|||||||
isRecipientsTurn,
|
isRecipientsTurn,
|
||||||
allRecipients = [],
|
allRecipients = [],
|
||||||
includeSenderDetails,
|
includeSenderDetails,
|
||||||
}: DocumentSigningPageViewProps) => {
|
}: DocumentSigningPageViewV1Props) => {
|
||||||
const { documentData, documentMeta } = document;
|
const { documentData, documentMeta } = document;
|
||||||
|
|
||||||
|
const { derivedRecipientAccessAuth, user: authUser } = useRequiredDocumentSigningAuthContext();
|
||||||
|
|
||||||
|
const hasAuthenticator = authUser?.twoFactorEnabled
|
||||||
|
? authUser.twoFactorEnabled && authUser.email === recipient.email
|
||||||
|
: false;
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const analytics = useAnalytics();
|
const analytics = useAnalytics();
|
||||||
|
|
||||||
@ -94,14 +102,16 @@ export const DocumentSigningPageView = ({
|
|||||||
validateFieldsInserted(fieldsRequiringValidation);
|
validateFieldsInserted(fieldsRequiringValidation);
|
||||||
};
|
};
|
||||||
|
|
||||||
const completeDocument = async (
|
const completeDocument = async (options: {
|
||||||
authOptions?: TRecipientActionAuth,
|
accessAuthOptions?: TRecipientAccessAuth;
|
||||||
nextSigner?: { email: string; name: string },
|
nextSigner?: { email: string; name: string };
|
||||||
) => {
|
}) => {
|
||||||
|
const { accessAuthOptions, nextSigner } = options;
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
authOptions,
|
accessAuthOptions,
|
||||||
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
|
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -160,6 +170,14 @@ export const DocumentSigningPageView = ({
|
|||||||
return (
|
return (
|
||||||
<DocumentSigningRecipientProvider recipient={recipient} targetSigner={targetSigner}>
|
<DocumentSigningRecipientProvider recipient={recipient} targetSigner={targetSigner}>
|
||||||
<div className="mx-auto w-full max-w-screen-xl sm:px-6">
|
<div className="mx-auto w-full max-w-screen-xl sm:px-6">
|
||||||
|
{document.team.teamGlobalSettings.brandingEnabled &&
|
||||||
|
document.team.teamGlobalSettings.brandingLogo && (
|
||||||
|
<img
|
||||||
|
src={`/api/branding/logo/team/${document.teamId}`}
|
||||||
|
alt={`${document.team.name}'s Logo`}
|
||||||
|
className="mb-4 h-12 w-12 md:mb-2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<h1
|
<h1
|
||||||
className="block max-w-[20rem] truncate text-2xl font-semibold sm:mt-4 md:max-w-[30rem] md:text-3xl"
|
className="block max-w-[20rem] truncate text-2xl font-semibold sm:mt-4 md:max-w-[30rem] md:text-3xl"
|
||||||
title={document.title}
|
title={document.title}
|
||||||
@ -214,14 +232,25 @@ export const DocumentSigningPageView = ({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DocumentSigningRejectDialog document={document} token={recipient.token} />
|
<div className="flex items-center gap-x-4">
|
||||||
|
<DocumentSigningAttachmentsPopover
|
||||||
|
envelopeId={document.envelopeId}
|
||||||
|
token={recipient.token}
|
||||||
|
/>
|
||||||
|
<DocumentSigningRejectDialog documentId={document.id} token={recipient.token} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative mt-4 flex w-full flex-col gap-x-6 gap-y-8 sm:mt-8 md:flex-row lg:gap-x-8 lg:gap-y-0">
|
<div className="relative mt-4 flex w-full flex-col gap-x-6 gap-y-8 sm:mt-8 md:flex-row lg:gap-x-8 lg:gap-y-0">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Card className="rounded-xl before:rounded-xl" gradient>
|
<Card className="rounded-xl before:rounded-xl" gradient>
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<PDFViewer key={documentData.id} documentData={documentData} document={document} />
|
<PDFViewer
|
||||||
|
key={document.envelopeItems[0].id}
|
||||||
|
envelopeItem={document.envelopeItems[0]}
|
||||||
|
token={recipient.token}
|
||||||
|
version="signed"
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@ -257,10 +286,10 @@ export const DocumentSigningPageView = ({
|
|||||||
fields={fields}
|
fields={fields}
|
||||||
fieldsValidated={fieldsValidated}
|
fieldsValidated={fieldsValidated}
|
||||||
disabled={!isRecipientsTurn}
|
disabled={!isRecipientsTurn}
|
||||||
onSignatureComplete={async (nextSigner) => {
|
onSignatureComplete={async (nextSigner) =>
|
||||||
await completeDocument(undefined, nextSigner);
|
completeDocument({ nextSigner })
|
||||||
}}
|
}
|
||||||
role={recipient.role}
|
recipient={recipient}
|
||||||
allowDictateNextSigner={
|
allowDictateNextSigner={
|
||||||
nextRecipient && documentMeta?.allowDictateNextSigner
|
nextRecipient && documentMeta?.allowDictateNextSigner
|
||||||
}
|
}
|
||||||
@ -0,0 +1,265 @@
|
|||||||
|
import { lazy, useMemo } from 'react';
|
||||||
|
|
||||||
|
import { Plural, Trans } from '@lingui/react/macro';
|
||||||
|
import { EnvelopeType, RecipientRole } from '@prisma/client';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { ArrowLeftIcon, BanIcon, DownloadCloudIcon, PaperclipIcon } from 'lucide-react';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||||
|
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||||
|
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
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 { SignFieldEmailDialog } from '~/components/dialogs/sign-field-email-dialog';
|
||||||
|
import { SignFieldInitialsDialog } from '~/components/dialogs/sign-field-initials-dialog';
|
||||||
|
import { SignFieldNameDialog } from '~/components/dialogs/sign-field-name-dialog';
|
||||||
|
import { SignFieldNumberDialog } from '~/components/dialogs/sign-field-number-dialog';
|
||||||
|
import { SignFieldSignatureDialog } from '~/components/dialogs/sign-field-signature-dialog';
|
||||||
|
import { SignFieldTextDialog } from '~/components/dialogs/sign-field-text-dialog';
|
||||||
|
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
|
||||||
|
|
||||||
|
import { BrandingLogo } from '../branding-logo';
|
||||||
|
import { DocumentSigningAttachmentsPopover } from '../document-signing/document-signing-attachments-popover';
|
||||||
|
import { EnvelopeItemSelector } from '../envelope-editor/envelope-file-selector';
|
||||||
|
import EnvelopeSignerForm from '../envelope-signing/envelope-signer-form';
|
||||||
|
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';
|
||||||
|
|
||||||
|
const EnvelopeSignerPageRenderer = lazy(
|
||||||
|
async () => import('../envelope-signing/envelope-signer-page-renderer'),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const DocumentSigningPageViewV2 = () => {
|
||||||
|
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||||
|
|
||||||
|
const {
|
||||||
|
isDirectTemplate,
|
||||||
|
envelope,
|
||||||
|
recipient,
|
||||||
|
recipientFields,
|
||||||
|
recipientFieldsRemaining,
|
||||||
|
requiredRecipientFields,
|
||||||
|
selectedAssistantRecipientFields,
|
||||||
|
} = useRequiredEnvelopeSigningContext();
|
||||||
|
|
||||||
|
const {
|
||||||
|
isEmbed = false,
|
||||||
|
allowDocumentRejection = true,
|
||||||
|
hidePoweredBy = true,
|
||||||
|
onDocumentRejected,
|
||||||
|
} = useEmbedSigningContext() || {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The total remaining fields remaining for the current recipient or selected assistant recipient.
|
||||||
|
*
|
||||||
|
* Includes both optional and required fields.
|
||||||
|
*/
|
||||||
|
const remainingFields = useMemo(() => {
|
||||||
|
if (recipient.role === RecipientRole.ASSISTANT) {
|
||||||
|
return selectedAssistantRecipientFields.filter((field) => !field.inserted);
|
||||||
|
}
|
||||||
|
|
||||||
|
return recipientFields.filter((field) => !field.inserted);
|
||||||
|
}, [recipientFieldsRemaining, selectedAssistantRecipientFields, currentEnvelopeItem]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dark:bg-background min-h-screen w-screen bg-gray-50">
|
||||||
|
<SignFieldEmailDialog.Root />
|
||||||
|
<SignFieldTextDialog.Root />
|
||||||
|
<SignFieldNumberDialog.Root />
|
||||||
|
<SignFieldNameDialog.Root />
|
||||||
|
<SignFieldInitialsDialog.Root />
|
||||||
|
<SignFieldDropdownDialog.Root />
|
||||||
|
<SignFieldSignatureDialog.Root />
|
||||||
|
<SignFieldCheckboxDialog.Root />
|
||||||
|
|
||||||
|
<EnvelopeSignerHeader />
|
||||||
|
|
||||||
|
{/* Main Content Area */}
|
||||||
|
<div className="flex h-[calc(100vh-4rem)] w-screen">
|
||||||
|
{/* Left Section - Step Navigation */}
|
||||||
|
<div className="embed--DocumentWidgetContainer bg-background border-border hidden w-80 flex-shrink-0 flex-col overflow-y-auto border-r py-4 lg:flex">
|
||||||
|
<div className="px-4">
|
||||||
|
<h3 className="text-foreground flex items-end justify-between text-sm 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)}
|
||||||
|
|
||||||
|
<span className="text-muted-foreground bg-muted/50 ml-2 rounded border px-2 py-0.5 text-xs">
|
||||||
|
<Plural
|
||||||
|
value={recipientFieldsRemaining.length}
|
||||||
|
one="1 Field Remaining"
|
||||||
|
other="# Fields Remaining"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="bg-muted relative my-4 h-[4px] rounded-md">
|
||||||
|
<motion.div
|
||||||
|
layout="size"
|
||||||
|
layoutId="document-flow-container-step"
|
||||||
|
className="bg-documenso absolute inset-y-0 left-0"
|
||||||
|
style={{
|
||||||
|
width: `${100 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="embed--DocumentWidgetContent mt-6 space-y-3">
|
||||||
|
<EnvelopeSignerForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="my-6" />
|
||||||
|
|
||||||
|
{/* Quick Actions. */}
|
||||||
|
{!isDirectTemplate && (
|
||||||
|
<div className="embed--Actions space-y-3 px-4">
|
||||||
|
<h4 className="text-foreground text-sm font-semibold">
|
||||||
|
<Trans>Actions</Trans>
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<DocumentSigningAttachmentsPopover
|
||||||
|
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 && allowDocumentRejection && (
|
||||||
|
<DocumentSigningRejectDialog
|
||||||
|
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
|
||||||
|
token={recipient.token}
|
||||||
|
onRejected={
|
||||||
|
onDocumentRejected &&
|
||||||
|
((reason) =>
|
||||||
|
onDocumentRejected({
|
||||||
|
token: recipient.token,
|
||||||
|
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
recipientId: recipient.id,
|
||||||
|
reason,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
trigger={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="hover:text-destructive w-full justify-start"
|
||||||
|
>
|
||||||
|
<BanIcon className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Reject Document</Trans>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="embed--DocumentWidgetFooter">
|
||||||
|
{/* Footer of left sidebar. */}
|
||||||
|
{!isEmbed && (
|
||||||
|
<div className="mt-auto px-4">
|
||||||
|
<Button asChild variant="ghost" className="w-full justify-start">
|
||||||
|
<Link to="/">
|
||||||
|
<ArrowLeftIcon className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Return</Trans>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="embed--DocumentContainer flex-1 overflow-y-auto">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{/* Horizontal envelope item selector */}
|
||||||
|
{envelopeItems.length > 1 && (
|
||||||
|
<div className="flex h-fit space-x-2 overflow-x-auto p-2 pt-4 sm:p-4">
|
||||||
|
{envelopeItems.map((doc, i) => (
|
||||||
|
<EnvelopeItemSelector
|
||||||
|
key={doc.id}
|
||||||
|
number={i + 1}
|
||||||
|
primaryText={doc.title}
|
||||||
|
secondaryText={
|
||||||
|
<Plural
|
||||||
|
one="1 Field"
|
||||||
|
other="# Fields"
|
||||||
|
value={
|
||||||
|
remainingFields.filter((field) => field.envelopeItemId === doc.id).length
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
isSelected={currentEnvelopeItem?.id === doc.id}
|
||||||
|
buttonProps={{ onClick: () => setCurrentEnvelopeItem(doc.id) }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Document View */}
|
||||||
|
<div className="embed--DocumentViewer flex flex-col items-center justify-center p-2 sm:mt-4 sm:p-4">
|
||||||
|
{currentEnvelopeItem ? (
|
||||||
|
<PDFViewerKonvaLazy
|
||||||
|
renderer="signing"
|
||||||
|
key={currentEnvelopeItem.id}
|
||||||
|
customPageRenderer={EnvelopeSignerPageRenderer}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center py-32">
|
||||||
|
<p className="text-foreground text-sm">
|
||||||
|
<Trans>No documents found</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile widget - Additional padding to allow users to scroll */}
|
||||||
|
<div className="block pb-28 lg:hidden">
|
||||||
|
<DocumentSigningMobileWidget />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!hidePoweredBy && (
|
||||||
|
<a
|
||||||
|
href="https://documenso.com"
|
||||||
|
target="_blank"
|
||||||
|
className="bg-primary text-primary-foreground fixed bottom-0 right-0 z-40 hidden cursor-pointer rounded-tl px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100 lg:block"
|
||||||
|
>
|
||||||
|
<span>Powered by</span>
|
||||||
|
<BrandingLogo className="ml-2 inline-block h-[14px]" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -10,7 +10,10 @@ export interface DocumentSigningRecipientContextValue {
|
|||||||
* In regular mode, this is the actual signer.
|
* In regular mode, this is the actual signer.
|
||||||
* In assistant mode, this is the recipient who is helping fill out the document.
|
* In assistant mode, this is the recipient who is helping fill out the document.
|
||||||
*/
|
*/
|
||||||
recipient: Recipient | RecipientWithFields;
|
recipient: Pick<
|
||||||
|
Recipient | RecipientWithFields,
|
||||||
|
'name' | 'email' | 'token' | 'role' | 'authOptions'
|
||||||
|
>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Only present in assistant mode.
|
* Only present in assistant mode.
|
||||||
@ -29,7 +32,10 @@ const DocumentSigningRecipientContext = createContext<DocumentSigningRecipientCo
|
|||||||
);
|
);
|
||||||
|
|
||||||
export interface DocumentSigningRecipientProviderProps extends PropsWithChildren {
|
export interface DocumentSigningRecipientProviderProps extends PropsWithChildren {
|
||||||
recipient: Recipient | RecipientWithFields;
|
recipient: Pick<
|
||||||
|
Recipient | RecipientWithFields,
|
||||||
|
'name' | 'email' | 'token' | 'role' | 'authOptions'
|
||||||
|
>;
|
||||||
targetSigner?: RecipientWithFields | null;
|
targetSigner?: RecipientWithFields | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import { useEffect, useState } from 'react';
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { Document } from '@prisma/client';
|
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { useSearchParams } from 'react-router';
|
import { useSearchParams } from 'react-router';
|
||||||
@ -37,15 +36,17 @@ const ZRejectDocumentFormSchema = z.object({
|
|||||||
type TRejectDocumentFormSchema = z.infer<typeof ZRejectDocumentFormSchema>;
|
type TRejectDocumentFormSchema = z.infer<typeof ZRejectDocumentFormSchema>;
|
||||||
|
|
||||||
export interface DocumentSigningRejectDialogProps {
|
export interface DocumentSigningRejectDialogProps {
|
||||||
document: Pick<Document, 'id'>;
|
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({
|
||||||
document,
|
documentId,
|
||||||
token,
|
token,
|
||||||
onRejected,
|
onRejected,
|
||||||
|
trigger,
|
||||||
}: DocumentSigningRejectDialogProps) {
|
}: DocumentSigningRejectDialogProps) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -66,7 +67,7 @@ export function DocumentSigningRejectDialog({
|
|||||||
const onRejectDocument = async ({ reason }: TRejectDocumentFormSchema) => {
|
const onRejectDocument = async ({ reason }: TRejectDocumentFormSchema) => {
|
||||||
try {
|
try {
|
||||||
await rejectDocumentWithToken({
|
await rejectDocumentWithToken({
|
||||||
documentId: document.id,
|
documentId,
|
||||||
token,
|
token,
|
||||||
reason,
|
reason,
|
||||||
});
|
});
|
||||||
@ -109,9 +110,11 @@ export function DocumentSigningRejectDialog({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
|
{trigger ?? (
|
||||||
<Button variant="outline">
|
<Button variant="outline">
|
||||||
<Trans>Reject Document</Trans>
|
<Trans>Reject Document</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
|||||||
@ -0,0 +1,410 @@
|
|||||||
|
import { createContext, useContext, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
EnvelopeType,
|
||||||
|
type Field,
|
||||||
|
FieldType,
|
||||||
|
type Recipient,
|
||||||
|
RecipientRole,
|
||||||
|
SigningStatus,
|
||||||
|
} from '@prisma/client';
|
||||||
|
import { prop, sortBy } from 'remeda';
|
||||||
|
|
||||||
|
import { isBase64Image } from '@documenso/lib/constants/signatures';
|
||||||
|
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 { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||||
|
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 type { TSignEnvelopeFieldValue } from '@documenso/trpc/server/envelope-router/sign-envelope-field.types';
|
||||||
|
|
||||||
|
export type EnvelopeSigningContextValue = {
|
||||||
|
isDirectTemplate: boolean;
|
||||||
|
|
||||||
|
fullName: string;
|
||||||
|
setFullName: (_value: string) => void;
|
||||||
|
email: string;
|
||||||
|
setEmail: (_value: string) => void;
|
||||||
|
signature: string | null;
|
||||||
|
setSignature: (_value: string | null) => void;
|
||||||
|
|
||||||
|
showPendingFieldTooltip: boolean;
|
||||||
|
setShowPendingFieldTooltip: (_value: boolean) => void;
|
||||||
|
|
||||||
|
envelopeData: EnvelopeForSigningResponse;
|
||||||
|
envelope: EnvelopeForSigningResponse['envelope'];
|
||||||
|
|
||||||
|
recipient: EnvelopeForSigningResponse['recipient'];
|
||||||
|
recipientFieldsRemaining: Field[];
|
||||||
|
recipientFields: Field[];
|
||||||
|
requiredRecipientFields: Field[];
|
||||||
|
selectedAssistantRecipientFields: Field[];
|
||||||
|
nextRecipient: EnvelopeForSigningResponse['envelope']['recipients'][number] | null;
|
||||||
|
otherRecipientCompletedFields: (Field & {
|
||||||
|
recipient: Pick<Recipient, 'name' | 'email' | 'signingStatus' | 'role'>;
|
||||||
|
})[];
|
||||||
|
assistantRecipients: EnvelopeForSigningResponse['envelope']['recipients'];
|
||||||
|
assistantFields: Field[];
|
||||||
|
setSelectedAssistantRecipientId: (_value: number | null) => void;
|
||||||
|
selectedAssistantRecipient: EnvelopeForSigningResponse['envelope']['recipients'][number] | null;
|
||||||
|
|
||||||
|
signField: (
|
||||||
|
_fieldId: number,
|
||||||
|
_value: TSignEnvelopeFieldValue,
|
||||||
|
authOptions?: TRecipientActionAuth,
|
||||||
|
) => Promise<Pick<Field, 'id' | 'inserted'>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EnvelopeSigningContext = createContext<EnvelopeSigningContextValue | null>(null);
|
||||||
|
|
||||||
|
export const useEnvelopeSigningContext = () => {
|
||||||
|
return useContext(EnvelopeSigningContext);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRequiredEnvelopeSigningContext = () => {
|
||||||
|
const context = useEnvelopeSigningContext();
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('Signing context is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface EnvelopeSigningProviderProps {
|
||||||
|
fullName?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
signature?: string | null;
|
||||||
|
envelopeData: EnvelopeForSigningResponse;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EnvelopeSigningProvider = ({
|
||||||
|
fullName: initialFullName,
|
||||||
|
email: initialEmail,
|
||||||
|
signature: initialSignature,
|
||||||
|
envelopeData: initialEnvelopeData,
|
||||||
|
children,
|
||||||
|
}: EnvelopeSigningProviderProps) => {
|
||||||
|
const [envelopeData, setEnvelopeData] = useState(initialEnvelopeData);
|
||||||
|
|
||||||
|
const { envelope, recipient } = envelopeData;
|
||||||
|
|
||||||
|
const [fullName, setFullName] = useState(initialFullName || '');
|
||||||
|
const [email, setEmail] = useState(initialEmail || '');
|
||||||
|
|
||||||
|
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
||||||
|
|
||||||
|
const isDirectTemplate = envelope.type === EnvelopeType.TEMPLATE;
|
||||||
|
|
||||||
|
const { mutateAsync: signEnvelopeField } = trpc.envelope.field.sign.useMutation({
|
||||||
|
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setEnvelopeData((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: {
|
||||||
|
...prev.recipient,
|
||||||
|
fields: prev.recipient.fields.map((field) =>
|
||||||
|
field.id === data.signedField.id ? data.signedField : field,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure the user signature doesn't show up if it's not allowed.
|
||||||
|
const [signature, setSignature] = useState(
|
||||||
|
(() => {
|
||||||
|
const sig = initialSignature || '';
|
||||||
|
const isBase64 = isBase64Image(sig);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!sig &&
|
||||||
|
(envelope.documentMeta.uploadSignatureEnabled ||
|
||||||
|
envelope.documentMeta.drawSignatureEnabled) &&
|
||||||
|
envelopeData.recipientSignature?.signatureImageAsBase64
|
||||||
|
) {
|
||||||
|
return envelopeData.recipientSignature.signatureImageAsBase64;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!sig &&
|
||||||
|
envelope.documentMeta.typedSignatureEnabled &&
|
||||||
|
envelopeData.recipientSignature?.typedSignature
|
||||||
|
) {
|
||||||
|
return envelopeData.recipientSignature.typedSignature;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
isBase64 &&
|
||||||
|
(envelope.documentMeta.uploadSignatureEnabled || envelope.documentMeta.drawSignatureEnabled)
|
||||||
|
) {
|
||||||
|
return sig;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isBase64 && envelope.documentMeta.typedSignatureEnabled) {
|
||||||
|
return sig;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
const assistantRecipients =
|
||||||
|
recipient.role === RecipientRole.ASSISTANT
|
||||||
|
? envelope.recipients.filter((r) => (r.signingOrder ?? 0) > (recipient.signingOrder ?? 0))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assistant fields are those fulfill all of the following:
|
||||||
|
* - From recipients that have not signed
|
||||||
|
* - After the assistant signing order
|
||||||
|
* - Are not signature fields
|
||||||
|
*/
|
||||||
|
const assistantFields =
|
||||||
|
recipient.role === RecipientRole.ASSISTANT
|
||||||
|
? assistantRecipients
|
||||||
|
.filter((r) => r.signingStatus !== SigningStatus.SIGNED)
|
||||||
|
.map((r) => r.fields.filter((field) => field.type !== FieldType.SIGNATURE))
|
||||||
|
.flat()
|
||||||
|
: [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The recipient that the assistant has currently selected to sign on behalf of.
|
||||||
|
*/
|
||||||
|
const [selectedAssistantRecipientId, setSelectedAssistantRecipientId] = useState<number | null>(
|
||||||
|
assistantRecipients[0]?.id || null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedAssistantRecipient = useMemo(() => {
|
||||||
|
return envelope.recipients.find((r) => r.id === selectedAssistantRecipientId) || null;
|
||||||
|
}, [envelope.recipients, selectedAssistantRecipientId]);
|
||||||
|
|
||||||
|
const selectedAssistantRecipientFields = useMemo(() => {
|
||||||
|
return assistantFields.filter((field) => field.recipientId === selectedAssistantRecipient?.id);
|
||||||
|
}, [recipientFields, selectedAssistantRecipient]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fields that have been completed by other recipients.
|
||||||
|
*/
|
||||||
|
const otherRecipientCompletedFields = envelope.recipients
|
||||||
|
.filter(({ signingStatus }) => signingStatus === SigningStatus.SIGNED)
|
||||||
|
.flatMap((recipient) =>
|
||||||
|
recipient.fields.map((field) => ({
|
||||||
|
...field,
|
||||||
|
recipient: {
|
||||||
|
name: recipient.name,
|
||||||
|
email: recipient.email,
|
||||||
|
signingStatus: recipient.signingStatus,
|
||||||
|
role: recipient.role,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.filter((field) => field.inserted);
|
||||||
|
|
||||||
|
const nextRecipient = useMemo(() => {
|
||||||
|
if (
|
||||||
|
!envelope.documentMeta.signingOrder ||
|
||||||
|
envelope.documentMeta.signingOrder !== 'SEQUENTIAL'
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedRecipients = envelope.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 === recipient.id);
|
||||||
|
|
||||||
|
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
|
||||||
|
? sortedRecipients[currentIndex + 1]
|
||||||
|
: null;
|
||||||
|
}, [envelope.documentMeta?.signingOrder, envelope.recipients, recipient.id]);
|
||||||
|
|
||||||
|
const signField = async (
|
||||||
|
fieldId: number,
|
||||||
|
fieldValue: TSignEnvelopeFieldValue,
|
||||||
|
authOptions?: TRecipientActionAuth,
|
||||||
|
) => {
|
||||||
|
// Set the field locally for direct templates.
|
||||||
|
if (isDirectTemplate) {
|
||||||
|
const signedField = handleDirectTemplateFieldInsertion(fieldId, fieldValue);
|
||||||
|
|
||||||
|
return signedField;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { signedField } = await signEnvelopeField({
|
||||||
|
token: envelopeData.recipient.token,
|
||||||
|
fieldId,
|
||||||
|
fieldValue,
|
||||||
|
authOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
return signedField;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDirectTemplateFieldInsertion = (
|
||||||
|
fieldId: number,
|
||||||
|
fieldValue: TSignEnvelopeFieldValue,
|
||||||
|
) => {
|
||||||
|
const foundField = recipient.fields.find((field) => field.id === fieldId);
|
||||||
|
|
||||||
|
if (!foundField) {
|
||||||
|
throw new Error('Not possible');
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertionValues = extractFieldInsertionValues({
|
||||||
|
fieldValue,
|
||||||
|
field: foundField,
|
||||||
|
documentMeta: envelope.documentMeta,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedField = {
|
||||||
|
...foundField,
|
||||||
|
...insertionValues,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (fieldValue.type === FieldType.SIGNATURE) {
|
||||||
|
const isBase64 = isBase64Image(fieldValue.value || '');
|
||||||
|
|
||||||
|
updatedField.signature = fieldValue.value
|
||||||
|
? {
|
||||||
|
signatureImageAsBase64: isBase64 ? fieldValue.value : null,
|
||||||
|
typedSignature: isBase64 ? null : fieldValue.value,
|
||||||
|
recipientId: recipient.id,
|
||||||
|
created: new Date(),
|
||||||
|
// Dummy IDs.
|
||||||
|
id: 0,
|
||||||
|
fieldId: 0,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEnvelopeData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
envelope: {
|
||||||
|
...prev.envelope,
|
||||||
|
recipients: prev.envelope.recipients.map((r) =>
|
||||||
|
r.id === recipient.id
|
||||||
|
? {
|
||||||
|
...r,
|
||||||
|
fields: r.fields.map((field) => (field.id === fieldId ? updatedField : field)),
|
||||||
|
}
|
||||||
|
: r,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
recipient: {
|
||||||
|
...prev.recipient,
|
||||||
|
fields: prev.recipient.fields.map((field) => (field.id === fieldId ? updatedField : field)),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return updatedField;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EnvelopeSigningContext.Provider
|
||||||
|
value={{
|
||||||
|
isDirectTemplate,
|
||||||
|
fullName,
|
||||||
|
setFullName,
|
||||||
|
email,
|
||||||
|
setEmail,
|
||||||
|
signature,
|
||||||
|
setSignature,
|
||||||
|
envelopeData,
|
||||||
|
envelope,
|
||||||
|
|
||||||
|
showPendingFieldTooltip,
|
||||||
|
setShowPendingFieldTooltip,
|
||||||
|
|
||||||
|
recipient,
|
||||||
|
recipientFieldsRemaining,
|
||||||
|
recipientFields,
|
||||||
|
requiredRecipientFields,
|
||||||
|
nextRecipient,
|
||||||
|
|
||||||
|
otherRecipientCompletedFields,
|
||||||
|
assistantRecipients,
|
||||||
|
assistantFields,
|
||||||
|
setSelectedAssistantRecipientId,
|
||||||
|
selectedAssistantRecipient,
|
||||||
|
selectedAssistantRecipientFields,
|
||||||
|
|
||||||
|
signField,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</EnvelopeSigningContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
EnvelopeSigningProvider.displayName = 'EnvelopeSigningProvider';
|
||||||
@ -0,0 +1,248 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { Paperclip, Plus, X } from 'lucide-react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type DocumentAttachmentsPopoverProps = {
|
||||||
|
envelopeId: string;
|
||||||
|
buttonClassName?: string;
|
||||||
|
buttonSize?: 'sm' | 'default';
|
||||||
|
};
|
||||||
|
|
||||||
|
const ZAttachmentFormSchema = z.object({
|
||||||
|
label: z.string().min(1, 'Label is required'),
|
||||||
|
url: z.string().url('Must be a valid URL'),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>;
|
||||||
|
|
||||||
|
export const DocumentAttachmentsPopover = ({
|
||||||
|
envelopeId,
|
||||||
|
buttonClassName,
|
||||||
|
buttonSize,
|
||||||
|
}: DocumentAttachmentsPopoverProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
|
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
|
const { data: attachments } = trpc.envelope.attachment.find.useQuery({
|
||||||
|
envelopeId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: createAttachment, isPending: isCreating } =
|
||||||
|
trpc.envelope.attachment.create.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.envelope.attachment.find.invalidate({ envelopeId });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: deleteAttachment } = trpc.envelope.attachment.delete.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.envelope.attachment.find.invalidate({ envelopeId });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<TAttachmentFormSchema>({
|
||||||
|
resolver: zodResolver(ZAttachmentFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
label: '',
|
||||||
|
url: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: TAttachmentFormSchema) => {
|
||||||
|
try {
|
||||||
|
await createAttachment({
|
||||||
|
envelopeId,
|
||||||
|
data: {
|
||||||
|
label: data.label,
|
||||||
|
data: data.url,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
form.reset();
|
||||||
|
|
||||||
|
setIsAdding(false);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Success`),
|
||||||
|
description: _(msg`Attachment added successfully.`),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Error`),
|
||||||
|
description: error.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDeleteAttachment = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await deleteAttachment({ id });
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Success`),
|
||||||
|
description: _(msg`Attachment removed successfully.`),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Error`),
|
||||||
|
description: error.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" className={cn('gap-2', buttonClassName)} size={buttonSize}>
|
||||||
|
<Paperclip className="h-4 w-4" />
|
||||||
|
|
||||||
|
<span>
|
||||||
|
<Trans>Attachments</Trans>
|
||||||
|
{attachments && attachments.data.length > 0 && (
|
||||||
|
<span className="ml-1">({attachments.data.length})</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
|
||||||
|
<PopoverContent className="w-96" align="end">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium">
|
||||||
|
<Trans>Attachments</Trans>
|
||||||
|
</h4>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
|
<Trans>Add links to relevant documents or resources.</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{attachments && attachments.data.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{attachments?.data.map((attachment) => (
|
||||||
|
<div
|
||||||
|
key={attachment.id}
|
||||||
|
className="border-border flex items-center justify-between rounded-md border p-2"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium">{attachment.label}</p>
|
||||||
|
<a
|
||||||
|
href={attachment.data}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-muted-foreground hover:text-foreground truncate text-xs underline"
|
||||||
|
>
|
||||||
|
{attachment.data}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void onDeleteAttachment(attachment.id)}
|
||||||
|
className="ml-2 h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isAdding && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => setIsAdding(true)}
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Add Attachment</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isAdding && (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-3">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="label"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={_(msg`Label`)} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="url"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="url" placeholder={_(msg`URL`)} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => {
|
||||||
|
setIsAdding(false);
|
||||||
|
form.reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" size="sm" className="flex-1" loading={isCreating}>
|
||||||
|
<Trans>Add</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,10 +1,17 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { DocumentData } from '@prisma/client';
|
import { type DocumentData, DocumentStatus, type EnvelopeItem } from '@prisma/client';
|
||||||
|
import { DownloadIcon } from 'lucide-react';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
import {
|
||||||
|
EnvelopeRenderProvider,
|
||||||
|
useCurrentEnvelopeRender,
|
||||||
|
} from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||||
|
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 { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@ -16,45 +23,52 @@ import {
|
|||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||||
|
|
||||||
import { ShareDocumentDownloadButton } from '../share-document-download-button';
|
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
|
||||||
|
|
||||||
|
import { EnvelopeRendererFileSelector } from '../envelope-editor/envelope-file-selector';
|
||||||
|
import EnvelopeGenericPageRenderer from '../envelope-editor/envelope-generic-page-renderer';
|
||||||
|
|
||||||
export type DocumentCertificateQRViewProps = {
|
export type DocumentCertificateQRViewProps = {
|
||||||
documentId: number;
|
documentId: number;
|
||||||
title: string;
|
title: string;
|
||||||
documentData: DocumentData;
|
internalVersion: number;
|
||||||
password?: string | null;
|
envelopeItems: (EnvelopeItem & { documentData: DocumentData })[];
|
||||||
|
documentTeamUrl: string;
|
||||||
recipientCount?: number;
|
recipientCount?: number;
|
||||||
completedDate?: Date;
|
completedDate?: Date;
|
||||||
|
token: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentCertificateQRView = ({
|
export const DocumentCertificateQRView = ({
|
||||||
documentId,
|
documentId,
|
||||||
title,
|
title,
|
||||||
documentData,
|
internalVersion,
|
||||||
password,
|
envelopeItems,
|
||||||
|
documentTeamUrl,
|
||||||
recipientCount = 0,
|
recipientCount = 0,
|
||||||
completedDate,
|
completedDate,
|
||||||
|
token,
|
||||||
}: DocumentCertificateQRViewProps) => {
|
}: DocumentCertificateQRViewProps) => {
|
||||||
const { data: documentUrl } = trpc.shareLink.getDocumentInternalUrlForQRCode.useQuery({
|
const { data: documentViaUser } = trpc.document.get.useQuery({
|
||||||
documentId,
|
documentId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(() => !!documentUrl);
|
const [isDialogOpen, setIsDialogOpen] = useState(() => !!documentViaUser);
|
||||||
|
|
||||||
const formattedDate = completedDate
|
const formattedDate = completedDate
|
||||||
? DateTime.fromJSDate(completedDate).toLocaleString(DateTime.DATETIME_MED)
|
? DateTime.fromJSDate(completedDate).toLocaleString(DateTime.DATETIME_MED)
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (documentUrl) {
|
if (documentViaUser) {
|
||||||
setIsDialogOpen(true);
|
setIsDialogOpen(true);
|
||||||
}
|
}
|
||||||
}, [documentUrl]);
|
}, [documentViaUser]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-screen-md">
|
<div className="mx-auto w-full max-w-screen-md">
|
||||||
{/* Dialog for internal document link */}
|
{/* Dialog for internal document link */}
|
||||||
{documentUrl && (
|
{documentViaUser && (
|
||||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@ -72,7 +86,11 @@ export const DocumentCertificateQRView = ({
|
|||||||
|
|
||||||
<DialogFooter className="flex flex-row justify-end gap-2">
|
<DialogFooter className="flex flex-row justify-end gap-2">
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<a href={documentUrl} target="_blank" rel="noopener noreferrer">
|
<a
|
||||||
|
href={`${formatDocumentsPath(documentTeamUrl)}/${documentViaUser.envelopeId}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
<Trans>Go to document</Trans>
|
<Trans>Go to document</Trans>
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
@ -81,6 +99,17 @@ export const DocumentCertificateQRView = ({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{internalVersion === 2 ? (
|
||||||
|
<EnvelopeRenderProvider envelope={{ envelopeItems }} token={token}>
|
||||||
|
<DocumentCertificateQrV2
|
||||||
|
title={title}
|
||||||
|
recipientCount={recipientCount}
|
||||||
|
formattedDate={formattedDate}
|
||||||
|
token={token}
|
||||||
|
/>
|
||||||
|
</EnvelopeRenderProvider>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div className="flex w-full flex-col justify-between gap-4 md:flex-row md:items-end">
|
<div className="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>
|
||||||
@ -95,11 +124,83 @@ export const DocumentCertificateQRView = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ShareDocumentDownloadButton title={title} documentData={documentData} />
|
<EnvelopeDownloadDialog
|
||||||
|
envelopeId={envelopeItems[0].envelopeId}
|
||||||
|
envelopeStatus={DocumentStatus.COMPLETED}
|
||||||
|
envelopeItems={envelopeItems}
|
||||||
|
token={token}
|
||||||
|
trigger={
|
||||||
|
<Button type="button" variant="outline" className="flex-1">
|
||||||
|
<DownloadIcon className="mr-2 h-5 w-5" />
|
||||||
|
<Trans>Download</Trans>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-12 w-full">
|
<div className="mt-12 w-full">
|
||||||
<PDFViewer key={documentData.id} documentData={documentData} password={password} />
|
<PDFViewer
|
||||||
|
key={envelopeItems[0].id}
|
||||||
|
envelopeItem={envelopeItems[0]}
|
||||||
|
token={token}
|
||||||
|
version="signed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type DocumentCertificateQrV2Props = {
|
||||||
|
title: string;
|
||||||
|
recipientCount: number;
|
||||||
|
formattedDate: string;
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DocumentCertificateQrV2 = ({
|
||||||
|
title,
|
||||||
|
recipientCount,
|
||||||
|
formattedDate,
|
||||||
|
token,
|
||||||
|
}: DocumentCertificateQrV2Props) => {
|
||||||
|
const { currentEnvelopeItem, envelopeItems } = useCurrentEnvelopeRender();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col items-start">
|
||||||
|
<div className="flex w-full flex-col justify-between gap-4 md:flex-row md:items-end">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-xl font-medium">{title}</h1>
|
||||||
|
<div className="text-muted-foreground flex flex-col gap-0.5 text-sm">
|
||||||
|
<p>
|
||||||
|
<Trans>{recipientCount} recipients</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<Trans>Completed on {formattedDate}</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EnvelopeDownloadDialog
|
||||||
|
envelopeId={envelopeItems[0].envelopeId}
|
||||||
|
envelopeStatus={DocumentStatus.COMPLETED}
|
||||||
|
envelopeItems={envelopeItems}
|
||||||
|
token={token}
|
||||||
|
trigger={
|
||||||
|
<Button type="button" variant="outline" className="flex-1">
|
||||||
|
<DownloadIcon className="mr-2 h-5 w-5" />
|
||||||
|
<Trans>Download</Trans>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12 w-full">
|
||||||
|
<EnvelopeRendererFileSelector className="mb-4 p-0" fields={[]} secondaryOverride={''} />
|
||||||
|
|
||||||
|
<PDFViewerKonvaLazy renderer="preview" customPageRenderer={EnvelopeGenericPageRenderer} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -4,7 +4,7 @@ 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 { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
import { useDropzone } from 'react-dropzone';
|
import { ErrorCode, type FileRejection, useDropzone } from 'react-dropzone';
|
||||||
import { Link, useNavigate, useParams } from 'react-router';
|
import { Link, useNavigate, useParams } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
@ -16,9 +16,9 @@ import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT, IS_BILLING_ENABLED } from '@documenso/l
|
|||||||
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
|
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
|
||||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import type { TCreateDocumentPayloadSchema } from '@documenso/trpc/server/document-router/create-document.types';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
@ -62,14 +62,18 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
|
|||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
const response = await putPdfFile(file);
|
const payload = {
|
||||||
|
|
||||||
const { id } = await createDocument({
|
|
||||||
title: file.name,
|
title: file.name,
|
||||||
documentDataId: response.id,
|
timezone: userTimezone,
|
||||||
timezone: userTimezone, // Note: When migrating to v2 document upload remember to pass this through as a 'userTimezone' field.
|
|
||||||
folderId: folderId ?? undefined,
|
folderId: folderId ?? undefined,
|
||||||
});
|
} satisfies TCreateDocumentPayloadSchema;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
formData.append('payload', JSON.stringify(payload));
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const { envelopeId: id } = await createDocument(formData);
|
||||||
|
|
||||||
void refreshLimits();
|
void refreshLimits();
|
||||||
|
|
||||||
@ -95,6 +99,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({
|
||||||
@ -108,15 +116,51 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFileDropRejected = () => {
|
const onFileDropRejected = (fileRejections: FileRejection[]) => {
|
||||||
|
if (!fileRejections.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Since users can only upload only one file (no multi-upload), we only handle the first file rejection
|
||||||
|
const { file, errors } = fileRejections[0];
|
||||||
|
|
||||||
|
if (!errors.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorNodes = errors.map((error, index) => (
|
||||||
|
<span key={index} className="block">
|
||||||
|
{match(error.code)
|
||||||
|
.with(ErrorCode.FileTooLarge, () => (
|
||||||
|
<Trans>File is larger than {APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB</Trans>
|
||||||
|
))
|
||||||
|
.with(ErrorCode.FileInvalidType, () => <Trans>Only PDF files are allowed</Trans>)
|
||||||
|
.with(ErrorCode.FileTooSmall, () => <Trans>File is too small</Trans>)
|
||||||
|
.with(ErrorCode.TooManyFiles, () => (
|
||||||
|
<Trans>Only one file can be uploaded at a time</Trans>
|
||||||
|
))
|
||||||
|
.otherwise(() => (
|
||||||
|
<Trans>Unknown error</Trans>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
));
|
||||||
|
|
||||||
|
const description = (
|
||||||
|
<>
|
||||||
|
<span className="font-medium">
|
||||||
|
{file.name} <Trans>couldn't be uploaded:</Trans>
|
||||||
|
</span>
|
||||||
|
{errorNodes}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Your document failed to upload.`),
|
title: _(msg`Upload failed`),
|
||||||
description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`),
|
description,
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||||
accept: {
|
accept: {
|
||||||
'application/pdf': ['.pdf'],
|
'application/pdf': ['.pdf'],
|
||||||
@ -129,8 +173,8 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
|
|||||||
void onFileDrop(acceptedFile);
|
void onFileDrop(acceptedFile);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDropRejected: () => {
|
onDropRejected: (fileRejections) => {
|
||||||
void onFileDropRejected();
|
onFileDropRejected(fileRejections);
|
||||||
},
|
},
|
||||||
noClick: true,
|
noClick: true,
|
||||||
noDragEventsBubbling: true,
|
noDragEventsBubbling: true,
|
||||||
|
|||||||
@ -83,7 +83,7 @@ export const DocumentEditForm = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: addFields } = trpc.field.addFields.useMutation({
|
const { mutateAsync: addFields } = trpc.field.setFieldsForDocument.useMutation({
|
||||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
onSuccess: ({ fields: newFields }) => {
|
onSuccess: ({ fields: newFields }) => {
|
||||||
utils.document.get.setData(
|
utils.document.get.setData(
|
||||||
@ -230,6 +230,7 @@ export const DocumentEditForm = ({
|
|||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
recipients: data.signers.map((signer) => ({
|
recipients: data.signers.map((signer) => ({
|
||||||
...signer,
|
...signer,
|
||||||
|
id: signer.nativeId,
|
||||||
// Explicitly set to null to indicate we want to remove auth if required.
|
// Explicitly set to null to indicate we want to remove auth if required.
|
||||||
actionAuth: signer.actionAuth ?? [],
|
actionAuth: signer.actionAuth ?? [],
|
||||||
})),
|
})),
|
||||||
@ -239,7 +240,28 @@ export const DocumentEditForm = ({
|
|||||||
|
|
||||||
const onAddSignersFormAutoSave = async (data: TAddSignersFormSchema) => {
|
const onAddSignersFormAutoSave = async (data: TAddSignersFormSchema) => {
|
||||||
try {
|
try {
|
||||||
await saveSignersData(data);
|
// For autosave, we need to return the recipients response for form state sync
|
||||||
|
const [, recipientsResponse] = await Promise.all([
|
||||||
|
updateDocument({
|
||||||
|
documentId: document.id,
|
||||||
|
meta: {
|
||||||
|
allowDictateNextSigner: data.allowDictateNextSigner,
|
||||||
|
signingOrder: data.signingOrder,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
setRecipients({
|
||||||
|
documentId: document.id,
|
||||||
|
recipients: data.signers.map((signer) => ({
|
||||||
|
...signer,
|
||||||
|
id: signer.nativeId,
|
||||||
|
// Explicitly set to null to indicate we want to remove auth if required.
|
||||||
|
actionAuth: signer.actionAuth ?? [],
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return recipientsResponse;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
@ -248,6 +270,8 @@ export const DocumentEditForm = ({
|
|||||||
description: _(msg`An error occurred while adding signers.`),
|
description: _(msg`An error occurred while adding signers.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
throw err; // Re-throw so the autosave hook can handle the error
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -270,7 +294,11 @@ export const DocumentEditForm = ({
|
|||||||
const saveFieldsData = async (data: TAddFieldsFormSchema) => {
|
const saveFieldsData = async (data: TAddFieldsFormSchema) => {
|
||||||
return addFields({
|
return addFields({
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
fields: data.fields,
|
fields: data.fields.map((field) => ({
|
||||||
|
...field,
|
||||||
|
id: field.nativeId,
|
||||||
|
envelopeItemId: document.documentData.envelopeItemId,
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -366,7 +394,7 @@ export const DocumentEditForm = ({
|
|||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await navigate(`${documentRootPath}/${document.id}`);
|
await navigate(`${documentRootPath}/${document.envelopeId}`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@ -413,9 +441,10 @@ export const DocumentEditForm = ({
|
|||||||
>
|
>
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<PDFViewer
|
<PDFViewer
|
||||||
key={document.documentData.id}
|
key={document.envelopeItems[0].id}
|
||||||
documentData={document.documentData}
|
envelopeItem={document.envelopeItems[0]}
|
||||||
document={document}
|
token={undefined}
|
||||||
|
version="signed"
|
||||||
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@ -1,79 +1,41 @@
|
|||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { Document, Recipient, Team, User } from '@prisma/client';
|
|
||||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
|
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
|
||||||
import { CheckCircle, Download, EyeIcon, Pencil } from 'lucide-react';
|
import { CheckCircle, Download, EyeIcon, Pencil } from 'lucide-react';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
|
||||||
|
|
||||||
export type DocumentPageViewButtonProps = {
|
export type DocumentPageViewButtonProps = {
|
||||||
document: Document & {
|
envelope: TEnvelope;
|
||||||
user: Pick<User, 'id' | 'name' | 'email'>;
|
|
||||||
recipients: Recipient[];
|
|
||||||
team: Pick<Team, 'id' | 'url'>;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps) => {
|
export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps) => {
|
||||||
const { user } = useSession();
|
const { user } = useSession();
|
||||||
|
|
||||||
const { toast } = useToast();
|
const recipient = envelope.recipients.find((recipient) => recipient.email === user.email);
|
||||||
const { _ } = useLingui();
|
|
||||||
|
|
||||||
const recipient = document.recipients.find((recipient) => recipient.email === user.email);
|
|
||||||
|
|
||||||
const isRecipient = !!recipient;
|
const isRecipient = !!recipient;
|
||||||
const isPending = document.status === DocumentStatus.PENDING;
|
const isPending = envelope.status === DocumentStatus.PENDING;
|
||||||
const isComplete = isDocumentCompleted(document);
|
const isComplete = isDocumentCompleted(envelope);
|
||||||
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||||
const role = recipient?.role;
|
const role = recipient?.role;
|
||||||
|
|
||||||
const documentsPath = formatDocumentsPath(document.team.url);
|
const documentsPath = formatDocumentsPath(envelope.team.url);
|
||||||
const formatPath = `${documentsPath}/${document.id}/edit`;
|
const formatPath = `${documentsPath}/${envelope.id}/edit`;
|
||||||
|
|
||||||
const onDownloadClick = async () => {
|
|
||||||
try {
|
|
||||||
const documentWithData = await trpcClient.document.get.query(
|
|
||||||
{
|
|
||||||
documentId: document.id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
context: {
|
|
||||||
teamId: document.team?.id?.toString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const documentData = documentWithData?.documentData;
|
|
||||||
|
|
||||||
if (!documentData) {
|
|
||||||
throw new Error('No document available');
|
|
||||||
}
|
|
||||||
|
|
||||||
await downloadPDF({ documentData, fileName: documentWithData.title });
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Something went wrong`),
|
|
||||||
description: _(msg`An error occurred while downloading your document.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return match({
|
return match({
|
||||||
isRecipient,
|
isRecipient,
|
||||||
isPending,
|
isPending,
|
||||||
isComplete,
|
isComplete,
|
||||||
isSigned,
|
isSigned,
|
||||||
|
internalVersion: envelope.internalVersion,
|
||||||
})
|
})
|
||||||
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
|
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
|
||||||
<Button className="w-full" asChild>
|
<Button className="w-full" asChild>
|
||||||
@ -108,10 +70,18 @@ export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps
|
|||||||
</Button>
|
</Button>
|
||||||
))
|
))
|
||||||
.with({ isComplete: true }, () => (
|
.with({ isComplete: true }, () => (
|
||||||
<Button className="w-full" onClick={onDownloadClick}>
|
<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" />
|
<Download className="-ml-1 mr-2 inline h-4 w-4" />
|
||||||
<Trans>Download</Trans>
|
<Trans>Download</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
))
|
))
|
||||||
.otherwise(() => null);
|
.otherwise(() => null);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { Document, Recipient, Team, User } from '@prisma/client';
|
|
||||||
import { DocumentStatus } from '@prisma/client';
|
import { DocumentStatus } from '@prisma/client';
|
||||||
import {
|
import {
|
||||||
Copy,
|
Copy,
|
||||||
@ -17,11 +15,11 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Link, useNavigate } from 'react-router';
|
import { Link, useNavigate } from 'react-router';
|
||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
|
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
|
||||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@ -35,18 +33,15 @@ 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';
|
||||||
|
|
||||||
export type DocumentPageViewDropdownProps = {
|
export type DocumentPageViewDropdownProps = {
|
||||||
document: Document & {
|
envelope: TEnvelope;
|
||||||
user: Pick<User, 'id' | 'name' | 'email'>;
|
|
||||||
recipients: Recipient[];
|
|
||||||
team: Pick<Team, 'id' | 'url'> | null;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownProps) => {
|
export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownProps) => {
|
||||||
const { user } = useSession();
|
const { user } = useSession();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
@ -57,77 +52,19 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
|
|||||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
||||||
|
|
||||||
const recipient = document.recipients.find((recipient) => recipient.email === user.email);
|
const recipient = envelope.recipients.find((recipient) => recipient.email === user.email);
|
||||||
|
|
||||||
const isOwner = document.user.id === user.id;
|
const isOwner = envelope.userId === user.id;
|
||||||
const isDraft = document.status === DocumentStatus.DRAFT;
|
const isDraft = envelope.status === DocumentStatus.DRAFT;
|
||||||
const isPending = document.status === DocumentStatus.PENDING;
|
const isPending = envelope.status === DocumentStatus.PENDING;
|
||||||
const isDeleted = document.deletedAt !== null;
|
const isDeleted = envelope.deletedAt !== null;
|
||||||
const isComplete = isDocumentCompleted(document);
|
const isComplete = isDocumentCompleted(envelope);
|
||||||
const isCurrentTeamDocument = team && document.team?.url === team.url;
|
const isCurrentTeamDocument = team && envelope.teamId === team.id;
|
||||||
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
|
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
|
||||||
|
|
||||||
const documentsPath = formatDocumentsPath(team.url);
|
const documentsPath = formatDocumentsPath(team.url);
|
||||||
|
|
||||||
const onDownloadClick = async () => {
|
const nonSignedRecipients = envelope.recipients.filter((item) => item.signingStatus !== 'SIGNED');
|
||||||
try {
|
|
||||||
const documentWithData = await trpcClient.document.get.query(
|
|
||||||
{
|
|
||||||
documentId: document.id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
context: {
|
|
||||||
teamId: team?.id?.toString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const documentData = documentWithData?.documentData;
|
|
||||||
|
|
||||||
if (!documentData) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await downloadPDF({ documentData, fileName: document.title });
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Something went wrong`),
|
|
||||||
description: _(msg`An error occurred while downloading your document.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDownloadOriginalClick = async () => {
|
|
||||||
try {
|
|
||||||
const documentWithData = await trpcClient.document.get.query(
|
|
||||||
{
|
|
||||||
documentId: document.id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
context: {
|
|
||||||
teamId: team?.id?.toString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const documentData = documentWithData?.documentData;
|
|
||||||
|
|
||||||
if (!documentData) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await downloadPDF({ documentData, fileName: document.title, version: 'original' });
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Something went wrong`),
|
|
||||||
description: _(msg`An error occurred while downloading your document.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const nonSignedRecipients = document.recipients.filter((item) => item.signingStatus !== 'SIGNED');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@ -142,27 +79,30 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
|
|||||||
|
|
||||||
{(isOwner || isCurrentTeamDocument) && !isComplete && (
|
{(isOwner || isCurrentTeamDocument) && !isComplete && (
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link to={`${documentsPath}/${document.id}/edit`}>
|
<Link to={`${documentsPath}/${envelope.id}/edit`}>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
<Trans>Edit</Trans>
|
<Trans>Edit</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isComplete && (
|
<EnvelopeDownloadDialog
|
||||||
<DropdownMenuItem onClick={onDownloadClick}>
|
envelopeId={envelope.id}
|
||||||
|
envelopeStatus={envelope.status}
|
||||||
|
token={recipient?.token}
|
||||||
|
envelopeItems={envelope.envelopeItems}
|
||||||
|
trigger={
|
||||||
|
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||||
|
<div>
|
||||||
<Download className="mr-2 h-4 w-4" />
|
<Download className="mr-2 h-4 w-4" />
|
||||||
<Trans>Download</Trans>
|
<Trans>Download</Trans>
|
||||||
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
}
|
||||||
|
/>
|
||||||
<DropdownMenuItem onClick={onDownloadOriginalClick}>
|
|
||||||
<Download className="mr-2 h-4 w-4" />
|
|
||||||
<Trans>Download Original</Trans>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link to={`${documentsPath}/${document.id}/logs`}>
|
<Link to={`${documentsPath}/${envelope.id}/logs`}>
|
||||||
<ScrollTextIcon className="mr-2 h-4 w-4" />
|
<ScrollTextIcon className="mr-2 h-4 w-4" />
|
||||||
<Trans>Audit Logs</Trans>
|
<Trans>Audit Logs</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
@ -184,7 +124,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
|
|||||||
|
|
||||||
{canManageDocument && (
|
{canManageDocument && (
|
||||||
<DocumentRecipientLinkCopyDialog
|
<DocumentRecipientLinkCopyDialog
|
||||||
recipients={document.recipients}
|
recipients={envelope.recipients}
|
||||||
trigger={
|
trigger={
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
disabled={!isPending || isDeleted}
|
disabled={!isPending || isDeleted}
|
||||||
@ -197,10 +137,16 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DocumentResendDialog document={document} recipients={nonSignedRecipients} />
|
<DocumentResendDialog
|
||||||
|
document={{
|
||||||
|
...envelope,
|
||||||
|
id: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||||
|
}}
|
||||||
|
recipients={nonSignedRecipients}
|
||||||
|
/>
|
||||||
|
|
||||||
<DocumentShareButton
|
<DocumentShareButton
|
||||||
documentId={document.id}
|
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
|
||||||
token={isOwner ? undefined : recipient?.token}
|
token={isOwner ? undefined : recipient?.token}
|
||||||
trigger={({ loading, disabled }) => (
|
trigger={({ loading, disabled }) => (
|
||||||
<DropdownMenuItem disabled={disabled || isDraft} onSelect={(e) => e.preventDefault()}>
|
<DropdownMenuItem disabled={disabled || isDraft} onSelect={(e) => e.preventDefault()}>
|
||||||
@ -214,9 +160,9 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|
||||||
<DocumentDeleteDialog
|
<DocumentDeleteDialog
|
||||||
id={document.id}
|
id={mapSecondaryIdToDocumentId(envelope.secondaryId)}
|
||||||
status={document.status}
|
status={envelope.status}
|
||||||
documentTitle={document.title}
|
documentTitle={envelope.title}
|
||||||
open={isDeleteDialogOpen}
|
open={isDeleteDialogOpen}
|
||||||
canManageDocument={canManageDocument}
|
canManageDocument={canManageDocument}
|
||||||
onOpenChange={setDeleteDialogOpen}
|
onOpenChange={setDeleteDialogOpen}
|
||||||
@ -227,7 +173,8 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
|
|||||||
|
|
||||||
{isDuplicateDialogOpen && (
|
{isDuplicateDialogOpen && (
|
||||||
<DocumentDuplicateDialog
|
<DocumentDuplicateDialog
|
||||||
id={document.id}
|
id={envelope.id}
|
||||||
|
token={recipient?.token}
|
||||||
open={isDuplicateDialogOpen}
|
open={isDuplicateDialogOpen}
|
||||||
onOpenChange={setDuplicateDialogOpen}
|
onOpenChange={setDuplicateDialogOpen}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -3,21 +3,19 @@ import { useMemo } from 'react';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { Document, Recipient, User } from '@prisma/client';
|
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||||
|
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||||
|
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||||
|
|
||||||
export type DocumentPageViewInformationProps = {
|
export type DocumentPageViewInformationProps = {
|
||||||
userId: number;
|
userId: number;
|
||||||
document: Document & {
|
envelope: TEnvelope;
|
||||||
user: Pick<User, 'id' | 'name' | 'email'>;
|
|
||||||
recipients: Recipient[];
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentPageViewInformation = ({
|
export const DocumentPageViewInformation = ({
|
||||||
document,
|
envelope,
|
||||||
userId,
|
userId,
|
||||||
}: DocumentPageViewInformationProps) => {
|
}: DocumentPageViewInformationProps) => {
|
||||||
const isMounted = useIsMounted();
|
const isMounted = useIsMounted();
|
||||||
@ -29,23 +27,27 @@ export const DocumentPageViewInformation = ({
|
|||||||
{
|
{
|
||||||
description: msg`Uploaded by`,
|
description: msg`Uploaded by`,
|
||||||
value:
|
value:
|
||||||
userId === document.userId ? _(msg`You`) : (document.user.name ?? document.user.email),
|
userId === envelope.userId ? _(msg`You`) : (envelope.user.name ?? envelope.user.email),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: msg`Created`,
|
description: msg`Created`,
|
||||||
value: DateTime.fromJSDate(document.createdAt)
|
value: DateTime.fromJSDate(envelope.createdAt)
|
||||||
.setLocale(i18n.locales?.[0] || i18n.locale)
|
.setLocale(i18n.locales?.[0] || i18n.locale)
|
||||||
.toFormat('MMMM d, yyyy'),
|
.toFormat('MMMM d, yyyy'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: msg`Last modified`,
|
description: msg`Last modified`,
|
||||||
value: DateTime.fromJSDate(document.updatedAt)
|
value: DateTime.fromJSDate(envelope.updatedAt)
|
||||||
.setLocale(i18n.locales?.[0] || i18n.locale)
|
.setLocale(i18n.locales?.[0] || i18n.locale)
|
||||||
.toRelative(),
|
.toRelative(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
description: msg`Document ID (Legacy)`,
|
||||||
|
value: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isMounted, document, userId]);
|
}, [isMounted, envelope, userId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="dark:bg-background text-foreground border-border bg-widget flex flex-col rounded-xl border">
|
<section className="dark:bg-background text-foreground border-border bg-widget flex flex-col rounded-xl border">
|
||||||
|
|||||||
@ -1,8 +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 type { Document, Recipient } from '@prisma/client';
|
import { TooltipArrow } from '@radix-ui/react-tooltip';
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
@ -13,10 +15,11 @@ 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';
|
||||||
|
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
import { formatSigningLink } from '@documenso/lib/utils/recipients';
|
import { formatSigningLink } from '@documenso/lib/utils/recipients';
|
||||||
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
|
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
|
||||||
@ -24,23 +27,43 @@ 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 = {
|
||||||
document: Document & {
|
envelope: TEnvelope;
|
||||||
recipients: Recipient[];
|
|
||||||
};
|
|
||||||
documentRootPath: string;
|
documentRootPath: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentPageViewRecipients = ({
|
export const DocumentPageViewRecipients = ({
|
||||||
document,
|
envelope,
|
||||||
documentRootPath,
|
documentRootPath,
|
||||||
}: DocumentPageViewRecipientsProps) => {
|
}: DocumentPageViewRecipientsProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
const recipients = document.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">
|
||||||
@ -49,9 +72,9 @@ export const DocumentPageViewRecipients = ({
|
|||||||
<Trans>Recipients</Trans>
|
<Trans>Recipients</Trans>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{!isDocumentCompleted(document.status) && (
|
{!isDocumentCompleted(envelope.status) && (
|
||||||
<Link
|
<Link
|
||||||
to={`${documentRootPath}/${document.id}/edit?step=signers`}
|
to={`${documentRootPath}/${envelope.id}/edit?step=signers`}
|
||||||
title={_(msg`Modify recipients`)}
|
title={_(msg`Modify recipients`)}
|
||||||
className="flex flex-row items-center justify-between"
|
className="flex flex-row items-center justify-between"
|
||||||
>
|
>
|
||||||
@ -71,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()}
|
||||||
@ -84,7 +107,7 @@ export const DocumentPageViewRecipients = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-row items-center">
|
<div className="flex flex-row items-center">
|
||||||
{document.status !== DocumentStatus.DRAFT &&
|
{envelope.status !== DocumentStatus.DRAFT &&
|
||||||
recipient.signingStatus === SigningStatus.SIGNED && (
|
recipient.signingStatus === SigningStatus.SIGNED && (
|
||||||
<Badge variant="default">
|
<Badge variant="default">
|
||||||
{match(recipient.role)
|
{match(recipient.role)
|
||||||
@ -95,7 +118,7 @@ export const DocumentPageViewRecipients = ({
|
|||||||
</>
|
</>
|
||||||
))
|
))
|
||||||
.with(RecipientRole.CC, () =>
|
.with(RecipientRole.CC, () =>
|
||||||
document.status === DocumentStatus.COMPLETED ? (
|
envelope.status === DocumentStatus.COMPLETED ? (
|
||||||
<>
|
<>
|
||||||
<MailIcon className="mr-1 h-3 w-3" />
|
<MailIcon className="mr-1 h-3 w-3" />
|
||||||
<Trans>Sent</Trans>
|
<Trans>Sent</Trans>
|
||||||
@ -130,7 +153,7 @@ export const DocumentPageViewRecipients = ({
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{document.status !== DocumentStatus.DRAFT &&
|
{envelope.status !== DocumentStatus.DRAFT &&
|
||||||
recipient.signingStatus === SigningStatus.NOT_SIGNED && (
|
recipient.signingStatus === SigningStatus.NOT_SIGNED && (
|
||||||
<Badge variant="secondary">
|
<Badge variant="secondary">
|
||||||
<Clock className="mr-1 h-3 w-3" />
|
<Clock className="mr-1 h-3 w-3" />
|
||||||
@ -138,7 +161,7 @@ export const DocumentPageViewRecipients = ({
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{document.status !== DocumentStatus.DRAFT &&
|
{envelope.status !== DocumentStatus.DRAFT &&
|
||||||
recipient.signingStatus === SigningStatus.REJECTED && (
|
recipient.signingStatus === SigningStatus.REJECTED && (
|
||||||
<PopoverHover
|
<PopoverHover
|
||||||
trigger={
|
trigger={
|
||||||
@ -158,18 +181,36 @@ export const DocumentPageViewRecipients = ({
|
|||||||
</PopoverHover>
|
</PopoverHover>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{document.status === DocumentStatus.PENDING &&
|
{envelope.status === DocumentStatus.PENDING &&
|
||||||
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
|
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
|
||||||
recipient.role !== RecipientRole.CC && (
|
recipient.role !== RecipientRole.CC && (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip open={shouldHighlightCopyButtons && i === 0}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div
|
||||||
|
className={shouldHighlightCopyButtons ? 'animate-pulse' : ''}
|
||||||
|
onClick={() => setShouldHighlightCopyButtons(false)}
|
||||||
|
>
|
||||||
<CopyTextButton
|
<CopyTextButton
|
||||||
value={formatSigningLink(recipient.token)}
|
value={formatSigningLink(recipient.token)}
|
||||||
onCopySuccess={() => {
|
onCopySuccess={() => {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Copied to clipboard`),
|
title: _(msg`Copied to clipboard`),
|
||||||
description: _(msg`The signing link has been copied to your 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>
|
||||||
|
|||||||
@ -13,9 +13,9 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
|
|||||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||||
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import type { TCreateDocumentPayloadSchema } from '@documenso/trpc/server/document-router/create-document.types';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { DocumentDropzone } from '@documenso/ui/primitives/document-upload';
|
import { DocumentDropzone } from '@documenso/ui/primitives/document-upload';
|
||||||
import {
|
import {
|
||||||
@ -28,11 +28,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export type DocumentUploadDropzoneProps = {
|
export type DocumentUploadButtonProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProps) => {
|
export const DocumentUploadButton = ({ className }: DocumentUploadButtonProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { user } = useSession();
|
const { user } = useSession();
|
||||||
@ -73,14 +73,18 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
|
|||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
const response = await putPdfFile(file);
|
const payload = {
|
||||||
|
|
||||||
const { id } = await createDocument({
|
|
||||||
title: file.name,
|
title: file.name,
|
||||||
documentDataId: response.id,
|
timezone: userTimezone,
|
||||||
timezone: userTimezone, // Note: When migrating to v2 document upload remember to pass this through as a 'userTimezone' field.
|
|
||||||
folderId: folderId ?? undefined,
|
folderId: folderId ?? undefined,
|
||||||
});
|
} satisfies TCreateDocumentPayloadSchema;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
formData.append('payload', JSON.stringify(payload));
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const { envelopeId: id } = await createDocument(formData);
|
||||||
|
|
||||||
void refreshLimits();
|
void refreshLimits();
|
||||||
|
|
||||||
@ -108,6 +112,10 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
|
|||||||
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({
|
||||||
@ -140,7 +148,7 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
|
|||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
disabled={remaining.documents === 0 || !user.emailVerified}
|
disabled={remaining.documents === 0 || !user.emailVerified}
|
||||||
disabledMessage={disabledMessage}
|
disabledMessage={disabledMessage}
|
||||||
onDrop={onFileDrop}
|
onDrop={async (files) => onFileDrop(files[0])}
|
||||||
onDropRejected={onFileDropRejected}
|
onDropRejected={onFileDropRejected}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -0,0 +1,205 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react/macro';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { EnvelopeType } from '@prisma/client';
|
||||||
|
import { ErrorCode as DropzoneErrorCode, type FileRejection } from 'react-dropzone';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||||
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||||
|
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import type { TCreateEnvelopePayload } from '@documenso/trpc/server/envelope-router/create-envelope.types';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { DocumentDropzone } from '@documenso/ui/primitives/document-upload';
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@documenso/ui/primitives/tooltip';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
|
export type EnvelopeUploadButtonProps = {
|
||||||
|
className?: string;
|
||||||
|
type: EnvelopeType;
|
||||||
|
folderId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload an envelope
|
||||||
|
*/
|
||||||
|
export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUploadButtonProps) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { user } = useSession();
|
||||||
|
|
||||||
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
|
const userTimezone = TIME_ZONES.find(
|
||||||
|
(timezone) => timezone === Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { quota, remaining, refreshLimits, maximumEnvelopeItemCount } = useLimits();
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const { mutateAsync: createEnvelope } = trpc.envelope.create.useMutation();
|
||||||
|
|
||||||
|
const disabledMessage = useMemo(() => {
|
||||||
|
if (organisation.subscription && remaining.documents === 0) {
|
||||||
|
return msg`Document upload disabled due to unpaid invoices`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remaining.documents === 0) {
|
||||||
|
return msg`You have reached your document limit.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.emailVerified) {
|
||||||
|
return msg`Verify your email to upload documents.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [remaining.documents, user.emailVerified, team]);
|
||||||
|
|
||||||
|
const onFileDrop = async (files: File[]) => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
folderId,
|
||||||
|
type,
|
||||||
|
title: files[0].name,
|
||||||
|
meta: {
|
||||||
|
timezone: userTimezone,
|
||||||
|
},
|
||||||
|
} satisfies TCreateEnvelopePayload;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
formData.append('payload', JSON.stringify(payload));
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
formData.append('files', file);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await createEnvelope(formData).catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
void refreshLimits();
|
||||||
|
|
||||||
|
const pathPrefix =
|
||||||
|
type === EnvelopeType.DOCUMENT
|
||||||
|
? formatDocumentsPath(team.url)
|
||||||
|
: formatTemplatesPath(team.url);
|
||||||
|
|
||||||
|
await navigate(`${pathPrefix}/${id}/edit`);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: type === EnvelopeType.DOCUMENT ? t`Document uploaded` : t`Template uploaded`,
|
||||||
|
description:
|
||||||
|
type === EnvelopeType.DOCUMENT
|
||||||
|
? t`Your document has been uploaded successfully.`
|
||||||
|
: t`Your template has been uploaded successfully.`,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
const errorMessage = match(error.code)
|
||||||
|
.with('INVALID_DOCUMENT_FILE', () => t`You cannot upload encrypted PDFs`)
|
||||||
|
.with(
|
||||||
|
AppErrorCode.LIMIT_EXCEEDED,
|
||||||
|
() => 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.`);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t`Error`,
|
||||||
|
description: errorMessage,
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 7500,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className={cn('relative', className)}>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div>
|
||||||
|
<DocumentDropzone
|
||||||
|
loading={isLoading}
|
||||||
|
disabled={remaining.documents === 0 || !user.emailVerified}
|
||||||
|
disabledMessage={disabledMessage}
|
||||||
|
onDrop={onFileDrop}
|
||||||
|
onDropRejected={onFileDropRejected}
|
||||||
|
type="envelope"
|
||||||
|
maxFiles={maximumEnvelopeItemCount}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
|
||||||
|
{type === EnvelopeType.DOCUMENT &&
|
||||||
|
remaining.documents > 0 &&
|
||||||
|
Number.isFinite(remaining.documents) && (
|
||||||
|
<TooltipContent>
|
||||||
|
<p className="text-sm">
|
||||||
|
<Trans>
|
||||||
|
{remaining.documents} of {quota.documents} documents remaining this month.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
)}
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,331 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react/macro';
|
||||||
|
import { FieldType } from '@prisma/client';
|
||||||
|
import {
|
||||||
|
CalendarIcon,
|
||||||
|
CheckSquareIcon,
|
||||||
|
ContactIcon,
|
||||||
|
DiscIcon,
|
||||||
|
HashIcon,
|
||||||
|
ListIcon,
|
||||||
|
MailIcon,
|
||||||
|
TextIcon,
|
||||||
|
UserIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
|
||||||
|
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
|
||||||
|
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||||
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
|
import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
|
||||||
|
import { nanoid } from '@documenso/lib/universal/id';
|
||||||
|
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
||||||
|
import { SignatureIcon } from '@documenso/ui/icons/signature';
|
||||||
|
import { RECIPIENT_COLOR_STYLES } from '@documenso/ui/lib/recipient-colors';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
|
||||||
|
|
||||||
|
const MIN_HEIGHT_PX = 12;
|
||||||
|
const MIN_WIDTH_PX = 36;
|
||||||
|
|
||||||
|
const DEFAULT_HEIGHT_PX = MIN_HEIGHT_PX * 2.5;
|
||||||
|
const DEFAULT_WIDTH_PX = MIN_WIDTH_PX * 2.5;
|
||||||
|
|
||||||
|
export const fieldButtonList = [
|
||||||
|
{
|
||||||
|
type: FieldType.SIGNATURE,
|
||||||
|
icon: SignatureIcon,
|
||||||
|
name: msg`Signature`,
|
||||||
|
className: 'font-signature text-lg',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.EMAIL,
|
||||||
|
icon: MailIcon,
|
||||||
|
name: msg`Email`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.NAME,
|
||||||
|
icon: UserIcon,
|
||||||
|
name: msg`Name`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.INITIALS,
|
||||||
|
icon: ContactIcon,
|
||||||
|
name: msg`Initials`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.DATE,
|
||||||
|
icon: CalendarIcon,
|
||||||
|
name: msg`Date`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.TEXT,
|
||||||
|
icon: TextIcon,
|
||||||
|
name: msg`Text`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.NUMBER,
|
||||||
|
icon: HashIcon,
|
||||||
|
name: msg`Number`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.RADIO,
|
||||||
|
icon: DiscIcon,
|
||||||
|
name: msg`Radio`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.CHECKBOX,
|
||||||
|
icon: CheckSquareIcon,
|
||||||
|
name: msg`Checkbox`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.DROPDOWN,
|
||||||
|
icon: ListIcon,
|
||||||
|
name: msg`Dropdown`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
type EnvelopeEditorFieldDragDropProps = {
|
||||||
|
selectedRecipientId: number | null;
|
||||||
|
selectedEnvelopeItemId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EnvelopeEditorFieldDragDrop = ({
|
||||||
|
selectedRecipientId,
|
||||||
|
selectedEnvelopeItemId,
|
||||||
|
}: EnvelopeEditorFieldDragDropProps) => {
|
||||||
|
const { envelope, editorFields, isTemplate, getRecipientColorKey } = useCurrentEnvelopeEditor();
|
||||||
|
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
|
||||||
|
|
||||||
|
const { isWithinPageBounds, getPage } = useDocumentElement();
|
||||||
|
|
||||||
|
const isFieldsDisabled = useMemo(() => {
|
||||||
|
const selectedSigner = envelope.recipients.find(
|
||||||
|
(recipient) => recipient.id === selectedRecipientId,
|
||||||
|
);
|
||||||
|
const fields = envelope.fields;
|
||||||
|
|
||||||
|
if (!selectedSigner) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow fields to be modified for templates regardless of anything.
|
||||||
|
if (isTemplate) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !canRecipientFieldsBeModified(selectedSigner, fields);
|
||||||
|
}, [selectedRecipientId, envelope.recipients, envelope.fields]);
|
||||||
|
|
||||||
|
const [isFieldWithinBounds, setIsFieldWithinBounds] = useState(false);
|
||||||
|
const [coords, setCoords] = useState({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fieldBounds = useRef({
|
||||||
|
height: 0,
|
||||||
|
width: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onMouseMove = useCallback(
|
||||||
|
(event: MouseEvent) => {
|
||||||
|
setIsFieldWithinBounds(
|
||||||
|
isWithinPageBounds(
|
||||||
|
event,
|
||||||
|
PDF_VIEWER_PAGE_SELECTOR,
|
||||||
|
fieldBounds.current.width,
|
||||||
|
fieldBounds.current.height,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
setCoords({
|
||||||
|
x: event.clientX - fieldBounds.current.width / 2,
|
||||||
|
y: event.clientY - fieldBounds.current.height / 2,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[isWithinPageBounds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onMouseClick = useCallback(
|
||||||
|
(event: MouseEvent) => {
|
||||||
|
if (!selectedField || !selectedRecipientId || !selectedEnvelopeItemId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const $page = getPage(event, PDF_VIEWER_PAGE_SELECTOR);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!$page ||
|
||||||
|
!isWithinPageBounds(
|
||||||
|
event,
|
||||||
|
PDF_VIEWER_PAGE_SELECTOR,
|
||||||
|
fieldBounds.current.width,
|
||||||
|
fieldBounds.current.height,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
setSelectedField(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { top, left, height, width } = getBoundingClientRect($page);
|
||||||
|
|
||||||
|
console.log({
|
||||||
|
top,
|
||||||
|
left,
|
||||||
|
height,
|
||||||
|
width,
|
||||||
|
rawPageX: event.pageX,
|
||||||
|
rawPageY: event.pageY,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10);
|
||||||
|
|
||||||
|
// Calculate x and y as a percentage of the page width and height
|
||||||
|
let pageX = ((event.pageX - left) / width) * 100;
|
||||||
|
let pageY = ((event.pageY - top) / height) * 100;
|
||||||
|
|
||||||
|
// Get the bounds as a percentage of the page width and height
|
||||||
|
const fieldPageWidth = (fieldBounds.current.width / width) * 100;
|
||||||
|
const fieldPageHeight = (fieldBounds.current.height / height) * 100;
|
||||||
|
|
||||||
|
// And center it based on the bounds
|
||||||
|
pageX -= fieldPageWidth / 2;
|
||||||
|
pageY -= fieldPageHeight / 2;
|
||||||
|
|
||||||
|
const field = {
|
||||||
|
formId: nanoid(12),
|
||||||
|
envelopeItemId: selectedEnvelopeItemId,
|
||||||
|
type: selectedField,
|
||||||
|
page: pageNumber,
|
||||||
|
positionX: pageX,
|
||||||
|
positionY: pageY,
|
||||||
|
width: fieldPageWidth,
|
||||||
|
height: fieldPageHeight,
|
||||||
|
recipientId: selectedRecipientId,
|
||||||
|
fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[selectedField]),
|
||||||
|
};
|
||||||
|
|
||||||
|
editorFields.addField(field);
|
||||||
|
|
||||||
|
setIsFieldWithinBounds(false);
|
||||||
|
setSelectedField(null);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
isWithinPageBounds,
|
||||||
|
selectedField,
|
||||||
|
selectedRecipientId,
|
||||||
|
selectedEnvelopeItemId,
|
||||||
|
getPage,
|
||||||
|
editorFields,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new MutationObserver((_mutations) => {
|
||||||
|
const $page = document.querySelector(PDF_VIEWER_PAGE_SELECTOR);
|
||||||
|
|
||||||
|
if (!$page) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldBounds.current = {
|
||||||
|
height: Math.max(DEFAULT_HEIGHT_PX),
|
||||||
|
width: Math.max(DEFAULT_WIDTH_PX),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(document.body, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedField) {
|
||||||
|
window.addEventListener('mousemove', onMouseMove);
|
||||||
|
window.addEventListener('mouseup', onMouseClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', onMouseMove);
|
||||||
|
window.removeEventListener('mouseup', onMouseClick);
|
||||||
|
};
|
||||||
|
}, [onMouseClick, onMouseMove, selectedField]);
|
||||||
|
|
||||||
|
const selectedRecipientColor = useMemo(() => {
|
||||||
|
return selectedRecipientId ? getRecipientColorKey(selectedRecipientId) : 'green';
|
||||||
|
}, [selectedRecipientId, getRecipientColorKey]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-x-2 gap-y-2.5">
|
||||||
|
{fieldButtonList.map((field) => (
|
||||||
|
<button
|
||||||
|
disabled={isFieldsDisabled}
|
||||||
|
key={field.type}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedField(field.type)}
|
||||||
|
onMouseDown={() => setSelectedField(field.type)}
|
||||||
|
data-selected={selectedField === field.type ? true : undefined}
|
||||||
|
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
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground font-noto group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
|
||||||
|
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" />}
|
||||||
|
{t(field.name)}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedField && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground dark:text-muted-background font-noto pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white ring-2 transition duration-200 [container-type:size]',
|
||||||
|
RECIPIENT_COLOR_STYLES[selectedRecipientColor].base,
|
||||||
|
selectedField === FieldType.SIGNATURE && 'font-signature',
|
||||||
|
{
|
||||||
|
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
|
||||||
|
'dark:text-black/60': isFieldWithinBounds,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
top: coords.y,
|
||||||
|
left: coords.x,
|
||||||
|
height: fieldBounds.current.height,
|
||||||
|
width: fieldBounds.current.width,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">
|
||||||
|
{t(FRIENDLY_FIELD_TYPE[selectedField])}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,645 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { useLingui } from '@lingui/react/macro';
|
||||||
|
import type { FieldType } from '@prisma/client';
|
||||||
|
import Konva from 'konva';
|
||||||
|
import type { KonvaEventObject } from 'konva/lib/Node';
|
||||||
|
import type { Transformer } from 'konva/lib/shapes/Transformer';
|
||||||
|
import { CopyPlusIcon, SquareStackIcon, TrashIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
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 { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||||
|
import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
|
||||||
|
import {
|
||||||
|
MIN_FIELD_HEIGHT_PX,
|
||||||
|
MIN_FIELD_WIDTH_PX,
|
||||||
|
convertPixelToPercentage,
|
||||||
|
} from '@documenso/lib/universal/field-renderer/field-renderer';
|
||||||
|
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 { fieldButtonList } from './envelope-editor-fields-drag-drop';
|
||||||
|
|
||||||
|
export default function EnvelopeEditorFieldsPageRenderer() {
|
||||||
|
const { t, i18n } = useLingui();
|
||||||
|
const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
|
||||||
|
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
|
||||||
|
|
||||||
|
const interactiveTransformer = useRef<Transformer | null>(null);
|
||||||
|
|
||||||
|
const [selectedKonvaFieldGroups, setSelectedKonvaFieldGroups] = useState<Konva.Group[]>([]);
|
||||||
|
|
||||||
|
const [isFieldChanging, setIsFieldChanging] = useState(false);
|
||||||
|
const [pendingFieldCreation, setPendingFieldCreation] = useState<Konva.Rect | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
stage,
|
||||||
|
pageLayer,
|
||||||
|
canvasElement,
|
||||||
|
konvaContainer,
|
||||||
|
pageContext,
|
||||||
|
scaledViewport,
|
||||||
|
unscaledViewport,
|
||||||
|
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer));
|
||||||
|
|
||||||
|
const { _className, scale } = pageContext;
|
||||||
|
|
||||||
|
const localPageFields = useMemo(
|
||||||
|
() =>
|
||||||
|
editorFields.localFields.filter(
|
||||||
|
(field) =>
|
||||||
|
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
||||||
|
),
|
||||||
|
[editorFields.localFields, pageContext.pageNumber],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
|
||||||
|
const { current: container } = canvasElement;
|
||||||
|
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDragEvent = event.type === 'dragend';
|
||||||
|
|
||||||
|
const fieldGroup = event.target as Konva.Group;
|
||||||
|
const fieldFormId = fieldGroup.id();
|
||||||
|
|
||||||
|
// Note: This values are scaled.
|
||||||
|
const {
|
||||||
|
width: fieldPixelWidth,
|
||||||
|
height: fieldPixelHeight,
|
||||||
|
x: fieldX,
|
||||||
|
y: fieldY,
|
||||||
|
} = fieldGroup.getClientRect({
|
||||||
|
skipStroke: true,
|
||||||
|
skipShadow: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pageHeight = scaledViewport.height;
|
||||||
|
const pageWidth = scaledViewport.width;
|
||||||
|
|
||||||
|
// Calculate x and y as a percentage of the page width and height
|
||||||
|
const positionPercentX = (fieldX / pageWidth) * 100;
|
||||||
|
const positionPercentY = (fieldY / pageHeight) * 100;
|
||||||
|
|
||||||
|
// Get the bounds as a percentage of the page width and height
|
||||||
|
const fieldPageWidth = (fieldPixelWidth / pageWidth) * 100;
|
||||||
|
const fieldPageHeight = (fieldPixelHeight / pageHeight) * 100;
|
||||||
|
|
||||||
|
const fieldUpdates: Partial<TLocalField> = {
|
||||||
|
positionX: positionPercentX,
|
||||||
|
positionY: positionPercentY,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Do not update the width/height unless the field has actually been resized.
|
||||||
|
// This is because our calculations will shift the width/height slightly
|
||||||
|
// due to the way we convert between pixel and percentage.
|
||||||
|
if (!isDragEvent) {
|
||||||
|
fieldUpdates.width = fieldPageWidth;
|
||||||
|
fieldUpdates.height = fieldPageHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
editorFields.updateFieldByFormId(fieldFormId, fieldUpdates);
|
||||||
|
|
||||||
|
// Select the field if it is not already selected.
|
||||||
|
if (isDragEvent && interactiveTransformer.current?.nodes().length === 0) {
|
||||||
|
setSelectedFields([fieldGroup]);
|
||||||
|
}
|
||||||
|
|
||||||
|
pageLayer.current?.batchDraw();
|
||||||
|
};
|
||||||
|
|
||||||
|
const unsafeRenderFieldOnLayer = (field: TLocalField) => {
|
||||||
|
if (!pageLayer.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipient = envelope.recipients.find((r) => r.id === field.recipientId);
|
||||||
|
const isFieldEditable =
|
||||||
|
recipient !== undefined && canRecipientFieldsBeModified(recipient, envelope.fields);
|
||||||
|
|
||||||
|
const { fieldGroup } = renderField({
|
||||||
|
scale,
|
||||||
|
pageLayer: pageLayer.current,
|
||||||
|
field: {
|
||||||
|
renderId: field.formId,
|
||||||
|
...field,
|
||||||
|
customText: '',
|
||||||
|
inserted: false,
|
||||||
|
fieldMeta: field.fieldMeta,
|
||||||
|
},
|
||||||
|
translations: getClientSideFieldTranslations(i18n),
|
||||||
|
pageWidth: unscaledViewport.width,
|
||||||
|
pageHeight: unscaledViewport.height,
|
||||||
|
color: getRecipientColorKey(field.recipientId),
|
||||||
|
editable: isFieldEditable,
|
||||||
|
mode: 'edit',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isFieldEditable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldGroup.off('click');
|
||||||
|
fieldGroup.off('transformend');
|
||||||
|
fieldGroup.off('dragend');
|
||||||
|
|
||||||
|
// Set up field selection.
|
||||||
|
fieldGroup.on('click', () => {
|
||||||
|
removePendingField();
|
||||||
|
setSelectedFields([fieldGroup]);
|
||||||
|
pageLayer.current?.batchDraw();
|
||||||
|
});
|
||||||
|
|
||||||
|
fieldGroup.on('transformend', handleResizeOrMove);
|
||||||
|
fieldGroup.on('dragend', handleResizeOrMove);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderFieldOnLayer = (field: TLocalField) => {
|
||||||
|
try {
|
||||||
|
unsafeRenderFieldOnLayer(field);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setRenderError(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the Konva page canvas and all fields and interactions.
|
||||||
|
*/
|
||||||
|
const createPageCanvas = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
|
||||||
|
// Initialize snap guides layer
|
||||||
|
// snapGuideLayer.current = initializeSnapGuides(stage.current);
|
||||||
|
|
||||||
|
// Add transformer for resizing and rotating.
|
||||||
|
interactiveTransformer.current = createInteractiveTransformer(currentStage, currentPageLayer);
|
||||||
|
|
||||||
|
// Render the fields.
|
||||||
|
for (const field of localPageFields) {
|
||||||
|
renderFieldOnLayer(field);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle stage click to deselect.
|
||||||
|
currentStage.on('mousedown', (e) => {
|
||||||
|
removePendingField();
|
||||||
|
|
||||||
|
if (e.target === stage.current) {
|
||||||
|
setSelectedFields([]);
|
||||||
|
currentPageLayer.batchDraw();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// When an item is dragged, select it automatically.
|
||||||
|
const onDragStartOrEnd = (e: KonvaEventObject<Event>) => {
|
||||||
|
removePendingField();
|
||||||
|
|
||||||
|
if (!e.target.hasName('field-group')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsFieldChanging(e.type === 'dragstart');
|
||||||
|
|
||||||
|
const itemAlreadySelected = (interactiveTransformer.current?.nodes() || []).includes(
|
||||||
|
e.target,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Do nothing and allow the transformer to handle it.
|
||||||
|
// Required so when multiple items are selected, this won't deselect them.
|
||||||
|
if (itemAlreadySelected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedFields([e.target]);
|
||||||
|
};
|
||||||
|
|
||||||
|
currentStage.on('dragstart', onDragStartOrEnd);
|
||||||
|
currentStage.on('dragend', onDragStartOrEnd);
|
||||||
|
currentStage.on('transformstart', () => setIsFieldChanging(true));
|
||||||
|
currentStage.on('transformend', () => setIsFieldChanging(false));
|
||||||
|
|
||||||
|
currentPageLayer.batchDraw();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an interactive transformer for the fields.
|
||||||
|
*
|
||||||
|
* Allows:
|
||||||
|
* - Resizing
|
||||||
|
* - Moving
|
||||||
|
* - Selecting multiple fields
|
||||||
|
* - Selecting empty area to create fields
|
||||||
|
*/
|
||||||
|
const createInteractiveTransformer = (
|
||||||
|
currentStage: Konva.Stage,
|
||||||
|
currentPageLayer: Konva.Layer,
|
||||||
|
) => {
|
||||||
|
const transformer = new Konva.Transformer({
|
||||||
|
rotateEnabled: false,
|
||||||
|
keepRatio: false,
|
||||||
|
shouldOverdrawWholeArea: true,
|
||||||
|
ignoreStroke: true,
|
||||||
|
flipEnabled: false,
|
||||||
|
boundBoxFunc: (oldBox, newBox) => {
|
||||||
|
// Enforce minimum size
|
||||||
|
if (newBox.width < 30 || newBox.height < 20) {
|
||||||
|
return oldBox;
|
||||||
|
}
|
||||||
|
|
||||||
|
return newBox;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
currentPageLayer.add(transformer);
|
||||||
|
|
||||||
|
// Add selection rectangle.
|
||||||
|
const selectionRectangle = new Konva.Rect({
|
||||||
|
fill: 'rgba(24, 160, 251, 0.3)',
|
||||||
|
visible: false,
|
||||||
|
});
|
||||||
|
currentPageLayer.add(selectionRectangle);
|
||||||
|
|
||||||
|
let x1: number;
|
||||||
|
let y1: number;
|
||||||
|
let x2: number;
|
||||||
|
let y2: number;
|
||||||
|
|
||||||
|
currentStage.on('mousedown touchstart', (e) => {
|
||||||
|
// do nothing if we mousedown on any shape
|
||||||
|
if (e.target !== currentStage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointerPosition = currentStage.getPointerPosition();
|
||||||
|
|
||||||
|
if (!pointerPosition) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
x1 = pointerPosition.x / scale;
|
||||||
|
y1 = pointerPosition.y / scale;
|
||||||
|
x2 = pointerPosition.x / scale;
|
||||||
|
y2 = pointerPosition.y / scale;
|
||||||
|
|
||||||
|
selectionRectangle.setAttrs({
|
||||||
|
x: x1,
|
||||||
|
y: y1,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
visible: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
currentStage.on('mousemove touchmove', () => {
|
||||||
|
// do nothing if we didn't start selection
|
||||||
|
if (!selectionRectangle.visible()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectionRectangle.moveToTop();
|
||||||
|
|
||||||
|
const pointerPosition = currentStage.getPointerPosition();
|
||||||
|
|
||||||
|
if (!pointerPosition) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
x2 = pointerPosition.x / scale;
|
||||||
|
y2 = pointerPosition.y / scale;
|
||||||
|
|
||||||
|
selectionRectangle.setAttrs({
|
||||||
|
x: Math.min(x1, x2),
|
||||||
|
y: Math.min(y1, y2),
|
||||||
|
width: Math.abs(x2 - x1),
|
||||||
|
height: Math.abs(y2 - y1),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
currentStage.on('mouseup touchend', () => {
|
||||||
|
// do nothing if we didn't start selection
|
||||||
|
if (!selectionRectangle.visible()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update visibility in timeout, so we can check it in click event
|
||||||
|
setTimeout(() => {
|
||||||
|
selectionRectangle.visible(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
const stageFieldGroups = currentStage.find('.field-group') || [];
|
||||||
|
const box = selectionRectangle.getClientRect();
|
||||||
|
const selectedFieldGroups = stageFieldGroups.filter(
|
||||||
|
(shape) => Konva.Util.haveIntersection(box, shape.getClientRect()) && shape.draggable(),
|
||||||
|
);
|
||||||
|
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.
|
||||||
|
if (
|
||||||
|
selectedFieldGroups.length === 0 &&
|
||||||
|
canvasElement.current &&
|
||||||
|
unscaledBoxWidth > MIN_FIELD_WIDTH_PX &&
|
||||||
|
unscaledBoxHeight > MIN_FIELD_HEIGHT_PX &&
|
||||||
|
editorFields.selectedRecipient &&
|
||||||
|
canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields)
|
||||||
|
) {
|
||||||
|
const pendingFieldCreation = new Konva.Rect({
|
||||||
|
name: 'pending-field-creation',
|
||||||
|
x: box.x / scale,
|
||||||
|
y: box.y / scale,
|
||||||
|
width: unscaledBoxWidth,
|
||||||
|
height: unscaledBoxHeight,
|
||||||
|
fill: 'rgba(24, 160, 251, 0.3)',
|
||||||
|
});
|
||||||
|
|
||||||
|
currentPageLayer.add(pendingFieldCreation);
|
||||||
|
setPendingFieldCreation(pendingFieldCreation);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clicks should select/deselect shapes
|
||||||
|
currentStage.on('click tap', function (e) {
|
||||||
|
// if we are selecting with rect, do nothing
|
||||||
|
if (
|
||||||
|
selectionRectangle.visible() &&
|
||||||
|
selectionRectangle.width() > 0 &&
|
||||||
|
selectionRectangle.height() > 0
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If empty area clicked, remove all selections
|
||||||
|
if (e.target === stage.current) {
|
||||||
|
setSelectedFields([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do nothing if field not clicked, or if field is not editable
|
||||||
|
if (!e.target.hasName('field-group') || e.target.draggable() === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// do we pressed shift or ctrl?
|
||||||
|
const metaPressed = e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey;
|
||||||
|
const isSelected = transformer.nodes().indexOf(e.target) >= 0;
|
||||||
|
|
||||||
|
if (!metaPressed && !isSelected) {
|
||||||
|
// if no key pressed and the node is not selected
|
||||||
|
// select just one
|
||||||
|
setSelectedFields([e.target]);
|
||||||
|
} else if (metaPressed && isSelected) {
|
||||||
|
// if we pressed keys and node was selected
|
||||||
|
// we need to remove it from selection:
|
||||||
|
const nodes = transformer.nodes().slice(); // use slice to have new copy of array
|
||||||
|
// remove node from array
|
||||||
|
nodes.splice(nodes.indexOf(e.target), 1);
|
||||||
|
setSelectedFields(nodes);
|
||||||
|
} else if (metaPressed && !isSelected) {
|
||||||
|
// add the node into selection
|
||||||
|
const nodes = transformer.nodes().concat([e.target]);
|
||||||
|
setSelectedFields(nodes);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return transformer;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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())
|
||||||
|
) {
|
||||||
|
group.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// If it exists, rerender.
|
||||||
|
localPageFields.forEach((field) => {
|
||||||
|
renderFieldOnLayer(field);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rerender the transformer
|
||||||
|
interactiveTransformer.current?.forceUpdate();
|
||||||
|
|
||||||
|
pageLayer.current.batchDraw();
|
||||||
|
}, [localPageFields]);
|
||||||
|
|
||||||
|
const setSelectedFields = (nodes: Konva.Node[]) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
const fieldGroups = nodes.filter((node) => node.hasName('field-group')) as Konva.Group[];
|
||||||
|
|
||||||
|
interactiveTransformer.current?.nodes(fieldGroups);
|
||||||
|
setSelectedKonvaFieldGroups(fieldGroups);
|
||||||
|
|
||||||
|
if (fieldGroups.length === 0 || fieldGroups.length > 1) {
|
||||||
|
editorFields.setSelectedField(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle single field selection.
|
||||||
|
if (fieldGroups.length === 1) {
|
||||||
|
const fieldGroup = fieldGroups[0];
|
||||||
|
|
||||||
|
editorFields.setSelectedField(fieldGroup.id());
|
||||||
|
fieldGroup.moveToTop();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deletedSelectedFields = () => {
|
||||||
|
const fieldFormids = selectedKonvaFieldGroups
|
||||||
|
.map((field) => field.id())
|
||||||
|
.filter((field) => field !== undefined);
|
||||||
|
|
||||||
|
editorFields.removeFieldsByFormId(fieldFormids);
|
||||||
|
|
||||||
|
setSelectedFields([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const duplicatedSelectedFields = () => {
|
||||||
|
const fields = selectedKonvaFieldGroups
|
||||||
|
.map((field) => editorFields.getFieldByFormId(field.id()))
|
||||||
|
.filter((field) => field !== undefined);
|
||||||
|
|
||||||
|
for (const field of fields) {
|
||||||
|
editorFields.duplicateField(field);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const duplicatedSelectedFieldsOnAllPages = () => {
|
||||||
|
const fields = selectedKonvaFieldGroups
|
||||||
|
.map((field) => editorFields.getFieldByFormId(field.id()))
|
||||||
|
.filter((field) => field !== undefined);
|
||||||
|
|
||||||
|
for (const field of fields) {
|
||||||
|
editorFields.duplicateFieldToAllPages(field);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedFields([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a field from a pending field.
|
||||||
|
*/
|
||||||
|
const createFieldFromPendingTemplate = (pendingFieldCreation: Konva.Rect, type: FieldType) => {
|
||||||
|
const pixelWidth = pendingFieldCreation.width();
|
||||||
|
const pixelHeight = pendingFieldCreation.height();
|
||||||
|
const pixelX = pendingFieldCreation.x();
|
||||||
|
const pixelY = pendingFieldCreation.y();
|
||||||
|
|
||||||
|
removePendingField();
|
||||||
|
|
||||||
|
if (!canvasElement.current || !currentEnvelopeItem || !editorFields.selectedRecipient) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { fieldX, fieldY, fieldWidth, fieldHeight } = convertPixelToPercentage({
|
||||||
|
width: pixelWidth,
|
||||||
|
height: pixelHeight,
|
||||||
|
positionX: pixelX,
|
||||||
|
positionY: pixelY,
|
||||||
|
pageWidth: unscaledViewport.width,
|
||||||
|
pageHeight: unscaledViewport.height,
|
||||||
|
});
|
||||||
|
|
||||||
|
editorFields.addField({
|
||||||
|
envelopeItemId: currentEnvelopeItem.id,
|
||||||
|
page: pageContext.pageNumber,
|
||||||
|
type,
|
||||||
|
positionX: fieldX,
|
||||||
|
positionY: fieldY,
|
||||||
|
width: fieldWidth,
|
||||||
|
height: fieldHeight,
|
||||||
|
recipientId: editorFields.selectedRecipient.id,
|
||||||
|
fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[type]),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove any pending fields or rectangle on the canvas.
|
||||||
|
*/
|
||||||
|
const removePendingField = () => {
|
||||||
|
setPendingFieldCreation(null);
|
||||||
|
|
||||||
|
const pendingFieldCreation = pageLayer.current?.find('.pending-field-creation') || [];
|
||||||
|
|
||||||
|
for (const field of pendingFieldCreation) {
|
||||||
|
field.destroy();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!currentEnvelopeItem) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative w-full"
|
||||||
|
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
|
||||||
|
>
|
||||||
|
{selectedKonvaFieldGroups.length > 0 &&
|
||||||
|
interactiveTransformer.current &&
|
||||||
|
!isFieldChanging && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top:
|
||||||
|
interactiveTransformer.current.y() +
|
||||||
|
interactiveTransformer.current.getClientRect().height +
|
||||||
|
5 +
|
||||||
|
'px',
|
||||||
|
left:
|
||||||
|
interactiveTransformer.current.x() +
|
||||||
|
interactiveTransformer.current.getClientRect().width / 2 +
|
||||||
|
'px',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
gap: '8px',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
zIndex: 50,
|
||||||
|
}}
|
||||||
|
className="group flex items-center justify-evenly gap-x-1 rounded-md border bg-gray-900 p-0.5"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
title={t`Duplicate`}
|
||||||
|
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
|
||||||
|
onClick={() => duplicatedSelectedFields()}
|
||||||
|
onTouchEnd={() => duplicatedSelectedFields()}
|
||||||
|
>
|
||||||
|
<CopyPlusIcon className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
title={t`Duplicate on all pages`}
|
||||||
|
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
|
||||||
|
onClick={() => duplicatedSelectedFieldsOnAllPages()}
|
||||||
|
onTouchEnd={() => duplicatedSelectedFieldsOnAllPages()}
|
||||||
|
>
|
||||||
|
<SquareStackIcon className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
title={t`Remove`}
|
||||||
|
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
|
||||||
|
onClick={() => deletedSelectedFields()}
|
||||||
|
onTouchEnd={() => deletedSelectedFields()}
|
||||||
|
>
|
||||||
|
<TrashIcon className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pendingFieldCreation && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top:
|
||||||
|
pendingFieldCreation.y() * scale +
|
||||||
|
pendingFieldCreation.getClientRect().height +
|
||||||
|
5 +
|
||||||
|
'px',
|
||||||
|
left:
|
||||||
|
pendingFieldCreation.x() * scale +
|
||||||
|
pendingFieldCreation.getClientRect().width / 2 +
|
||||||
|
'px',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
zIndex: 50,
|
||||||
|
}}
|
||||||
|
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) => (
|
||||||
|
<button
|
||||||
|
key={field.type}
|
||||||
|
onClick={() => createFieldFromPendingTemplate(pendingFieldCreation, field.type)}
|
||||||
|
className="hover:text-foreground col-span-1 w-full flex-shrink-0 rounded-sm px-2 py-1 text-xs hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
{t(field.name)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</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
|
||||||
|
className={`${_className}__canvas z-0`}
|
||||||
|
ref={canvasElement}
|
||||||
|
height={scaledViewport.height}
|
||||||
|
width={scaledViewport.width}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user