mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 00:03:33 +10:00
Compare commits
91 Commits
exp/effect
...
717fa8f870
| Author | SHA1 | Date | |
|---|---|---|---|
| 717fa8f870 | |||
| 8663c8f883 | |||
| c89ca83f44 | |||
| bbf1dd3c6b | |||
| c10c95ca00 | |||
| 4a0425b120 | |||
| a6e923dd8a | |||
| 7e38d06ef5 | |||
| d2a009d52e | |||
| 4e2443396c | |||
| 2e2980f04f | |||
| 3efe0de52f | |||
| efbd133f0e | |||
| 4993e8a306 | |||
| f93d34c38e | |||
| 8c228f965a | |||
| 9020bbc753 | |||
| 9350c53c7d | |||
| ffce7a2c81 | |||
| 353bdce86b | |||
| f6bdb34b56 | |||
| 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 | |||
| bb5c2edefd | |||
| 19565c1821 | |||
| 2603ae8b90 | |||
| 7d257236a6 | |||
| 31c1a9a783 | |||
| 657db3bc84 | |||
| 184ebdedf1 | |||
| 4012022f55 | |||
| 44f5da95b3 | |||
| 7eb882aea8 | |||
| dbf10e5b7b | |||
| fe4d3ed1fd | |||
| b8d07fd1a6 | |||
| 49fabeb0ec | |||
| 5a5bfe6e34 | |||
| d7e5a9eec7 | |||
| adefac81e2 | |||
| 67501b45cf | |||
| 17b36ac8e4 | |||
| 80e452afa2 | |||
| 1cb9de8083 |
@ -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.
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@ -56,4 +56,7 @@ logs.json
|
|||||||
|
|
||||||
# claude
|
# claude
|
||||||
.claude
|
.claude
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
|
||||||
|
# agents
|
||||||
|
.specs
|
||||||
|
|||||||
57
AGENTS.md
Normal file
57
AGENTS.md
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
# Agent Guidelines for Documenso
|
||||||
|
|
||||||
|
## Build/Test/Lint Commands
|
||||||
|
|
||||||
|
- `npm run build` - Build all packages
|
||||||
|
- `npm run lint` - Lint all packages
|
||||||
|
- `npm run lint:fix` - Auto-fix linting issues
|
||||||
|
- `npm run test:e2e` - Run E2E tests with Playwright
|
||||||
|
- `npm run test:dev -w @documenso/app-tests` - Run single E2E test in dev mode
|
||||||
|
- `npm run test-ui:dev -w @documenso/app-tests` - Run E2E tests with UI
|
||||||
|
- `npm run format` - Format code with Prettier
|
||||||
|
- `npm run dev` - Start development server for Remix app
|
||||||
|
|
||||||
|
## Code Style Guidelines
|
||||||
|
|
||||||
|
- Use TypeScript for all code; prefer `type` over `interface`
|
||||||
|
- Use functional components with `const Component = () => {}`
|
||||||
|
- Never use classes; prefer functional/declarative patterns
|
||||||
|
- Use descriptive variable names with auxiliary verbs (isLoading, hasError)
|
||||||
|
- Directory names: lowercase with dashes (auth-wizard)
|
||||||
|
- Use named exports for components
|
||||||
|
- Never use 'use client' directive
|
||||||
|
- Never use 1-line if statements
|
||||||
|
- Structure files: exported component, subcomponents, helpers, static content, types
|
||||||
|
|
||||||
|
## Error Handling & Validation
|
||||||
|
|
||||||
|
- Use custom AppError class when throwing errors
|
||||||
|
- When catching errors on the frontend use `const error = AppError.parse(error)` to get the error code
|
||||||
|
- Use early returns and guard clauses
|
||||||
|
- Use Zod for form validation and react-hook-form for forms
|
||||||
|
- Use error boundaries for unexpected errors
|
||||||
|
|
||||||
|
## UI & Styling
|
||||||
|
|
||||||
|
- Use Shadcn UI, Radix, and Tailwind CSS with mobile-first approach
|
||||||
|
- Use `<Form>` `<FormItem>` elements with fieldset having `:disabled` attribute when loading
|
||||||
|
- Use Lucide icons with longhand names (HomeIcon vs Home)
|
||||||
|
|
||||||
|
## TRPC Routes
|
||||||
|
|
||||||
|
- Each route in own file: `routers/teams/create-team.ts`
|
||||||
|
- Associated types file: `routers/teams/create-team.types.ts`
|
||||||
|
- Request/response schemas: `Z[RouteName]RequestSchema`, `Z[RouteName]ResponseSchema`
|
||||||
|
- Only use GET and POST methods in OpenAPI meta
|
||||||
|
- Deconstruct input argument on its own line
|
||||||
|
- Prefer route names such as get/getMany/find/create/update/delete
|
||||||
|
- "create" routes request schema should have the ID and data in the top level
|
||||||
|
- "update" routes request schema should have the ID in the top level and the data in a nested "data" object
|
||||||
|
|
||||||
|
## Translations & Remix
|
||||||
|
|
||||||
|
- Use `<Trans>string</Trans>` for JSX translations from `@lingui/react/macro`
|
||||||
|
- Use `t\`string\`` macro for TypeScript translations
|
||||||
|
- Use `(params: Route.Params)` and `(loaderData: Route.LoaderData)` for routes
|
||||||
|
- Directly return data from loaders, don't use `json()`
|
||||||
|
- Use `superLoaderJson` when sending complex data through loaders such as dates or prisma decimals
|
||||||
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
|
||||||
@ -214,8 +214,6 @@ For detailed instructions on how to configure and run the Docker container, plea
|
|||||||
|
|
||||||
We support a variety of deployment methods, and are actively working on adding more. Stay tuned for updates!
|
We support a variety of deployment methods, and are actively working on adding more. Stay tuned for updates!
|
||||||
|
|
||||||
> Please note that the below deployment methods are for v0.9, we will update these to v1.0 once it has been released.
|
|
||||||
|
|
||||||
### Fetch, configure, and build
|
### Fetch, configure, and build
|
||||||
|
|
||||||
First, clone the code from Github:
|
First, clone the code from Github:
|
||||||
@ -258,7 +256,7 @@ npm run start
|
|||||||
|
|
||||||
This will start the server on `localhost:3000`. For now, any reverse proxy can then do the frontend and SSL termination.
|
This will start the server on `localhost:3000`. For now, any reverse proxy can then do the frontend and SSL termination.
|
||||||
|
|
||||||
> If you want to run with another port than 3000, you can start the application with `next -p <ANY PORT>` from the `apps/web` folder.
|
> If you want to run with another port than 3000, you can start the application with `next -p <ANY PORT>` from the `apps/remix` folder.
|
||||||
|
|
||||||
### Run as a service
|
### Run as a service
|
||||||
|
|
||||||
@ -308,7 +306,7 @@ The Web UI can be found at http://localhost:9000, while the SMTP port will be on
|
|||||||
|
|
||||||
### Support IPv6
|
### Support IPv6
|
||||||
|
|
||||||
If you are deploying to a cluster that uses only IPv6, You can use a custom command to pass a parameter to the Next.js start command
|
If you are deploying to a cluster that uses only IPv6, You can use a custom command to pass a parameter to the Remix start command
|
||||||
|
|
||||||
For local docker run
|
For local docker run
|
||||||
|
|
||||||
|
|||||||
21
SIGNING.md
21
SIGNING.md
@ -10,15 +10,26 @@ 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
|
||||||
|
|
||||||
|
# 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. 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**)
|
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/web/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)
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
|
|||||||
@ -25,7 +25,7 @@ The translation files are organized into folders represented by their respective
|
|||||||
Each PO file contains translations which look like this:
|
Each PO file contains translations which look like this:
|
||||||
|
|
||||||
```po
|
```po
|
||||||
#: apps/web/src/app/(signing)/sign/[token]/no-longer-available.tsx:61
|
#: apps/remix/app/(signing)/sign/[token]/no-longer-available.tsx:61
|
||||||
msgid "Want to send slick signing links like this one? <0>Check out Documenso.</0>"
|
msgid "Want to send slick signing links like this one? <0>Check out Documenso.</0>"
|
||||||
msgstr "Möchten Sie auffällige Signatur-Links wie diesen senden? <0>Überprüfen Sie Documenso.</0>"
|
msgstr "Möchten Sie auffällige Signatur-Links wie diesen senden? <0>Überprüfen Sie Documenso.</0>"
|
||||||
```
|
```
|
||||||
|
|||||||
@ -54,7 +54,7 @@ Install the project dependencies as follows:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm i
|
npm i
|
||||||
npm run build:web
|
npm run build
|
||||||
npm run prisma:migrate-deploy
|
npm run prisma:migrate-deploy
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -69,7 +69,7 @@ npm run start
|
|||||||
This will start the server on `localhost:3000`. Any reverse proxy can handle the front end and SSL termination.
|
This will start the server on `localhost:3000`. Any reverse proxy can handle the front end and SSL termination.
|
||||||
|
|
||||||
<Callout type="info">
|
<Callout type="info">
|
||||||
If you want to run with another port than `3000`, you can start the application with `next -p <ANY PORT>` from the `apps/web` folder.
|
If you want to run with another port than `3000`, you can start the application with `next -p <ANY PORT>` from the `apps/remix` folder.
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
</Steps>
|
</Steps>
|
||||||
@ -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
|
||||||
@ -249,7 +322,7 @@ After=network.target
|
|||||||
Environment=PATH=/path/to/your/node/binaries
|
Environment=PATH=/path/to/your/node/binaries
|
||||||
Type=simple
|
Type=simple
|
||||||
User=www-data
|
User=www-data
|
||||||
WorkingDirectory=/var/www/documenso/apps/web
|
WorkingDirectory=/var/www/documenso/apps/remix
|
||||||
ExecStart=/usr/bin/next start -p 3500
|
ExecStart=/usr/bin/next start -p 3500
|
||||||
TimeoutSec=15
|
TimeoutSec=15
|
||||||
Restart=always
|
Restart=always
|
||||||
|
|||||||
@ -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();
|
||||||
|
|
||||||
@ -34,7 +33,7 @@ export const AdminDocumentDeleteDialog = ({ document }: AdminDocumentDeleteDialo
|
|||||||
const [reason, setReason] = useState('');
|
const [reason, setReason] = useState('');
|
||||||
|
|
||||||
const { mutateAsync: deleteDocument, isPending: isDeletingDocument } =
|
const { mutateAsync: deleteDocument, isPending: isDeletingDocument } =
|
||||||
trpc.admin.deleteDocument.useMutation();
|
trpc.admin.document.delete.useMutation();
|
||||||
|
|
||||||
const handleDeleteDocument = async () => {
|
const handleDeleteDocument = async () => {
|
||||||
try {
|
try {
|
||||||
@ -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`),
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { 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 { useLingui } from '@lingui/react/macro';
|
||||||
@ -96,17 +96,16 @@ export const AdminOrganisationCreateDialog = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
useEffect(() => {
|
||||||
if (!value) {
|
form.reset();
|
||||||
form.reset();
|
}, [open, form]);
|
||||||
}
|
|
||||||
if (!form.formState.isSubmitting) {
|
|
||||||
setOpen(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
<Dialog
|
||||||
|
{...props}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||||
|
>
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button className="flex-shrink-0" variant="secondary">
|
<Button className="flex-shrink-0" variant="secondary">
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -3,12 +3,12 @@ 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 { User } from '@prisma/client';
|
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@ -25,7 +25,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
|
|
||||||
export type AdminUserDeleteDialogProps = {
|
export type AdminUserDeleteDialogProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
user: User;
|
user: TGetUserResponse;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AdminUserDeleteDialog = ({ className, user }: AdminUserDeleteDialogProps) => {
|
export const AdminUserDeleteDialog = ({ className, user }: AdminUserDeleteDialogProps) => {
|
||||||
@ -35,7 +35,7 @@ export const AdminUserDeleteDialog = ({ className, user }: AdminUserDeleteDialog
|
|||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
|
|
||||||
const { mutateAsync: deleteUser, isPending: isDeletingUser } =
|
const { mutateAsync: deleteUser, isPending: isDeletingUser } =
|
||||||
trpc.admin.deleteUser.useMutation();
|
trpc.admin.user.delete.useMutation();
|
||||||
|
|
||||||
const onDeleteAccount = async () => {
|
const onDeleteAccount = async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -3,11 +3,11 @@ 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 { User } from '@prisma/client';
|
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@ -24,7 +24,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
|
|
||||||
export type AdminUserDisableDialogProps = {
|
export type AdminUserDisableDialogProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
userToDisable: User;
|
userToDisable: TGetUserResponse;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AdminUserDisableDialog = ({
|
export const AdminUserDisableDialog = ({
|
||||||
@ -37,7 +37,7 @@ export const AdminUserDisableDialog = ({
|
|||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
|
|
||||||
const { mutateAsync: disableUser, isPending: isDisablingUser } =
|
const { mutateAsync: disableUser, isPending: isDisablingUser } =
|
||||||
trpc.admin.disableUser.useMutation();
|
trpc.admin.user.disable.useMutation();
|
||||||
|
|
||||||
const onDisableAccount = async () => {
|
const onDisableAccount = async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -3,11 +3,11 @@ 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 { User } from '@prisma/client';
|
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@ -24,7 +24,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
|
|
||||||
export type AdminUserEnableDialogProps = {
|
export type AdminUserEnableDialogProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
userToEnable: User;
|
userToEnable: TGetUserResponse;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AdminUserEnableDialog = ({ className, userToEnable }: AdminUserEnableDialogProps) => {
|
export const AdminUserEnableDialog = ({ className, userToEnable }: AdminUserEnableDialogProps) => {
|
||||||
@ -34,7 +34,7 @@ export const AdminUserEnableDialog = ({ className, userToEnable }: AdminUserEnab
|
|||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
|
|
||||||
const { mutateAsync: enableUser, isPending: isEnablingUser } =
|
const { mutateAsync: enableUser, isPending: isEnablingUser } =
|
||||||
trpc.admin.enableUser.useMutation();
|
trpc.admin.user.enable.useMutation();
|
||||||
|
|
||||||
const onEnableAccount = async () => {
|
const onEnableAccount = async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -3,12 +3,12 @@ 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 { User } from '@prisma/client';
|
|
||||||
import { useRevalidator } from 'react-router';
|
import { useRevalidator } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@ -25,7 +25,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
|
|
||||||
export type AdminUserResetTwoFactorDialogProps = {
|
export type AdminUserResetTwoFactorDialogProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
user: User;
|
user: TGetUserResponse;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AdminUserResetTwoFactorDialog = ({
|
export const AdminUserResetTwoFactorDialog = ({
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
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';
|
||||||
@ -49,7 +49,7 @@ export const DocumentDeleteDialog = ({
|
|||||||
const [inputValue, setInputValue] = useState('');
|
const [inputValue, setInputValue] = useState('');
|
||||||
const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT);
|
const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT);
|
||||||
|
|
||||||
const { mutateAsync: deleteDocument, isPending } = trpcReact.document.deleteDocument.useMutation({
|
const { mutateAsync: deleteDocument, isPending } = trpcReact.document.delete.useMutation({
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
void refreshLimits();
|
void refreshLimits();
|
||||||
|
|
||||||
@ -73,23 +73,20 @@ export const DocumentDeleteDialog = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setInputValue('');
|
||||||
|
setIsDeleteEnabled(status === DocumentStatus.DRAFT);
|
||||||
|
}
|
||||||
|
}, [open, status]);
|
||||||
|
|
||||||
const onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setInputValue(event.target.value);
|
setInputValue(event.target.value);
|
||||||
setIsDeleteEnabled(event.target.value === _(deleteMessage));
|
setIsDeleteEnabled(event.target.value === _(deleteMessage));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
|
||||||
if (value) {
|
|
||||||
setInputValue('');
|
|
||||||
setIsDeleteEnabled(status === DocumentStatus.DRAFT);
|
|
||||||
}
|
|
||||||
if (!isPending) {
|
|
||||||
onOpenChange(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<Dialog open={open} onOpenChange={(value) => !isPending && onOpenChange(value)}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
|
|||||||
@ -36,11 +36,12 @@ export const DocumentDuplicateDialog = ({
|
|||||||
|
|
||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery(
|
const { data: document, isLoading } = trpcReact.document.get.useQuery(
|
||||||
{
|
{
|
||||||
documentId: id,
|
documentId: id,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
queryHash: `document-duplicate-dialog-${id}`,
|
||||||
enabled: open === true,
|
enabled: open === true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -55,15 +56,15 @@ export const DocumentDuplicateDialog = ({
|
|||||||
const documentsPath = formatDocumentsPath(team.url);
|
const documentsPath = formatDocumentsPath(team.url);
|
||||||
|
|
||||||
const { mutateAsync: duplicateDocument, isPending: isDuplicateLoading } =
|
const { mutateAsync: duplicateDocument, isPending: isDuplicateLoading } =
|
||||||
trpcReact.document.duplicateDocument.useMutation({
|
trpcReact.document.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);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
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';
|
||||||
@ -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,13 +81,24 @@ export const DocumentMoveToFolderDialog = ({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { mutateAsync: moveDocumentToFolder } = trpc.folder.moveDocumentToFolder.useMutation();
|
const { mutateAsync: updateDocument } = trpc.document.update.useMutation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
form.reset();
|
||||||
|
setSearchTerm('');
|
||||||
|
} else {
|
||||||
|
form.reset({ folderId: currentFolderId });
|
||||||
|
}
|
||||||
|
}, [open, currentFolderId, form]);
|
||||||
|
|
||||||
const onSubmit = async (data: TMoveDocumentFormSchema) => {
|
const onSubmit = async (data: TMoveDocumentFormSchema) => {
|
||||||
try {
|
try {
|
||||||
await moveDocumentToFolder({
|
await updateDocument({
|
||||||
documentId,
|
documentId,
|
||||||
folderId: data.folderId ?? null,
|
data: {
|
||||||
|
folderId: data.folderId ?? null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const documentsPath = formatDocumentsPath(team.url);
|
const documentsPath = formatDocumentsPath(team.url);
|
||||||
@ -136,22 +147,12 @@ export const DocumentMoveToFolderDialog = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
|
||||||
if (!value) {
|
|
||||||
form.reset();
|
|
||||||
setSearchTerm('');
|
|
||||||
} else {
|
|
||||||
form.reset({ folderId: currentFolderId });
|
|
||||||
}
|
|
||||||
onOpenChange(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredFolders = folders?.data.filter((folder) =>
|
const filteredFolders = folders?.data.filter((folder) =>
|
||||||
folder.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
folder.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
<Dialog {...props} open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
|
|||||||
@ -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[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -71,7 +75,7 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
|
|||||||
document.status !== 'PENDING' ||
|
document.status !== 'PENDING' ||
|
||||||
!recipients.some((r) => r.signingStatus === SigningStatus.NOT_SIGNED);
|
!recipients.some((r) => r.signingStatus === SigningStatus.NOT_SIGNED);
|
||||||
|
|
||||||
const { mutateAsync: resendDocument } = trpcReact.document.resendDocument.useMutation();
|
const { mutateAsync: resendDocument } = trpcReact.document.redistribute.useMutation();
|
||||||
|
|
||||||
const form = useForm<TResendDocumentFormSchema>({
|
const form = useForm<TResendDocumentFormSchema>({
|
||||||
resolver: zodResolver(ZResendDocumentFormSchema),
|
resolver: zodResolver(ZResendDocumentFormSchema),
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
214
apps/remix/app/components/dialogs/envelope-download-dialog.tsx
Normal file
214
apps/remix/app/components/dialogs/envelope-download-dialog.tsx
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { useLingui } from '@lingui/react/macro';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { type DocumentData, DocumentStatus, type EnvelopeItem } from '@prisma/client';
|
||||||
|
import { DownloadIcon, FileTextIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { downloadFile } from '@documenso/lib/client-only/download-file';
|
||||||
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
type EnvelopeItemToDownload = Pick<EnvelopeItem, 'id' | 'title' | 'order'> & {
|
||||||
|
documentData: DocumentData;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EnvelopeDownloadDialogProps = {
|
||||||
|
envelopeId: string;
|
||||||
|
envelopeStatus: DocumentStatus;
|
||||||
|
envelopeItems?: EnvelopeItemToDownload[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The recipient token to download the document.
|
||||||
|
*
|
||||||
|
* If not provided, it will be assumed that the current user can access the document.
|
||||||
|
*/
|
||||||
|
token?: string;
|
||||||
|
trigger: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EnvelopeDownloadDialog = ({
|
||||||
|
envelopeId,
|
||||||
|
envelopeStatus,
|
||||||
|
envelopeItems: initialEnvelopeItems,
|
||||||
|
token,
|
||||||
|
trigger,
|
||||||
|
}: EnvelopeDownloadDialogProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const [isDownloadingState, setIsDownloadingState] = useState<{
|
||||||
|
[envelopeItemIdAndVersion: string]: boolean;
|
||||||
|
}>({});
|
||||||
|
|
||||||
|
const generateDownloadKey = (envelopeItemId: string, version: 'original' | 'signed') =>
|
||||||
|
`${envelopeItemId}-${version}`;
|
||||||
|
|
||||||
|
const { data: envelopeItemsPayload, isLoading: isLoadingEnvelopeItems } =
|
||||||
|
trpc.envelope.item.getManyByToken.useQuery(
|
||||||
|
{
|
||||||
|
envelopeId,
|
||||||
|
access: token ? { type: 'recipient', token } : { type: 'user' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
initialData: initialEnvelopeItems ? { envelopeItems: initialEnvelopeItems } : undefined,
|
||||||
|
enabled: open,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const envelopeItems = envelopeItemsPayload?.envelopeItems || [];
|
||||||
|
|
||||||
|
const onDownload = async (
|
||||||
|
envelopeItem: EnvelopeItemToDownload,
|
||||||
|
version: 'original' | 'signed',
|
||||||
|
) => {
|
||||||
|
const { id: envelopeItemId } = envelopeItem;
|
||||||
|
|
||||||
|
if (isDownloadingState[generateDownloadKey(envelopeItemId, version)]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsDownloadingState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[generateDownloadKey(envelopeItemId, version)]: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const downloadUrl = token
|
||||||
|
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/token/${token}/envelopeItem/${envelopeItemId}/download/${version}`
|
||||||
|
: `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/envelope/${envelopeId}/envelopeItem/${envelopeItemId}/download/${version}`;
|
||||||
|
|
||||||
|
const blob = await fetch(downloadUrl).then(async (res) => await res.blob());
|
||||||
|
|
||||||
|
const baseTitle = envelopeItem.title.replace(/\.pdf$/, '');
|
||||||
|
const suffix = version === 'signed' ? '_signed.pdf' : '.pdf';
|
||||||
|
const filename = `${baseTitle}${suffix}`;
|
||||||
|
|
||||||
|
downloadFile({
|
||||||
|
filename,
|
||||||
|
data: blob,
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsDownloadingState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[generateDownloadKey(envelopeItemId, version)]: false,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
setIsDownloadingState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[generateDownloadKey(envelopeItemId, version)]: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t`Something went wrong`,
|
||||||
|
description: t`This document could not be downloaded at this time. Please try again.`,
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 7500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(value) => setOpen(value)}>
|
||||||
|
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Download Files</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>Select the files you would like to download.</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{isLoadingEnvelopeItems ? (
|
||||||
|
<>
|
||||||
|
{Array.from({ length: 2 }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="border-border bg-card flex items-center gap-2 rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
<Skeleton className="h-10 w-10 flex-shrink-0 rounded-lg" />
|
||||||
|
|
||||||
|
<div className="flex w-full flex-col gap-2">
|
||||||
|
<Skeleton className="h-4 w-28 rounded-lg" />
|
||||||
|
<Skeleton className="h-4 w-20 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Skeleton className="h-10 w-20 flex-shrink-0 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
envelopeItems.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="border-border bg-card hover:bg-accent/50 flex items-center gap-4 rounded-lg border p-4 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg">
|
||||||
|
<FileTextIcon className="text-primary h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h4 className="text-foreground truncate text-sm font-medium">{item.title}</h4>
|
||||||
|
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||||
|
<Trans>PDF Document</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-shrink-0 items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs"
|
||||||
|
onClick={async () => onDownload(item, 'original')}
|
||||||
|
loading={isDownloadingState[generateDownloadKey(item.id, 'original')]}
|
||||||
|
>
|
||||||
|
{!isDownloadingState[generateDownloadKey(item.id, 'original')] && (
|
||||||
|
<DownloadIcon className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
<Trans>Original</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{envelopeStatus === DocumentStatus.COMPLETED && (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs"
|
||||||
|
onClick={async () => onDownload(item, 'signed')}
|
||||||
|
loading={isDownloadingState[generateDownloadKey(item.id, 'signed')]}
|
||||||
|
>
|
||||||
|
{!isDownloadingState[generateDownloadKey(item.id, 'signed')] && (
|
||||||
|
<DownloadIcon className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
<Trans>Signed</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
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 ({ duplicatedEnvelopeId }) => {
|
||||||
|
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}/${duplicatedEnvelopeId}/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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
@ -80,15 +80,14 @@ export const FolderCreateDialog = ({ type, trigger, ...props }: FolderCreateDial
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
useEffect(() => {
|
||||||
if (!value) {
|
if (!isCreateFolderOpen) {
|
||||||
form.reset();
|
form.reset();
|
||||||
}
|
}
|
||||||
setIsCreateFolderOpen(value);
|
}, [isCreateFolderOpen, form]);
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog {...props} open={isCreateFolderOpen} onOpenChange={handleOpenChange}>
|
<Dialog {...props} open={isCreateFolderOpen} onOpenChange={setIsCreateFolderOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
@ -61,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);
|
||||||
@ -90,15 +92,14 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
useEffect(() => {
|
||||||
if (!value) {
|
if (!isOpen) {
|
||||||
form.reset();
|
form.reset();
|
||||||
}
|
}
|
||||||
onOpenChange(value);
|
}, [isOpen]);
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { 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 { useLingui } from '@lingui/react/macro';
|
||||||
@ -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,
|
||||||
parentId: targetFolderId || null,
|
data: {
|
||||||
|
parentId: targetFolderId || null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
@ -97,13 +101,12 @@ export const FolderMoveDialog = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
useEffect(() => {
|
||||||
if (!value) {
|
if (!isOpen) {
|
||||||
form.reset();
|
form.reset();
|
||||||
setSearchTerm('');
|
setSearchTerm('');
|
||||||
}
|
}
|
||||||
onOpenChange(value);
|
}, [isOpen, form]);
|
||||||
};
|
|
||||||
|
|
||||||
// Filter out the current folder, only show folders of the same type, and filter by search term
|
// Filter out the current folder, only show folders of the same type, and filter by search term
|
||||||
const filteredFolders = foldersData?.filter(
|
const filteredFolders = foldersData?.filter(
|
||||||
@ -114,7 +117,7 @@ export const FolderMoveDialog = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
@ -59,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: {
|
||||||
@ -69,6 +69,15 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (folder) {
|
||||||
|
form.reset({
|
||||||
|
name: folder.name,
|
||||||
|
visibility: folder.visibility ?? DocumentVisibility.EVERYONE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [folder, form]);
|
||||||
|
|
||||||
const onFormSubmit = async (data: TUpdateFolderFormSchema) => {
|
const onFormSubmit = async (data: TUpdateFolderFormSchema) => {
|
||||||
if (!folder) {
|
if (!folder) {
|
||||||
return;
|
return;
|
||||||
@ -76,11 +85,11 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await updateFolder({
|
await updateFolder({
|
||||||
id: folder.id,
|
folderId: folder.id,
|
||||||
name: data.name,
|
data: {
|
||||||
visibility: isTeamContext
|
name: data.name,
|
||||||
? (data.visibility ?? DocumentVisibility.EVERYONE)
|
visibility: data.visibility,
|
||||||
: DocumentVisibility.EVERYONE,
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@ -99,18 +108,8 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
|
||||||
if (value && folder) {
|
|
||||||
form.reset({
|
|
||||||
name: folder.name,
|
|
||||||
visibility: folder.visibility ?? DocumentVisibility.EVERYONE,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
onOpenChange(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
@ -139,38 +138,36 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isTeamContext && (
|
<FormField
|
||||||
<FormField
|
control={form.control}
|
||||||
control={form.control}
|
name="visibility"
|
||||||
name="visibility"
|
render={({ field }) => (
|
||||||
render={({ field }) => (
|
<FormItem>
|
||||||
<FormItem>
|
<FormLabel>
|
||||||
<FormLabel>
|
<Trans>Visibility</Trans>
|
||||||
<Trans>Visibility</Trans>
|
</FormLabel>
|
||||||
</FormLabel>
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
<FormControl>
|
||||||
<FormControl>
|
<SelectTrigger>
|
||||||
<SelectTrigger>
|
<SelectValue placeholder={t`Select visibility`} />
|
||||||
<SelectValue placeholder={t`Select visibility`} />
|
</SelectTrigger>
|
||||||
</SelectTrigger>
|
</FormControl>
|
||||||
</FormControl>
|
<SelectContent>
|
||||||
<SelectContent>
|
<SelectItem value={DocumentVisibility.EVERYONE}>
|
||||||
<SelectItem value={DocumentVisibility.EVERYONE}>
|
<Trans>Everyone</Trans>
|
||||||
<Trans>Everyone</Trans>
|
</SelectItem>
|
||||||
</SelectItem>
|
<SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE}>
|
||||||
<SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE}>
|
<Trans>Managers and above</Trans>
|
||||||
<Trans>Managers and above</Trans>
|
</SelectItem>
|
||||||
</SelectItem>
|
<SelectItem value={DocumentVisibility.ADMIN}>
|
||||||
<SelectItem value={DocumentVisibility.ADMIN}>
|
<Trans>Admins only</Trans>
|
||||||
<Trans>Admins only</Trans>
|
</SelectItem>
|
||||||
</SelectItem>
|
</SelectContent>
|
||||||
</SelectContent>
|
</Select>
|
||||||
</Select>
|
<FormMessage />
|
||||||
<FormMessage />
|
</FormItem>
|
||||||
</FormItem>
|
)}
|
||||||
)}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<DialogClose asChild>
|
<DialogClose asChild>
|
||||||
|
|||||||
@ -76,7 +76,7 @@ export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCrea
|
|||||||
|
|
||||||
const [selectedPriceId, setSelectedPriceId] = useState<string>('');
|
const [selectedPriceId, setSelectedPriceId] = useState<string>('');
|
||||||
|
|
||||||
const [open, setOpen] = useState(actionSearchParam === 'add-organisation');
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
resolver: zodResolver(ZCreateOrganisationFormSchema),
|
resolver: zodResolver(ZCreateOrganisationFormSchema),
|
||||||
@ -91,19 +91,6 @@ export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCrea
|
|||||||
enabled: IS_BILLING_ENABLED(),
|
enabled: IS_BILLING_ENABLED(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
|
||||||
if (!value) {
|
|
||||||
form.reset();
|
|
||||||
if (actionSearchParam === 'add-organisation') {
|
|
||||||
updateSearchParams({ action: null });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!form.formState.isSubmitting) {
|
|
||||||
setOpen(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onFormSubmit = async ({ name }: TCreateOrganisationFormSchema) => {
|
const onFormSubmit = async ({ name }: TCreateOrganisationFormSchema) => {
|
||||||
try {
|
try {
|
||||||
const response = await createOrganisation({
|
const response = await createOrganisation({
|
||||||
@ -139,6 +126,17 @@ export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCrea
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (actionSearchParam === 'add-organisation') {
|
||||||
|
setOpen(true);
|
||||||
|
updateSearchParams({ action: null });
|
||||||
|
}
|
||||||
|
}, [actionSearchParam, open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.reset();
|
||||||
|
}, [open, form]);
|
||||||
|
|
||||||
const isIndividualPlan = (priceId: string) => {
|
const isIndividualPlan = (priceId: string) => {
|
||||||
return (
|
return (
|
||||||
plansData?.plans[INTERNAL_CLAIM_ID.INDIVIDUAL]?.monthlyPrice?.id === priceId ||
|
plansData?.plans[INTERNAL_CLAIM_ID.INDIVIDUAL]?.monthlyPrice?.id === priceId ||
|
||||||
@ -147,7 +145,11 @@ export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCrea
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
<Dialog
|
||||||
|
{...props}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||||
|
>
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button className="flex-shrink-0" variant="secondary">
|
<Button className="flex-shrink-0" variant="secondary">
|
||||||
@ -312,16 +314,13 @@ const BillingPlanForm = ({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}, [plans, t]);
|
}, [plans]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (value === '' && !canCreateFreeOrganisation && dynamicPlans.length > 0) {
|
if (value === '' && !canCreateFreeOrganisation) {
|
||||||
const defaultValue = dynamicPlans[0][billingPeriod]?.id ?? '';
|
onChange(dynamicPlans[0][billingPeriod]?.id ?? '');
|
||||||
if (defaultValue) {
|
|
||||||
onChange(defaultValue);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [canCreateFreeOrganisation, dynamicPlans, billingPeriod, onChange, value]);
|
}, [value]);
|
||||||
|
|
||||||
const onBillingPeriodChange = (billingPeriod: 'monthlyPrice' | 'yearlyPrice') => {
|
const onBillingPeriodChange = (billingPeriod: 'monthlyPrice' | 'yearlyPrice') => {
|
||||||
const plan = dynamicPlans.find(
|
const plan = dynamicPlans.find(
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
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';
|
||||||
@ -93,19 +93,14 @@ export const OrganisationDeleteDialog = ({ trigger }: OrganisationDeleteDialogPr
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
useEffect(() => {
|
||||||
if (form.formState.isSubmitting) {
|
if (!open) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!value) {
|
|
||||||
form.reset();
|
form.reset();
|
||||||
}
|
}
|
||||||
setOpen(value);
|
}, [open, form]);
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button variant="destructive">
|
<Button variant="destructive">
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { 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 { useLingui } from '@lingui/react/macro';
|
||||||
@ -73,6 +73,13 @@ export const OrganisationEmailCreateDialog = ({
|
|||||||
const { mutateAsync: createOrganisationEmail, isPending } =
|
const { mutateAsync: createOrganisationEmail, isPending } =
|
||||||
trpc.enterprise.organisation.email.create.useMutation();
|
trpc.enterprise.organisation.email.create.useMutation();
|
||||||
|
|
||||||
|
// Reset state when dialog closes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
form.reset();
|
||||||
|
}
|
||||||
|
}, [open, form]);
|
||||||
|
|
||||||
const onFormSubmit = async (data: TCreateOrganisationEmailFormSchema) => {
|
const onFormSubmit = async (data: TCreateOrganisationEmailFormSchema) => {
|
||||||
try {
|
try {
|
||||||
await createOrganisationEmail({
|
await createOrganisationEmail({
|
||||||
@ -107,17 +114,8 @@ export const OrganisationEmailCreateDialog = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
|
||||||
if (!value) {
|
|
||||||
form.reset();
|
|
||||||
}
|
|
||||||
if (!isPending) {
|
|
||||||
setOpen(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
<Dialog {...props} open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button className="flex-shrink-0" variant="secondary">
|
<Button className="flex-shrink-0" variant="secondary">
|
||||||
|
|||||||
@ -36,12 +36,6 @@ export const OrganisationEmailDeleteDialog = ({
|
|||||||
|
|
||||||
const organisation = useCurrentOrganisation();
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
|
||||||
if (!isDeleting) {
|
|
||||||
setOpen(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const { mutateAsync: deleteEmail, isPending: isDeleting } =
|
const { mutateAsync: deleteEmail, isPending: isDeleting } =
|
||||||
trpc.enterprise.organisation.email.delete.useMutation({
|
trpc.enterprise.organisation.email.delete.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@ -64,7 +58,7 @@ export const OrganisationEmailDeleteDialog = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<Dialog open={open} onOpenChange={(value) => !isDeleting && setOpen(value)}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button variant="secondary">
|
<Button variant="secondary">
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { 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 { useLingui } from '@lingui/react/macro';
|
||||||
@ -75,6 +75,14 @@ export const OrganisationEmailDomainCreateDialog = ({
|
|||||||
const { mutateAsync: createOrganisationEmail } =
|
const { mutateAsync: createOrganisationEmail } =
|
||||||
trpc.enterprise.organisation.emailDomain.create.useMutation();
|
trpc.enterprise.organisation.emailDomain.create.useMutation();
|
||||||
|
|
||||||
|
// Reset state when dialog closes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
form.reset();
|
||||||
|
setStep('domain');
|
||||||
|
}
|
||||||
|
}, [open, form]);
|
||||||
|
|
||||||
const onFormSubmit = async ({ domain }: TCreateOrganisationEmailDomainFormSchema) => {
|
const onFormSubmit = async ({ domain }: TCreateOrganisationEmailDomainFormSchema) => {
|
||||||
try {
|
try {
|
||||||
const { records } = await createOrganisationEmail({
|
const { records } = await createOrganisationEmail({
|
||||||
@ -110,18 +118,12 @@ export const OrganisationEmailDomainCreateDialog = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
|
||||||
if (!value) {
|
|
||||||
form.reset();
|
|
||||||
setStep('domain');
|
|
||||||
}
|
|
||||||
if (!form.formState.isSubmitting) {
|
|
||||||
setOpen(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
<Dialog
|
||||||
|
{...props}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||||
|
>
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button className="flex-shrink-0" variant="secondary">
|
<Button className="flex-shrink-0" variant="secondary">
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { 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 { useLingui } from '@lingui/react/macro';
|
||||||
@ -87,20 +87,23 @@ export const OrganisationEmailUpdateDialog = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
useEffect(() => {
|
||||||
if (value) {
|
if (!open) {
|
||||||
form.reset({
|
return;
|
||||||
emailName: organisationEmail.emailName,
|
|
||||||
// replyTo: organisationEmail.replyTo ?? undefined,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (!form.formState.isSubmitting) {
|
|
||||||
setOpen(value);
|
form.reset({
|
||||||
}
|
emailName: organisationEmail.emailName,
|
||||||
};
|
// replyTo: organisationEmail.replyTo ?? undefined,
|
||||||
|
});
|
||||||
|
}, [open, form]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
<Dialog
|
||||||
|
{...props}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||||
|
>
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||||
{trigger}
|
{trigger}
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { 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 { useLingui } from '@lingui/react/macro';
|
||||||
@ -117,17 +117,16 @@ export const OrganisationGroupCreateDialog = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
useEffect(() => {
|
||||||
if (!value) {
|
form.reset();
|
||||||
form.reset();
|
}, [open, form]);
|
||||||
}
|
|
||||||
if (!form.formState.isSubmitting) {
|
|
||||||
setOpen(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
<Dialog
|
||||||
|
{...props}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||||
|
>
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button className="flex-shrink-0" variant="secondary">
|
<Button className="flex-shrink-0" variant="secondary">
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, 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';
|
||||||
@ -193,6 +193,13 @@ export const OrganisationMemberInviteDialog = ({
|
|||||||
return 'form';
|
return 'form';
|
||||||
}, [fullOrganisation]);
|
}, [fullOrganisation]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
form.reset();
|
||||||
|
setInvitationType('INDIVIDUAL');
|
||||||
|
}
|
||||||
|
}, [open, form]);
|
||||||
|
|
||||||
const onFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const onFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (!e.target.files?.length) {
|
if (!e.target.files?.length) {
|
||||||
return;
|
return;
|
||||||
@ -260,18 +267,12 @@ export const OrganisationMemberInviteDialog = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
|
||||||
if (!value) {
|
|
||||||
form.reset();
|
|
||||||
setInvitationType('INDIVIDUAL');
|
|
||||||
}
|
|
||||||
if (!form.formState.isSubmitting) {
|
|
||||||
setOpen(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
<Dialog
|
||||||
|
{...props}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||||
|
>
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button variant="secondary">
|
<Button variant="secondary">
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
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';
|
||||||
@ -106,27 +106,32 @@ export const OrganisationMemberUpdateDialog = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
useEffect(() => {
|
||||||
if (value) {
|
if (!open) {
|
||||||
form.reset();
|
return;
|
||||||
if (
|
|
||||||
!isOrganisationRoleWithinUserHierarchy(currentUserOrganisationRole, organisationMemberRole)
|
|
||||||
) {
|
|
||||||
setOpen(false);
|
|
||||||
toast({
|
|
||||||
title: _(msg`You cannot modify a organisation member who has a higher role than you.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (!form.formState.isSubmitting) {
|
|
||||||
setOpen(value);
|
form.reset();
|
||||||
|
|
||||||
|
if (
|
||||||
|
!isOrganisationRoleWithinUserHierarchy(currentUserOrganisationRole, organisationMemberRole)
|
||||||
|
) {
|
||||||
|
setOpen(false);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`You cannot modify a organisation member who has a higher role than you.`),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, currentUserOrganisationRole, organisationMemberRole, form, toast]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
<Dialog
|
||||||
|
{...props}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||||
|
>
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button variant="secondary">
|
<Button variant="secondary">
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
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';
|
||||||
@ -65,9 +65,9 @@ export const PasskeyCreateDialog = ({ trigger, onSuccess, ...props }: PasskeyCre
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: createPasskeyRegistrationOptions, isPending } =
|
const { mutateAsync: createPasskeyRegistrationOptions, isPending } =
|
||||||
trpc.auth.createPasskeyRegistrationOptions.useMutation();
|
trpc.auth.passkey.createRegistrationOptions.useMutation();
|
||||||
|
|
||||||
const { mutateAsync: createPasskey } = trpc.auth.createPasskey.useMutation();
|
const { mutateAsync: createPasskey } = trpc.auth.passkey.create.useMutation();
|
||||||
|
|
||||||
const onFormSubmit = async ({ passkeyName }: TCreatePasskeyFormSchema) => {
|
const onFormSubmit = async ({ passkeyName }: TCreatePasskeyFormSchema) => {
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
@ -120,21 +120,24 @@ export const PasskeyCreateDialog = ({ trigger, onSuccess, ...props }: PasskeyCre
|
|||||||
return passkeyName;
|
return passkeyName;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDialogOpenChange = (value: boolean) => {
|
useEffect(() => {
|
||||||
if (!value) {
|
if (!open) {
|
||||||
const defaultPasskeyName = extractDefaultPasskeyName();
|
const defaultPasskeyName = extractDefaultPasskeyName();
|
||||||
|
|
||||||
form.reset({
|
form.reset({
|
||||||
passkeyName: defaultPasskeyName,
|
passkeyName: defaultPasskeyName,
|
||||||
});
|
});
|
||||||
|
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
}
|
}
|
||||||
if (!form.formState.isSubmitting) {
|
}, [open, form]);
|
||||||
setOpen(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog {...props} open={open} onOpenChange={handleDialogOpenChange}>
|
<Dialog
|
||||||
|
{...props}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||||
|
>
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button variant="secondary" loading={isPending}>
|
<Button variant="secondary" loading={isPending}>
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, 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';
|
||||||
@ -66,14 +66,14 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
|
|||||||
const updateSearchParams = useUpdateSearchParams();
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
const organisation = useCurrentOrganisation();
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
const actionSearchParam = searchParams?.get('action');
|
const [open, setOpen] = useState(false);
|
||||||
const shouldOpenDialog = actionSearchParam === 'add-team';
|
|
||||||
const [open, setOpen] = useState(shouldOpenDialog);
|
|
||||||
|
|
||||||
const { data: fullOrganisation } = trpc.organisation.get.useQuery({
|
const { data: fullOrganisation } = trpc.organisation.get.useQuery({
|
||||||
organisationReference: organisation.id,
|
organisationReference: organisation.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const actionSearchParam = searchParams?.get('action');
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
resolver: zodResolver(ZCreateTeamFormSchema),
|
resolver: zodResolver(ZCreateTeamFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@ -85,18 +85,6 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
|
|||||||
|
|
||||||
const { mutateAsync: createTeam } = trpc.team.create.useMutation();
|
const { mutateAsync: createTeam } = trpc.team.create.useMutation();
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
|
||||||
if (!value) {
|
|
||||||
form.reset();
|
|
||||||
if (shouldOpenDialog) {
|
|
||||||
updateSearchParams({ action: null });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!form.formState.isSubmitting) {
|
|
||||||
setOpen(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onFormSubmit = async ({ teamName, teamUrl, inheritMembers }: TCreateTeamFormSchema) => {
|
const onFormSubmit = async ({ teamName, teamUrl, inheritMembers }: TCreateTeamFormSchema) => {
|
||||||
try {
|
try {
|
||||||
await createTeam({
|
await createTeam({
|
||||||
@ -162,8 +150,23 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
|
|||||||
return 'form';
|
return 'form';
|
||||||
}, [fullOrganisation]);
|
}, [fullOrganisation]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (actionSearchParam === 'add-team') {
|
||||||
|
setOpen(true);
|
||||||
|
updateSearchParams({ action: null });
|
||||||
|
}
|
||||||
|
}, [actionSearchParam, open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.reset();
|
||||||
|
}, [open, form]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
<Dialog
|
||||||
|
{...props}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||||
|
>
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button className="flex-shrink-0" variant="secondary">
|
<Button className="flex-shrink-0" variant="secondary">
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
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';
|
||||||
@ -114,17 +114,14 @@ export const TeamDeleteDialog = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
useEffect(() => {
|
||||||
if (!value) {
|
if (!open) {
|
||||||
form.reset();
|
form.reset();
|
||||||
}
|
}
|
||||||
if (!form.formState.isSubmitting) {
|
}, [open, form]);
|
||||||
setOpen(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button variant="destructive">
|
<Button variant="destructive">
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
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';
|
||||||
@ -103,17 +103,18 @@ export const TeamEmailAddDialog = ({ teamId, trigger, ...props }: TeamEmailAddDi
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
useEffect(() => {
|
||||||
if (!value) {
|
if (!open) {
|
||||||
form.reset();
|
form.reset();
|
||||||
}
|
}
|
||||||
if (!form.formState.isSubmitting) {
|
}, [open, form]);
|
||||||
setOpen(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
<Dialog
|
||||||
|
{...props}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||||
|
>
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button variant="outline" loading={isPending} className="bg-background">
|
<Button variant="outline" loading={isPending} className="bg-background">
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
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';
|
||||||
@ -92,17 +92,18 @@ export const TeamEmailUpdateDialog = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
useEffect(() => {
|
||||||
if (!value) {
|
if (!open) {
|
||||||
form.reset();
|
form.reset();
|
||||||
}
|
}
|
||||||
if (!form.formState.isSubmitting) {
|
}, [open, form]);
|
||||||
setOpen(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
<Dialog
|
||||||
|
{...props}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||||
|
>
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button variant="outline" className="bg-background">
|
<Button variant="outline" className="bg-background">
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
@ -107,7 +107,7 @@ export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps)
|
|||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
handleClose();
|
setOpen(false);
|
||||||
} catch {
|
} catch {
|
||||||
toast({
|
toast({
|
||||||
title: t`An unknown error occurred`,
|
title: t`An unknown error occurred`,
|
||||||
@ -117,23 +117,17 @@ export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps)
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
useEffect(() => {
|
||||||
setOpen(false);
|
if (!open) {
|
||||||
form.reset();
|
form.reset();
|
||||||
setStep('SELECT');
|
setStep('SELECT');
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenChange = (isOpen: boolean) => {
|
|
||||||
if (!isOpen) {
|
|
||||||
handleClose();
|
|
||||||
}
|
}
|
||||||
};
|
}, [open, form]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
{...props}
|
{...props}
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={handleOpenChange}
|
|
||||||
// Disable automatic onOpenChange events to prevent dialog from closing if auser 'accidentally' clicks the overlay.
|
// Disable automatic onOpenChange events to prevent dialog from closing if auser 'accidentally' clicks the overlay.
|
||||||
// Since it would be annoying to redo the whole process.
|
// Since it would be annoying to redo the whole process.
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
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';
|
||||||
@ -106,17 +106,22 @@ export const TeamGroupUpdateDialog = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
useEffect(() => {
|
||||||
if (!form.formState.isSubmitting) {
|
if (!open) {
|
||||||
if (value) {
|
return;
|
||||||
form.reset();
|
|
||||||
}
|
|
||||||
setOpen(value);
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
form.reset();
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, team.currentTeamRole, teamGroupRole, form, toast]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
<Dialog
|
||||||
|
{...props}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||||
|
>
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button variant="secondary">
|
<Button variant="secondary">
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
@ -119,17 +119,20 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
useEffect(() => {
|
||||||
if (!value) {
|
if (!open) {
|
||||||
form.reset();
|
form.reset();
|
||||||
setStep('SELECT');
|
setStep('SELECT');
|
||||||
}
|
}
|
||||||
// Disable automatic onOpenChange events to prevent dialog from closing if user 'accidentally' clicks the overlay.
|
}, [open, form]);
|
||||||
// Since it would be annoying to redo the whole process, we handle open state manually
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
<Dialog
|
||||||
|
{...props}
|
||||||
|
open={open}
|
||||||
|
// Disable automatic onOpenChange events to prevent dialog from closing if auser 'accidentally' clicks the overlay.
|
||||||
|
// Since it would be annoying to redo the whole process.
|
||||||
|
>
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||||
<Button variant="secondary" onClick={() => setOpen(true)}>
|
<Button variant="secondary" onClick={() => setOpen(true)}>
|
||||||
<Trans>Add members</Trans>
|
<Trans>Add members</Trans>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
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';
|
||||||
@ -106,25 +106,30 @@ export const TeamMemberUpdateDialog = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
useEffect(() => {
|
||||||
if (value) {
|
if (!open) {
|
||||||
form.reset();
|
return;
|
||||||
if (!isTeamRoleWithinUserHierarchy(currentUserTeamRole, memberTeamRole)) {
|
|
||||||
setOpen(false);
|
|
||||||
toast({
|
|
||||||
title: _(msg`You cannot modify a team member who has a higher role than you.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (!form.formState.isSubmitting) {
|
|
||||||
setOpen(value);
|
form.reset();
|
||||||
|
|
||||||
|
if (!isTeamRoleWithinUserHierarchy(currentUserTeamRole, memberTeamRole)) {
|
||||||
|
setOpen(false);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`You cannot modify a team member who has a higher role than you.`),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, currentUserTeamRole, memberTeamRole, form, toast]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
|
<Dialog
|
||||||
|
{...props}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||||
|
>
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button variant="secondary">
|
<Button variant="secondary">
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,15 +1,17 @@
|
|||||||
import { useMemo, useState } from 'react';
|
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,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -186,313 +189,320 @@ export const TemplateDirectLinkDialog = ({
|
|||||||
const isLoading =
|
const isLoading =
|
||||||
isCreatingTemplateDirectLink || isTogglingTemplateAccess || isDeletingTemplateDirectLink;
|
isCreatingTemplateDirectLink || isTogglingTemplateAccess || isDeletingTemplateDirectLink;
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
useEffect(() => {
|
||||||
if (!isLoading) {
|
resetCreateTemplateDirectLink();
|
||||||
if (value) {
|
setCurrentStep(token ? 'MANAGE' : 'ONBOARD');
|
||||||
resetCreateTemplateDirectLink();
|
setSelectedRecipientId(null);
|
||||||
setCurrentStep(token ? 'MANAGE' : 'ONBOARD');
|
|
||||||
setSelectedRecipientId(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
onOpenChange(value);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}
|
}, [open]);
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<Dialog open={open} onOpenChange={(value) => !isLoading && setOpen(value)}>
|
||||||
<fieldset disabled={isLoading} className="relative">
|
<DialogTrigger asChild>
|
||||||
<AnimateGenericFadeInOut motionKey={currentStep}>
|
{trigger || (
|
||||||
{match({ token, currentStep })
|
<Button variant="outline" className="px-3">
|
||||||
.with({ token: P.nullish, currentStep: 'ONBOARD' }, () => (
|
<LinkIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Create Direct Signing Link</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription>
|
{directLink ? <Trans>Manage Direct Link</Trans> : <Trans>Create Direct Link</Trans>}
|
||||||
<Trans>Here's how it works:</Trans>
|
</Button>
|
||||||
</DialogDescription>
|
)}
|
||||||
</DialogHeader>
|
</DialogTrigger>
|
||||||
|
<DialogContent hideClose>
|
||||||
|
<fieldset disabled={isLoading} className="relative">
|
||||||
|
<AnimateGenericFadeInOut motionKey={currentStep}>
|
||||||
|
{match({ token, currentStep })
|
||||||
|
.with({ token: P.nullish, currentStep: 'ONBOARD' }, () => (
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Create Direct Signing Link</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
<ul className="mt-4 space-y-4 pl-12">
|
<DialogDescription>
|
||||||
{DIRECT_TEMPLATE_DOCUMENTATION.map((step, index) => (
|
<Trans>Here's how it works:</Trans>
|
||||||
<li className="relative" key={index}>
|
</DialogDescription>
|
||||||
<div className="absolute -left-12">
|
</DialogHeader>
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-full border-[3px] border-neutral-200 text-sm font-bold">
|
|
||||||
{index + 1}
|
<ul className="mt-4 space-y-4 pl-12">
|
||||||
|
{DIRECT_TEMPLATE_DOCUMENTATION.map((step, index) => (
|
||||||
|
<li className="relative" key={index}>
|
||||||
|
<div className="absolute -left-12">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-full border-[3px] border-neutral-200 text-sm font-bold">
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 className="font-semibold">{_(step.title)}</h3>
|
<h3 className="font-semibold">{_(step.title)}</h3>
|
||||||
<p className="text-muted-foreground mt-1 text-sm">{_(step.description)}</p>
|
<p className="text-muted-foreground mt-1 text-sm">{_(step.description)}</p>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{remaining.directTemplates === 0 && (
|
{remaining.directTemplates === 0 && (
|
||||||
<Alert variant="warning">
|
<Alert variant="warning">
|
||||||
<AlertTitle>
|
<AlertTitle>
|
||||||
<Trans>
|
<Trans>
|
||||||
Direct template link usage exceeded ({quota.directTemplates}/
|
Direct template link usage exceeded ({quota.directTemplates}/
|
||||||
{quota.directTemplates})
|
{quota.directTemplates})
|
||||||
</Trans>
|
</Trans>
|
||||||
</AlertTitle>
|
</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<Trans>
|
<Trans>
|
||||||
You have reached the maximum limit of {quota.directTemplates} direct
|
You have reached the maximum limit of {quota.directTemplates} direct
|
||||||
templates.{' '}
|
templates.{' '}
|
||||||
<Link
|
<Link
|
||||||
className="mt-1 block underline underline-offset-4"
|
className="mt-1 block underline underline-offset-4"
|
||||||
to={`/o/${organisation.url}/settings/billing`}
|
to={`/o/${organisation.url}/settings/billing`}
|
||||||
>
|
>
|
||||||
Upgrade your account to continue!
|
Upgrade your account to continue!
|
||||||
</Link>
|
</Link>
|
||||||
</Trans>
|
</Trans>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{remaining.directTemplates !== 0 && (
|
{remaining.directTemplates !== 0 && (
|
||||||
<DialogFooter className="mx-auto mt-4">
|
<DialogFooter className="mx-auto mt-4">
|
||||||
<Button type="button" onClick={() => setCurrentStep('SELECT_RECIPIENT')}>
|
<Button type="button" onClick={() => setCurrentStep('SELECT_RECIPIENT')}>
|
||||||
<Trans> Enable direct link signing</Trans>
|
<Trans> Enable direct link signing</Trans>
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
))
|
|
||||||
.with({ token: P.nullish, currentStep: 'SELECT_RECIPIENT' }, () => (
|
|
||||||
<DialogContent className="relative">
|
|
||||||
{isCreatingTemplateDirectLink && validDirectTemplateRecipients.length !== 0 && (
|
|
||||||
<div className="absolute inset-0 z-50 flex items-center justify-center rounded bg-white/50 dark:bg-black/50">
|
|
||||||
<LoaderIcon className="h-6 w-6 animate-spin text-gray-500" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Choose Direct Link Recipient</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription>
|
|
||||||
<Trans>Choose an existing recipient from below to continue</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="custom-scrollbar max-h-[60vh] overflow-y-auto rounded-md border">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>
|
|
||||||
<Trans>Recipient</Trans>
|
|
||||||
</TableHead>
|
|
||||||
<TableHead>
|
|
||||||
<Trans>Role</Trans>
|
|
||||||
</TableHead>
|
|
||||||
<TableHead></TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{validDirectTemplateRecipients.length === 0 && (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={3} className="h-16 text-center">
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
<Trans>No valid recipients found</Trans>
|
|
||||||
</p>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{validDirectTemplateRecipients.map((row) => (
|
|
||||||
<TableRow
|
|
||||||
className="cursor-pointer"
|
|
||||||
key={row.id}
|
|
||||||
onClick={async () => onRecipientTableRowClick(row.id)}
|
|
||||||
>
|
|
||||||
<TableCell>
|
|
||||||
<div className="text-muted-foreground text-sm">
|
|
||||||
<p>{row.name}</p>
|
|
||||||
<p className="text-muted-foreground/70 text-xs">{row.email}</p>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell className="text-muted-foreground text-sm">
|
|
||||||
{_(RECIPIENT_ROLES_DESCRIPTION[row.role].roleName)}
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell>
|
|
||||||
{selectedRecipientId === row.id ? (
|
|
||||||
<CircleDotIcon className="h-5 w-5 text-neutral-300" />
|
|
||||||
) : (
|
|
||||||
<CircleIcon className="h-5 w-5 text-neutral-300" />
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Prevent creating placeholder direct template recipient if the email already exists. */}
|
|
||||||
{!template.recipients.some(
|
|
||||||
(recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL,
|
|
||||||
) && (
|
|
||||||
<DialogFooter className="mx-auto">
|
|
||||||
<div className="flex flex-col items-center justify-center">
|
|
||||||
{validDirectTemplateRecipients.length !== 0 && (
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
<Trans>Or</Trans>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="mt-2"
|
|
||||||
loading={isCreatingTemplateDirectLink && !selectedRecipientId}
|
|
||||||
onClick={async () =>
|
|
||||||
createTemplateDirectLink({
|
|
||||||
templateId: template.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Trans>Create one automatically</Trans>
|
|
||||||
</Button>
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
))
|
||||||
|
.with({ token: P.nullish, currentStep: 'SELECT_RECIPIENT' }, () => (
|
||||||
|
<DialogContent className="relative">
|
||||||
|
{isCreatingTemplateDirectLink && validDirectTemplateRecipients.length !== 0 && (
|
||||||
|
<div className="absolute inset-0 z-50 flex items-center justify-center rounded bg-white/50 dark:bg-black/50">
|
||||||
|
<LoaderIcon className="h-6 w-6 animate-spin text-gray-500" />
|
||||||
</div>
|
</div>
|
||||||
</DialogFooter>
|
)}
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
))
|
|
||||||
.with({ token: P.string, currentStep: 'MANAGE' }, ({ token }) => (
|
|
||||||
<DialogContent className="relative">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Direct Link Signing</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription>
|
<DialogHeader>
|
||||||
<Trans>Manage the direct link signing for this template</Trans>
|
<DialogTitle>
|
||||||
</DialogDescription>
|
<Trans>Choose Direct Link Recipient</Trans>
|
||||||
</DialogHeader>
|
</DialogTitle>
|
||||||
|
|
||||||
<div>
|
<DialogDescription>
|
||||||
<div className="flex flex-row items-center justify-between">
|
<Trans>Choose an existing recipient from below to continue</Trans>
|
||||||
<Label className="flex flex-row">
|
</DialogDescription>
|
||||||
<Trans>Enable Direct Link Signing</Trans>
|
</DialogHeader>
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger tabIndex={-1} className="ml-2">
|
|
||||||
<InfoIcon className="h-4 w-4" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
|
|
||||||
<Trans>
|
|
||||||
Disabling direct link signing will prevent anyone from accessing the
|
|
||||||
link.
|
|
||||||
</Trans>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Switch
|
<div className="custom-scrollbar max-h-[60vh] overflow-y-auto rounded-md border">
|
||||||
className="mt-2"
|
<Table>
|
||||||
checked={isEnabled}
|
<TableHeader>
|
||||||
onCheckedChange={(value) => setIsEnabled(value)}
|
<TableRow>
|
||||||
/>
|
<TableHead>
|
||||||
|
<Trans>Recipient</Trans>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<Trans>Role</Trans>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{validDirectTemplateRecipients.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={3} className="h-16 text-center">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
<Trans>No valid recipients found</Trans>
|
||||||
|
</p>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{validDirectTemplateRecipients.map((row) => (
|
||||||
|
<TableRow
|
||||||
|
className="cursor-pointer"
|
||||||
|
key={row.id}
|
||||||
|
onClick={async () => onRecipientTableRowClick(row.id)}
|
||||||
|
>
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
<p>{row.name}</p>
|
||||||
|
<p className="text-muted-foreground/70 text-xs">{row.email}</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="text-muted-foreground text-sm">
|
||||||
|
{_(RECIPIENT_ROLES_DESCRIPTION[row.role].roleName)}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
{selectedRecipientId === row.id ? (
|
||||||
|
<CircleDotIcon className="h-5 w-5 text-neutral-300" />
|
||||||
|
) : (
|
||||||
|
<CircleIcon className="h-5 w-5 text-neutral-300" />
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2">
|
{/* Prevent creating placeholder direct template recipient if the email already exists. */}
|
||||||
<Label htmlFor="copy-direct-link">
|
{!recipients.some(
|
||||||
<Trans>Copy Shareable Link</Trans>
|
(recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL,
|
||||||
</Label>
|
) && (
|
||||||
|
<DialogFooter className="mx-auto">
|
||||||
|
<div className="flex flex-col items-center justify-center">
|
||||||
|
{validDirectTemplateRecipients.length !== 0 && (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
<Trans>Or</Trans>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="relative mt-1">
|
|
||||||
<Input
|
|
||||||
id="copy-direct-link"
|
|
||||||
disabled
|
|
||||||
value={formatDirectTemplatePath(token).replace(/https?:\/\//, '')}
|
|
||||||
readOnly
|
|
||||||
className="pr-12"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="absolute bottom-0 right-1 top-0 flex items-center justify-center">
|
|
||||||
<Button
|
<Button
|
||||||
variant="none"
|
|
||||||
type="button"
|
type="button"
|
||||||
className="h-8 w-8"
|
className="mt-2"
|
||||||
onClick={() => void onCopyClick(token)}
|
loading={isCreatingTemplateDirectLink && !selectedRecipientId}
|
||||||
|
onClick={async () =>
|
||||||
|
createTemplateDirectLink({
|
||||||
|
templateId,
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<ClipboardCopyIcon className="h-4 w-4 flex-shrink-0" />
|
<Trans>Create one automatically</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
))
|
||||||
|
.with({ token: P.string, currentStep: 'MANAGE' }, ({ token }) => (
|
||||||
|
<DialogContent className="relative">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Direct Link Signing</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>Manage the direct link signing for this template</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-row items-center justify-between">
|
||||||
|
<Label className="flex flex-row">
|
||||||
|
<Trans>Enable Direct Link Signing</Trans>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger tabIndex={-1} className="ml-2">
|
||||||
|
<InfoIcon className="h-4 w-4" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
|
||||||
|
<Trans>
|
||||||
|
Disabling direct link signing will prevent anyone from accessing the
|
||||||
|
link.
|
||||||
|
</Trans>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
className="mt-2"
|
||||||
|
checked={isEnabled}
|
||||||
|
onCheckedChange={(value) => setIsEnabled(value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2">
|
||||||
|
<Label htmlFor="copy-direct-link">
|
||||||
|
<Trans>Copy Shareable Link</Trans>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<div className="relative mt-1">
|
||||||
|
<Input
|
||||||
|
id="copy-direct-link"
|
||||||
|
disabled
|
||||||
|
value={formatDirectTemplatePath(token).replace(/https?:\/\//, '')}
|
||||||
|
readOnly
|
||||||
|
className="pr-12"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="absolute bottom-0 right-1 top-0 flex items-center justify-center">
|
||||||
|
<Button
|
||||||
|
variant="none"
|
||||||
|
type="button"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => void onCopyClick(token)}
|
||||||
|
>
|
||||||
|
<ClipboardCopyIcon className="h-4 w-4 flex-shrink-0" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="mt-4">
|
<DialogFooter className="mt-4">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
className="mr-auto w-full sm:w-auto"
|
className="mr-auto w-full sm:w-auto"
|
||||||
loading={isDeletingTemplateDirectLink}
|
loading={isDeletingTemplateDirectLink}
|
||||||
onClick={() => setCurrentStep('CONFIRM_DELETE')}
|
onClick={() => setCurrentStep('CONFIRM_DELETE')}
|
||||||
>
|
>
|
||||||
<Trans>Remove</Trans>
|
<Trans>Remove</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
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>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
))
|
))
|
||||||
.with({ token: P.string, currentStep: 'CONFIRM_DELETE' }, () => (
|
.with({ token: P.string, currentStep: 'CONFIRM_DELETE' }, () => (
|
||||||
<DialogContent className="relative">
|
<DialogContent className="relative">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<Trans>Are you sure?</Trans>
|
<Trans>Are you sure?</Trans>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
<Trans>
|
<Trans>
|
||||||
Please note that proceeding will remove direct linking recipient and turn it
|
Please note that proceeding will remove direct linking recipient and turn it
|
||||||
into a placeholder.
|
into a placeholder.
|
||||||
</Trans>
|
</Trans>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => setCurrentStep('MANAGE')}
|
onClick={() => setCurrentStep('MANAGE')}
|
||||||
>
|
>
|
||||||
<Trans>Cancel</Trans>
|
<Trans>Cancel</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
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>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
))
|
))
|
||||||
.otherwise(() => null)}
|
.otherwise(() => null)}
|
||||||
</AnimateGenericFadeInOut>
|
</AnimateGenericFadeInOut>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
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';
|
||||||
@ -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,13 +83,24 @@ export function TemplateMoveToFolderDialog({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { mutateAsync: moveTemplateToFolder } = trpc.folder.moveTemplateToFolder.useMutation();
|
const { mutateAsync: updateTemplate } = trpc.template.updateTemplate.useMutation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
form.reset();
|
||||||
|
setSearchTerm('');
|
||||||
|
} else {
|
||||||
|
form.reset({ folderId: currentFolderId ?? null });
|
||||||
|
}
|
||||||
|
}, [isOpen, currentFolderId, form]);
|
||||||
|
|
||||||
const onSubmit = async (data: TMoveTemplateFormSchema) => {
|
const onSubmit = async (data: TMoveTemplateFormSchema) => {
|
||||||
try {
|
try {
|
||||||
await moveTemplateToFolder({
|
await updateTemplate({
|
||||||
templateId,
|
templateId,
|
||||||
folderId: data.folderId ?? null,
|
data: {
|
||||||
|
folderId: data.folderId ?? null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@ -128,22 +139,12 @@ export function TemplateMoveToFolderDialog({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
|
||||||
if (!value) {
|
|
||||||
form.reset();
|
|
||||||
setSearchTerm('');
|
|
||||||
} else {
|
|
||||||
form.reset({ folderId: currentFolderId ?? null });
|
|
||||||
}
|
|
||||||
onOpenChange(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredFolders = folders?.data?.filter((folder) =>
|
const filteredFolders = folders?.data?.filter((folder) =>
|
||||||
folder.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
folder.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog {...props} open={isOpen} onOpenChange={handleOpenChange}>
|
<Dialog {...props} open={isOpen} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
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';
|
||||||
@ -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,58 +45,37 @@ 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
|
.array(
|
||||||
.any()
|
|
||||||
.refine((data) => data instanceof File || data === undefined)
|
|
||||||
.optional(),
|
|
||||||
recipients: z.array(
|
|
||||||
z.object({
|
z.object({
|
||||||
id: z.number(),
|
title: z.string(),
|
||||||
email: z.string().email(),
|
data: z.instanceof(File).optional(),
|
||||||
name: z.string(),
|
envelopeItemId: z.string(),
|
||||||
signingOrder: z.number().optional(),
|
|
||||||
}),
|
}),
|
||||||
),
|
)
|
||||||
})
|
.optional(),
|
||||||
// Display exactly which rows are duplicates.
|
recipients: z.array(
|
||||||
.superRefine((items, ctx) => {
|
z.object({
|
||||||
const uniqueEmails = new Map<string, number>();
|
id: z.number(),
|
||||||
|
email: z.string().email(),
|
||||||
for (const [index, recipients] of items.recipients.entries()) {
|
name: z.string(),
|
||||||
const email = recipients.email.toLowerCase();
|
signingOrder: z.number().optional(),
|
||||||
|
}),
|
||||||
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?.envelopeItems ?? [];
|
||||||
|
|
||||||
const { mutateAsync: createDocumentFromTemplate } =
|
const { mutateAsync: createDocumentFromTemplate } =
|
||||||
trpc.template.createDocumentFromTemplate.useMutation();
|
trpc.template.createDocumentFromTemplate.useMutation();
|
||||||
|
|
||||||
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
|
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
|
||||||
try {
|
try {
|
||||||
let customDocumentDataId: string | undefined = undefined;
|
const customFilesToUpload = (data.customDocumentData || []).filter(
|
||||||
|
(item): item is { data: File; envelopeItemId: string; title: string } =>
|
||||||
|
item.data !== undefined && item.envelopeItemId !== undefined && item.title !== undefined,
|
||||||
|
);
|
||||||
|
|
||||||
if (data.useCustomDocument && data.customDocumentData) {
|
const customDocumentData = await Promise.all(
|
||||||
const customDocumentData = await putPdfFile(data.customDocumentData);
|
customFilesToUpload.map(async (item) => {
|
||||||
customDocumentDataId = customDocumentData.id;
|
const customDocumentData = await putPdfFile(item.data);
|
||||||
}
|
|
||||||
|
|
||||||
const { id } = await createDocumentFromTemplate({
|
return {
|
||||||
|
documentDataId: customDocumentData.id,
|
||||||
|
envelopeItemId: item.envelopeItemId,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { envelopeId } = await createDocumentFromTemplate({
|
||||||
templateId,
|
templateId,
|
||||||
recipients: data.recipients,
|
recipients: data.recipients,
|
||||||
distributeDocument: data.distributeDocument,
|
distributeDocument: data.distributeDocument,
|
||||||
customDocumentDataId,
|
customDocumentData,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@ -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 &&
|
||||||
@ -203,17 +213,26 @@ export function TemplateUseDialog({
|
|||||||
name: 'recipients',
|
name: 'recipients',
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
useEffect(() => {
|
||||||
if (form.formState.isSubmitting) return;
|
if (!open) {
|
||||||
|
|
||||||
if (!value) {
|
|
||||||
form.reset();
|
form.reset();
|
||||||
}
|
}
|
||||||
setOpen(value);
|
}, [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={handleOpenChange}>
|
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
{trigger || (
|
{trigger || (
|
||||||
<Button variant="outline" className="bg-background">
|
<Button variant="outline" className="bg-background">
|
||||||
@ -281,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>
|
||||||
@ -309,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>
|
||||||
@ -443,116 +456,133 @@ export function TemplateUseDialog({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{form.watch('useCustomDocument') && (
|
{form.watch('useCustomDocument') && (
|
||||||
<div className="my-4">
|
<div className="my-4 space-y-2">
|
||||||
<FormField
|
{isLoadingEnvelopeItems ? (
|
||||||
control={form.control}
|
<SpinnerBox className="py-16" />
|
||||||
name="customDocumentData"
|
) : (
|
||||||
render={({ field }) => (
|
localCustomDocumentData.map((item, i) => (
|
||||||
<FormItem>
|
<FormField
|
||||||
<FormControl>
|
key={item.id}
|
||||||
<div className="w-full space-y-4">
|
control={form.control}
|
||||||
<label
|
name={`customDocumentData.${i}.data`}
|
||||||
className={cn(
|
render={({ field }) => (
|
||||||
'text-muted-foreground hover:border-muted-foreground/50 group relative flex min-h-[150px] cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-gray-300 px-6 py-10 transition-colors',
|
<FormItem>
|
||||||
{
|
<FormControl>
|
||||||
'border-destructive hover:border-destructive':
|
<div
|
||||||
form.formState.errors.customDocumentData,
|
key={item.id}
|
||||||
},
|
className="border-border bg-card hover:bg-accent/10 flex items-center gap-4 rounded-lg border p-4 transition-colors"
|
||||||
)}
|
>
|
||||||
>
|
<div className="flex-shrink-0">
|
||||||
<div className="text-center">
|
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg">
|
||||||
{!field.value && (
|
<FileTextIcon className="text-primary h-5 w-5" />
|
||||||
<>
|
|
||||||
<Upload className="text-muted-foreground/50 mx-auto h-10 w-10" />
|
|
||||||
<div className="mt-4 flex text-sm leading-6">
|
|
||||||
<span className="text-muted-foreground relative">
|
|
||||||
<Trans>
|
|
||||||
<span className="text-primary font-semibold">
|
|
||||||
Click to upload
|
|
||||||
</span>{' '}
|
|
||||||
or drag and drop
|
|
||||||
</Trans>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-muted-foreground/80 text-xs">
|
|
||||||
PDF files only
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{field.value && (
|
|
||||||
<div className="text-muted-foreground space-y-1">
|
|
||||||
<p className="text-sm font-medium">{field.value.name}</p>
|
|
||||||
<p className="text-muted-foreground/60 text-xs">
|
|
||||||
{(field.value.size / (1024 * 1024)).toFixed(2)} MB
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
data-testid="template-use-dialog-file-input"
|
|
||||||
className="absolute h-full w-full opacity-0"
|
|
||||||
accept=".pdf,application/pdf"
|
|
||||||
onChange={(e) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
|
|
||||||
if (!file) {
|
|
||||||
field.onChange(undefined);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file.type !== 'application/pdf') {
|
|
||||||
form.setError('customDocumentData', {
|
|
||||||
type: 'manual',
|
|
||||||
message: _(msg`Please select a PDF file`),
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file.size > APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024) {
|
|
||||||
form.setError('customDocumentData', {
|
|
||||||
type: 'manual',
|
|
||||||
message: _(
|
|
||||||
msg`File size exceeds the limit of ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT} MB`,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
field.onChange(file);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{field.value && (
|
|
||||||
<div className="absolute right-2 top-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="destructive"
|
|
||||||
className="h-6 w-6 p-0"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
field.onChange(undefined);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
<div className="sr-only">
|
|
||||||
<Trans>Clear file</Trans>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</label>
|
<div className="min-w-0 flex-1">
|
||||||
</div>
|
<h4 className="text-foreground truncate text-sm font-medium">
|
||||||
</FormControl>
|
{item.title}
|
||||||
<FormMessage />
|
</h4>
|
||||||
</FormItem>
|
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||||
)}
|
{field.value ? (
|
||||||
/>
|
<div>
|
||||||
|
<Trans>
|
||||||
|
Custom {(field.value.size / (1024 * 1024)).toFixed(2)}{' '}
|
||||||
|
MB file
|
||||||
|
</Trans>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Trans>Default file</Trans>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-shrink-0 items-center gap-2">
|
||||||
|
{field.value ? (
|
||||||
|
<div className="">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
field.onChange(undefined);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Remove</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs"
|
||||||
|
onClick={() => {
|
||||||
|
const fileInput = document.getElementById(
|
||||||
|
`template-use-dialog-file-input-${item.envelopeItemId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (fileInput instanceof HTMLInputElement) {
|
||||||
|
fileInput.click();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UploadCloudIcon className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Upload</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id={`template-use-dialog-file-input-${item.envelopeItemId}`}
|
||||||
|
className="hidden"
|
||||||
|
accept=".pdf,application/pdf"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
field.onChange(undefined);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.type !== 'application/pdf') {
|
||||||
|
form.setError('customDocumentData', {
|
||||||
|
type: 'manual',
|
||||||
|
message: _(msg`Please select a PDF file`),
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
file.size >
|
||||||
|
APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024
|
||||||
|
) {
|
||||||
|
form.setError('customDocumentData', {
|
||||||
|
type: 'manual',
|
||||||
|
message: _(
|
||||||
|
msg`File size exceeds the limit of ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT} MB`,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
field.onChange(file);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
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';
|
||||||
@ -56,7 +56,7 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe
|
|||||||
|
|
||||||
type TDeleteTokenByIdMutationSchema = z.infer<typeof ZTokenDeleteDialogSchema>;
|
type TDeleteTokenByIdMutationSchema = z.infer<typeof ZTokenDeleteDialogSchema>;
|
||||||
|
|
||||||
const { mutateAsync: deleteTokenMutation } = trpc.apiToken.deleteTokenById.useMutation({
|
const { mutateAsync: deleteTokenMutation } = trpc.apiToken.delete.useMutation({
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
onDelete?.();
|
onDelete?.();
|
||||||
},
|
},
|
||||||
@ -95,17 +95,17 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
useEffect(() => {
|
||||||
if (!value) {
|
if (!isOpen) {
|
||||||
form.reset();
|
form.reset();
|
||||||
}
|
}
|
||||||
if (!form.formState.isSubmitting) {
|
}, [isOpen, form]);
|
||||||
setIsOpen(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
<Dialog
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={(value) => !form.formState.isSubmitting && setIsOpen(value)}
|
||||||
|
>
|
||||||
<DialogTrigger asChild={true}>
|
<DialogTrigger asChild={true}>
|
||||||
{children ?? (
|
{children ?? (
|
||||||
<Button className="mr-4" variant="destructive">
|
<Button className="mr-4" variant="destructive">
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
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';
|
||||||
@ -88,17 +88,14 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (value: boolean) => {
|
useEffect(() => {
|
||||||
if (!value) {
|
if (!open) {
|
||||||
form.reset();
|
form.reset();
|
||||||
}
|
}
|
||||||
if (!form.formState.isSubmitting) {
|
}, [open, form]);
|
||||||
setOpen(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
{children ?? (
|
{children ?? (
|
||||||
<Button className="mr-4" variant="destructive">
|
<Button className="mr-4" variant="destructive">
|
||||||
|
|||||||
@ -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>;
|
||||||
|
|||||||
@ -118,6 +118,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]);
|
||||||
|
|
||||||
@ -172,6 +173,8 @@ export const ConfigureFieldsView = ({
|
|||||||
name: 'fields',
|
name: 'fields',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const highestPageNumber = Math.max(...localFields.map((field) => field.pageNumber));
|
||||||
|
|
||||||
const onFieldCopy = useCallback(
|
const onFieldCopy = useCallback(
|
||||||
(event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => {
|
(event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => {
|
||||||
const { duplicate = false, duplicateAll = false } = options ?? {};
|
const { duplicate = false, duplicateAll = false } = options ?? {};
|
||||||
@ -540,7 +543,9 @@ export const ConfigureFieldsView = ({
|
|||||||
<div>
|
<div>
|
||||||
<PDFViewer documentData={normalizedDocumentData} />
|
<PDFViewer documentData={normalizedDocumentData} />
|
||||||
|
|
||||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
<ElementVisible
|
||||||
|
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
|
||||||
|
>
|
||||||
{localFields.map((field, index) => {
|
{localFields.map((field, index) => {
|
||||||
const recipientIndex = recipients.findIndex(
|
const recipientIndex = recipients.findIndex(
|
||||||
(r) => r.id === field.recipientId,
|
(r) => r.id === field.recipientId,
|
||||||
|
|||||||
@ -3,7 +3,7 @@ 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, Recipient, Signature } from '@prisma/client';
|
||||||
import { type DocumentData, type Field, FieldType } from '@prisma/client';
|
import { type DocumentData, type Field, FieldType } from '@prisma/client';
|
||||||
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
@ -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,17 +45,19 @@ import { EmbedDocumentFields } from './embed-document-fields';
|
|||||||
|
|
||||||
export type EmbedDirectTemplateClientPageProps = {
|
export type EmbedDirectTemplateClientPageProps = {
|
||||||
token: string;
|
token: string;
|
||||||
|
envelopeId: string;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
documentData: DocumentData;
|
documentData: DocumentData;
|
||||||
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,
|
documentData,
|
||||||
recipient,
|
recipient,
|
||||||
@ -91,8 +94,12 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
localFields.filter((field) => field.inserted),
|
localFields.filter((field) => field.inserted),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
|
||||||
|
|
||||||
const hasSignatureField = localFields.some((field) => field.type === FieldType.SIGNATURE);
|
const hasSignatureField = localFields.some((field) => field.type === FieldType.SIGNATURE);
|
||||||
|
|
||||||
|
const signatureValid = !hasSignatureField || (signature && signature.trim() !== '');
|
||||||
|
|
||||||
const { mutateAsync: createDocumentFromDirectTemplate, isPending: isSubmitting } =
|
const { mutateAsync: createDocumentFromDirectTemplate, isPending: isSubmitting } =
|
||||||
trpc.template.createDocumentFromDirectTemplate.useMutation();
|
trpc.template.createDocumentFromDirectTemplate.useMutation();
|
||||||
|
|
||||||
@ -317,9 +324,13 @@ 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">
|
||||||
@ -343,19 +354,34 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
<Trans>Sign document</Trans>
|
<Trans>Sign document</Trans>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
|
{isExpanded ? (
|
||||||
{isExpanded ? (
|
<Button
|
||||||
<LucideChevronDown
|
variant="outline"
|
||||||
className="text-muted-foreground h-5 w-5"
|
className="h-8 w-8 p-0 md:hidden"
|
||||||
onClick={() => setIsExpanded(false)}
|
onClick={() => setIsExpanded(false)}
|
||||||
/>
|
>
|
||||||
) : (
|
<LucideChevronDown className="text-muted-foreground h-5 w-5" />
|
||||||
<LucideChevronUp
|
</Button>
|
||||||
className="text-muted-foreground h-5 w-5"
|
) : pendingFields.length > 0 ? (
|
||||||
onClick={() => setIsExpanded(true)}
|
<Button
|
||||||
/>
|
variant="outline"
|
||||||
)}
|
className="h-8 w-8 p-0 md:hidden"
|
||||||
</Button>
|
onClick={() => setIsExpanded(true)}
|
||||||
|
>
|
||||||
|
<LucideChevronUp className="text-muted-foreground h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
className="md:hidden"
|
||||||
|
disabled={isThrottled || (hasSignatureField && !signatureValid)}
|
||||||
|
loading={isSubmitting}
|
||||||
|
onClick={() => throttledOnCompleteClick()}
|
||||||
|
>
|
||||||
|
<Trans>Complete</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -442,7 +468,9 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
<ElementVisible
|
||||||
|
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPendingPageNumber}"]`}
|
||||||
|
>
|
||||||
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
||||||
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
|
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
|
||||||
<Trans>Click to insert field</Trans>
|
<Trans>Click to insert field</Trans>
|
||||||
|
|||||||
@ -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'
|
||||||
@ -50,8 +50,10 @@ export const EmbedDocumentFields = ({
|
|||||||
onSignField,
|
onSignField,
|
||||||
onUnsignField,
|
onUnsignField,
|
||||||
}: EmbedDocumentFieldsProps) => {
|
}: EmbedDocumentFieldsProps) => {
|
||||||
|
const highestPageNumber = Math.max(...fields.map((field) => field.page));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
<ElementVisible target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}>
|
||||||
{fields.map((field) =>
|
{fields.map((field) =>
|
||||||
match(field.type)
|
match(field.type)
|
||||||
.with(FieldType.SIGNATURE, () => (
|
.with(FieldType.SIGNATURE, () => (
|
||||||
|
|||||||
@ -3,7 +3,7 @@ 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 } from '@prisma/client';
|
||||||
import {
|
import {
|
||||||
type DocumentData,
|
type DocumentData,
|
||||||
type Field,
|
type Field,
|
||||||
@ -15,12 +15,14 @@ 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 +37,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';
|
||||||
@ -46,11 +49,12 @@ import { EmbedDocumentRejected } from './embed-document-rejected';
|
|||||||
export type EmbedSignDocumentClientPageProps = {
|
export type EmbedSignDocumentClientPageProps = {
|
||||||
token: string;
|
token: string;
|
||||||
documentId: number;
|
documentId: number;
|
||||||
|
envelopeId: string;
|
||||||
documentData: DocumentData;
|
documentData: DocumentData;
|
||||||
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;
|
||||||
@ -60,6 +64,7 @@ export type EmbedSignDocumentClientPageProps = {
|
|||||||
export const EmbedSignDocumentClientPage = ({
|
export const EmbedSignDocumentClientPage = ({
|
||||||
token,
|
token,
|
||||||
documentId,
|
documentId,
|
||||||
|
envelopeId,
|
||||||
documentData,
|
documentData,
|
||||||
recipient,
|
recipient,
|
||||||
fields,
|
fields,
|
||||||
@ -89,7 +94,7 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
const [isNameLocked, setIsNameLocked] = useState(false);
|
const [isNameLocked, setIsNameLocked] = useState(false);
|
||||||
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
||||||
const [showOtherRecipientsCompletedFields, setShowOtherRecipientsCompletedFields] =
|
const [_showOtherRecipientsCompletedFields, setShowOtherRecipientsCompletedFields] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
|
||||||
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
|
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
|
||||||
@ -106,6 +111,8 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
fields.filter((field) => field.inserted),
|
fields.filter((field) => field.inserted),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
|
||||||
|
|
||||||
const { mutateAsync: completeDocumentWithToken, isPending: isSubmitting } =
|
const { mutateAsync: completeDocumentWithToken, isPending: isSubmitting } =
|
||||||
trpc.recipient.completeDocumentWithToken.useMutation();
|
trpc.recipient.completeDocumentWithToken.useMutation();
|
||||||
|
|
||||||
@ -116,6 +123,8 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
|
|
||||||
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
|
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
|
||||||
|
|
||||||
|
const signatureValid = !hasSignatureField || (signature && signature.trim() !== '');
|
||||||
|
|
||||||
const assistantSignersId = useId();
|
const assistantSignersId = useId();
|
||||||
|
|
||||||
const onNextFieldClick = () => {
|
const onNextFieldClick = () => {
|
||||||
@ -268,15 +277,17 @@ 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 */}
|
||||||
@ -305,19 +316,36 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
)}
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
|
{isExpanded ? (
|
||||||
{isExpanded ? (
|
<Button
|
||||||
<LucideChevronDown
|
variant="outline"
|
||||||
className="text-muted-foreground h-5 w-5"
|
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
|
||||||
onClick={() => setIsExpanded(false)}
|
onClick={() => setIsExpanded(false)}
|
||||||
/>
|
>
|
||||||
) : (
|
<LucideChevronDown className="text-muted-foreground dark:text-background h-5 w-5" />
|
||||||
<LucideChevronUp
|
</Button>
|
||||||
className="text-muted-foreground h-5 w-5"
|
) : pendingFields.length > 0 ? (
|
||||||
onClick={() => setIsExpanded(true)}
|
<Button
|
||||||
/>
|
variant="outline"
|
||||||
)}
|
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
|
||||||
</Button>
|
onClick={() => setIsExpanded(true)}
|
||||||
|
>
|
||||||
|
<LucideChevronUp className="text-muted-foreground dark:text-background h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
className="md:hidden"
|
||||||
|
disabled={
|
||||||
|
isThrottled || (!isAssistantMode && hasSignatureField && !signatureValid)
|
||||||
|
}
|
||||||
|
loading={isSubmitting}
|
||||||
|
onClick={() => throttledOnCompleteClick()}
|
||||||
|
>
|
||||||
|
<Trans>Complete</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -465,7 +493,9 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
<ElementVisible
|
||||||
|
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPendingPageNumber}"]`}
|
||||||
|
>
|
||||||
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
||||||
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
|
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
|
||||||
<Trans>Click to insert field</Trans>
|
<Trans>Click to insert field</Trans>
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
|
||||||
export const EmbedDocumentWaitingForTurn = () => {
|
export const EmbedDocumentWaitingForTurn = () => {
|
||||||
|
const [hasPostedMessage, setHasPostedMessage] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (window.parent) {
|
if (window.parent && !hasPostedMessage) {
|
||||||
window.parent.postMessage(
|
window.parent.postMessage(
|
||||||
{
|
{
|
||||||
action: 'document-waiting-for-turn',
|
action: 'document-waiting-for-turn',
|
||||||
@ -13,7 +15,13 @@ export const EmbedDocumentWaitingForTurn = () => {
|
|||||||
'*',
|
'*',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, []);
|
|
||||||
|
setHasPostedMessage(true);
|
||||||
|
}, [hasPostedMessage]);
|
||||||
|
|
||||||
|
if (!hasPostedMessage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="embed--WaitingForTurn relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
<div className="embed--WaitingForTurn relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||||
|
|||||||
@ -92,6 +92,8 @@ export const MultiSignDocumentSigningView = ({
|
|||||||
[],
|
[],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
|
||||||
|
|
||||||
const uninsertedFields = document?.fields.filter((field) => !field.inserted) ?? [];
|
const uninsertedFields = document?.fields.filter((field) => !field.inserted) ?? [];
|
||||||
|
|
||||||
const onSignField = async (payload: TSignFieldWithTokenMutationSchema) => {
|
const onSignField = async (payload: TSignFieldWithTokenMutationSchema) => {
|
||||||
@ -210,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}
|
||||||
/>
|
/>
|
||||||
@ -357,7 +359,9 @@ export const MultiSignDocumentSigningView = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasDocumentLoaded && (
|
{hasDocumentLoaded && (
|
||||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
<ElementVisible
|
||||||
|
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPendingPageNumber}"]`}
|
||||||
|
>
|
||||||
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
||||||
<FieldToolTip
|
<FieldToolTip
|
||||||
key={pendingFields[0].id}
|
key={pendingFields[0].id}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
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';
|
||||||
@ -136,19 +136,18 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
|
|||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (open: boolean) => {
|
useEffect(() => {
|
||||||
setIsOpen(open);
|
enable2FAForm.reset();
|
||||||
|
|
||||||
if (!open) {
|
if (!isOpen && recoveryCodes && recoveryCodes.length > 0) {
|
||||||
enable2FAForm.reset();
|
setRecoveryCodes(null);
|
||||||
if (recoveryCodes && recoveryCodes.length > 0) {
|
|
||||||
setRecoveryCodes(null);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DialogTrigger asChild={true}>
|
<DialogTrigger asChild={true}>
|
||||||
<Button
|
<Button
|
||||||
className="flex-shrink-0"
|
className="flex-shrink-0"
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import type { MessageDescriptor } from '@lingui/core';
|
import type { MessageDescriptor } from '@lingui/core';
|
||||||
@ -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,
|
||||||
@ -95,6 +97,8 @@ export const SignInForm = ({
|
|||||||
'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(() => {
|
||||||
@ -114,20 +118,11 @@ export const SignInForm = ({
|
|||||||
}, [returnTo]);
|
}, [returnTo]);
|
||||||
|
|
||||||
const { mutateAsync: createPasskeySigninOptions } =
|
const { mutateAsync: createPasskeySigninOptions } =
|
||||||
trpc.auth.createPasskeySigninOptions.useMutation();
|
trpc.auth.passkey.createSigninOptions.useMutation();
|
||||||
|
|
||||||
const emailFromHash = useMemo(() => {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const hash = window.location.hash.slice(1);
|
|
||||||
const params = new URLSearchParams(hash);
|
|
||||||
return params.get('email');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const form = useForm<TSignInFormSchema>({
|
const form = useForm<TSignInFormSchema>({
|
||||||
values: {
|
values: {
|
||||||
email: emailFromHash ?? initialEmail ?? '',
|
email: initialEmail ?? '',
|
||||||
password: '',
|
password: '',
|
||||||
totpCode: '',
|
totpCode: '',
|
||||||
backupCode: '',
|
backupCode: '',
|
||||||
@ -280,6 +275,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({
|
||||||
@ -296,6 +307,18 @@ export const SignInForm = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const hash = window.location.hash.slice(1);
|
||||||
|
|
||||||
|
const params = new URLSearchParams(hash);
|
||||||
|
|
||||||
|
const email = params.get('email');
|
||||||
|
|
||||||
|
if (email) {
|
||||||
|
form.setValue('email', email);
|
||||||
|
}
|
||||||
|
}, [form]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
@ -360,7 +383,7 @@ 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) && (
|
{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">
|
||||||
@ -384,6 +407,20 @@ 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"
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useMemo } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import type { MessageDescriptor } from '@lingui/core';
|
import type { MessageDescriptor } from '@lingui/core';
|
||||||
@ -66,6 +66,7 @@ export type SignUpFormProps = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
initialEmail?: string;
|
initialEmail?: string;
|
||||||
isGoogleSSOEnabled?: boolean;
|
isGoogleSSOEnabled?: boolean;
|
||||||
|
isMicrosoftSSOEnabled?: boolean;
|
||||||
isOIDCSSOEnabled?: boolean;
|
isOIDCSSOEnabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -73,6 +74,7 @@ export const SignUpForm = ({
|
|||||||
className,
|
className,
|
||||||
initialEmail,
|
initialEmail,
|
||||||
isGoogleSSOEnabled,
|
isGoogleSSOEnabled,
|
||||||
|
isMicrosoftSSOEnabled,
|
||||||
isOIDCSSOEnabled,
|
isOIDCSSOEnabled,
|
||||||
}: SignUpFormProps) => {
|
}: SignUpFormProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
@ -84,19 +86,12 @@ export const SignUpForm = ({
|
|||||||
|
|
||||||
const utmSrc = searchParams.get('utm_source') ?? null;
|
const utmSrc = searchParams.get('utm_source') ?? null;
|
||||||
|
|
||||||
const emailFromHash = useMemo(() => {
|
const hasSocialAuthEnabled = isGoogleSSOEnabled || isMicrosoftSSOEnabled || isOIDCSSOEnabled;
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const hash = window.location.hash.slice(1);
|
|
||||||
const params = new URLSearchParams(hash);
|
|
||||||
return params.get('email');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const form = useForm<TSignUpFormSchema>({
|
const form = useForm<TSignUpFormSchema>({
|
||||||
values: {
|
values: {
|
||||||
name: '',
|
name: '',
|
||||||
email: emailFromHash ?? initialEmail ?? '',
|
email: initialEmail ?? '',
|
||||||
password: '',
|
password: '',
|
||||||
signature: '',
|
signature: '',
|
||||||
},
|
},
|
||||||
@ -157,6 +152,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();
|
||||||
@ -171,6 +180,18 @@ export const SignUpForm = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const hash = window.location.hash.slice(1);
|
||||||
|
|
||||||
|
const params = new URLSearchParams(hash);
|
||||||
|
|
||||||
|
const email = params.get('email');
|
||||||
|
|
||||||
|
if (email) {
|
||||||
|
form.setValue('email', email);
|
||||||
|
}
|
||||||
|
}, [form]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex justify-center gap-x-12', className)}>
|
<div className={cn('flex justify-center gap-x-12', className)}>
|
||||||
<div className="border-border relative hidden flex-1 overflow-hidden rounded-xl border xl:flex">
|
<div className="border-border relative hidden flex-1 overflow-hidden rounded-xl border xl:flex">
|
||||||
@ -224,7 +245,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}
|
||||||
>
|
>
|
||||||
@ -299,7 +320,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" />
|
||||||
@ -327,6 +348,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>
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import type { z } from 'zod';
|
|||||||
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { ZCreateTokenMutationSchema } from '@documenso/trpc/server/api-token-router/schema';
|
import { ZCreateApiTokenRequestSchema } from '@documenso/trpc/server/api-token-router/create-api-token.types';
|
||||||
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 { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
@ -47,7 +47,7 @@ export const EXPIRATION_DATES = {
|
|||||||
ONE_YEAR: msg`12 months`,
|
ONE_YEAR: msg`12 months`,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const ZCreateTokenFormSchema = ZCreateTokenMutationSchema.pick({
|
const ZCreateTokenFormSchema = ZCreateApiTokenRequestSchema.pick({
|
||||||
tokenName: true,
|
tokenName: true,
|
||||||
expirationDate: true,
|
expirationDate: true,
|
||||||
});
|
});
|
||||||
@ -75,7 +75,7 @@ export const ApiTokenForm = ({ className, tokens }: ApiTokenFormProps) => {
|
|||||||
const [newlyCreatedToken, setNewlyCreatedToken] = useState<NewlyCreatedToken | null>();
|
const [newlyCreatedToken, setNewlyCreatedToken] = useState<NewlyCreatedToken | null>();
|
||||||
const [noExpirationDate, setNoExpirationDate] = useState(false);
|
const [noExpirationDate, setNoExpirationDate] = useState(false);
|
||||||
|
|
||||||
const { mutateAsync: createTokenMutation } = trpc.apiToken.createToken.useMutation({
|
const { mutateAsync: createTokenMutation } = trpc.apiToken.create.useMutation({
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
setNewlyCreatedToken(data);
|
setNewlyCreatedToken(data);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import type { TooltipProps } from 'recharts';
|
import type { TooltipProps } from 'recharts';
|
||||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||||
|
|||||||
@ -64,7 +64,7 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
|
|||||||
const [pages, setPages] = useState<string[]>([]);
|
const [pages, setPages] = useState<string[]>([]);
|
||||||
|
|
||||||
const { data: searchDocumentsData, isPending: isSearchingDocuments } =
|
const { data: searchDocumentsData, isPending: isSearchingDocuments } =
|
||||||
trpcReact.document.searchDocuments.useQuery(
|
trpcReact.document.search.useQuery(
|
||||||
{
|
{
|
||||||
query: search,
|
query: search,
|
||||||
},
|
},
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user