mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 00:03:33 +10:00
Compare commits
1 Commits
v1.12.0-rc
...
fix/team-m
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e443b1795 |
59
.cursorrules
59
.cursorrules
@ -1,7 +1,4 @@
|
|||||||
You are an expert in TypeScript, Node.js, Remix, React, Shadcn UI and Tailwind.
|
|
||||||
|
|
||||||
Code Style and Structure:
|
Code Style and Structure:
|
||||||
|
|
||||||
- Write concise, technical TypeScript code with accurate examples
|
- Write concise, technical TypeScript code with accurate examples
|
||||||
- Use functional and declarative programming patterns; avoid classes
|
- Use functional and declarative programming patterns; avoid classes
|
||||||
- Prefer iteration and modularization over code duplication
|
- Prefer iteration and modularization over code duplication
|
||||||
@ -9,25 +6,20 @@ Code Style and Structure:
|
|||||||
- Structure files: exported component, subcomponents, helpers, static content, types
|
- Structure files: exported component, subcomponents, helpers, static content, types
|
||||||
|
|
||||||
Naming Conventions:
|
Naming Conventions:
|
||||||
|
|
||||||
- Use lowercase with dashes for directories (e.g., components/auth-wizard)
|
- Use lowercase with dashes for directories (e.g., components/auth-wizard)
|
||||||
- Favor named exports for components
|
- Favor named exports for components
|
||||||
|
|
||||||
TypeScript Usage:
|
TypeScript Usage:
|
||||||
|
- Use TypeScript for all code; prefer interfaces over types
|
||||||
- Use TypeScript for all code; prefer types over interfaces
|
- Avoid enums; use maps instead
|
||||||
- Use functional components with TypeScript interfaces
|
- Use functional components with TypeScript interfaces
|
||||||
|
|
||||||
Syntax and Formatting:
|
Syntax and Formatting:
|
||||||
|
- Use the "function" keyword for pure functions
|
||||||
- Create functions using `const fn = () => {}`
|
|
||||||
- Avoid unnecessary curly braces in conditionals; use concise syntax for simple statements
|
- Avoid unnecessary curly braces in conditionals; use concise syntax for simple statements
|
||||||
- Use declarative JSX
|
- Use declarative JSX
|
||||||
- Never use 'use client'
|
|
||||||
- Never use 1 line if statements
|
|
||||||
|
|
||||||
Error Handling and Validation:
|
Error Handling and Validation:
|
||||||
|
|
||||||
- Prioritize error handling: handle errors and edge cases early
|
- Prioritize error handling: handle errors and edge cases early
|
||||||
- Use early returns and guard clauses
|
- Use early returns and guard clauses
|
||||||
- Implement proper error logging and user-friendly messages
|
- Implement proper error logging and user-friendly messages
|
||||||
@ -36,40 +28,21 @@ Error Handling and Validation:
|
|||||||
- Use error boundaries for unexpected errors
|
- Use error boundaries for unexpected errors
|
||||||
|
|
||||||
UI and Styling:
|
UI and Styling:
|
||||||
|
|
||||||
- Use Shadcn UI, Radix, and Tailwind Aria for components and styling
|
- Use Shadcn UI, Radix, and Tailwind Aria for components and styling
|
||||||
- Implement responsive design with Tailwind CSS; use a mobile-first approach
|
- Implement responsive design with Tailwind CSS; use a mobile-first approach
|
||||||
- When using Lucide icons, prefer the longhand names, for example HomeIcon instead of Home
|
|
||||||
|
|
||||||
React forms
|
Performance Optimization:
|
||||||
|
- Minimize 'use client', 'useEffect', and 'setState'; favor React Server Components (RSC)
|
||||||
|
- Wrap client components in Suspense with fallback
|
||||||
|
- Use dynamic loading for non-critical components
|
||||||
|
- Optimize images: use WebP format, include size data, implement lazy loading
|
||||||
|
|
||||||
- Use zod for form validation react-hook-form for forms
|
Key Conventions:
|
||||||
- Look at TeamCreateDialog.tsx as an example of form usage
|
- Use 'nuqs' for URL search parameter state management
|
||||||
- Use <Form> <FormItem> elements, and also wrap the contents of form in a fieldset which should have the :disabled attribute when the form is loading
|
- Optimize Web Vitals (LCP, CLS, FID)
|
||||||
|
- Limit 'use client':
|
||||||
|
- Favor server components and Next.js SSR
|
||||||
|
- Use only for Web API access in small components
|
||||||
|
- Avoid for data fetching or state management
|
||||||
|
|
||||||
TRPC Specifics
|
Follow Next.js docs for Data Fetching, Rendering, and Routing
|
||||||
|
|
||||||
- Every route should be in it's own file, example routers/teams/create-team.ts
|
|
||||||
- Every route should have a types file associated with it, example routers/teams/create-team.types.ts. These files should have the OpenAPI meta, and request/response zod schemas
|
|
||||||
- The request/response schemas should be named like Z[RouteName]RequestSchema and Z[RouteName]ResponseSchema
|
|
||||||
- Use create-team.ts and create-team.types.ts as an example when creating new routes.
|
|
||||||
- When creating the OpenAPI meta, only use GET and POST requests, do not use any other REST methods
|
|
||||||
- Deconstruct the input argument on it's one line of code.
|
|
||||||
|
|
||||||
Toast usage
|
|
||||||
|
|
||||||
- Use the t`string` macro from @lingui/react/macro to display toast messages
|
|
||||||
|
|
||||||
Remix/ReactRouter Usage
|
|
||||||
|
|
||||||
- Use (params: Route.Params) to get the params from the route
|
|
||||||
- Use (loaderData: Route.LoaderData) to get the loader data from the route
|
|
||||||
- When using loaderdata, deconstruct the data you need from the loader data inside the function body
|
|
||||||
- Do not use json() to return data, directly return the data
|
|
||||||
|
|
||||||
Translations
|
|
||||||
|
|
||||||
- Use <Trans>string</Trans> to display translations in jsx code, this should be imported from @lingui/react/macro
|
|
||||||
- Use the t`string` macro from @lingui/react/macro to display translations in typescript code
|
|
||||||
- t should be imported as const { t } = useLingui() where useLingui is imported from @lingui/react/macro
|
|
||||||
- String in constants should be using the t`string` macro
|
|
||||||
23
.github/actions/cache-build/action.yml
vendored
Normal file
23
.github/actions/cache-build/action.yml
vendored
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
name: Cache production build binaries
|
||||||
|
description: 'Cache or restore if necessary'
|
||||||
|
inputs:
|
||||||
|
node_version:
|
||||||
|
required: false
|
||||||
|
default: v22.x
|
||||||
|
runs:
|
||||||
|
using: 'composite'
|
||||||
|
steps:
|
||||||
|
- name: Cache production build
|
||||||
|
uses: actions/cache@v3
|
||||||
|
id: production-build-cache
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
${{ github.workspace }}/apps/web/.next
|
||||||
|
**/.turbo/**
|
||||||
|
**/dist/**
|
||||||
|
|
||||||
|
key: prod-build-${{ github.run_id }}-${{ hashFiles('package-lock.json') }}
|
||||||
|
restore-keys: prod-build-
|
||||||
|
|
||||||
|
- run: npm run build
|
||||||
|
shell: bash
|
||||||
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@ -26,8 +26,7 @@ jobs:
|
|||||||
- name: Copy env
|
- name: Copy env
|
||||||
run: cp .env.example .env
|
run: cp .env.example .env
|
||||||
|
|
||||||
- name: Build app
|
- uses: ./.github/actions/cache-build
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
build_docker:
|
build_docker:
|
||||||
name: Build Docker Image
|
name: Build Docker Image
|
||||||
|
|||||||
29
.github/workflows/clean-cache.yml
vendored
Normal file
29
.github/workflows/clean-cache.yml
vendored
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
name: cleanup caches by a branch
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types:
|
||||||
|
- closed
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
cleanup:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Cleanup
|
||||||
|
run: |
|
||||||
|
gh extension install actions/gh-actions-cache
|
||||||
|
|
||||||
|
echo "Fetching list of cache key"
|
||||||
|
cacheKeysForPR=$(gh actions-cache list -R $REPO -B $BRANCH -L 100 | cut -f 1 )
|
||||||
|
|
||||||
|
## Setting this to not fail the workflow while deleting cache keys.
|
||||||
|
set +e
|
||||||
|
echo "Deleting caches..."
|
||||||
|
for cacheKey in $cacheKeysForPR
|
||||||
|
do
|
||||||
|
gh actions-cache delete $cacheKey -R $REPO -B $BRANCH --confirm
|
||||||
|
done
|
||||||
|
echo "Done"
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
BRANCH: refs/pull/${{ github.event.pull_request.number }}/merge
|
||||||
5
.github/workflows/codeql-analysis.yml
vendored
5
.github/workflows/codeql-analysis.yml
vendored
@ -10,7 +10,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
analyze:
|
analyze:
|
||||||
name: Analyze
|
name: Analyze
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-22.04
|
||||||
permissions:
|
permissions:
|
||||||
actions: read
|
actions: read
|
||||||
contents: read
|
contents: read
|
||||||
@ -30,8 +30,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: ./.github/actions/node-install
|
- uses: ./.github/actions/node-install
|
||||||
|
|
||||||
- name: Build app
|
- uses: ./.github/actions/cache-build
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v3
|
uses: github/codeql-action/init@v3
|
||||||
|
|||||||
6
.github/workflows/e2e-tests.yml
vendored
6
.github/workflows/e2e-tests.yml
vendored
@ -28,11 +28,7 @@ jobs:
|
|||||||
- name: Seed the database
|
- name: Seed the database
|
||||||
run: npm run prisma:seed
|
run: npm run prisma:seed
|
||||||
|
|
||||||
- name: Build app
|
- uses: ./.github/actions/cache-build
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
- name: Install playwright browsers
|
|
||||||
run: npx playwright install --with-deps
|
|
||||||
|
|
||||||
- name: Run Playwright tests
|
- name: Run Playwright tests
|
||||||
run: npm run ci
|
run: npm run ci
|
||||||
|
|||||||
@ -1 +1,4 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
. "$(dirname -- "$0")/_/husky.sh"
|
||||||
|
|
||||||
npm run commitlint -- $1
|
npm run commitlint -- $1
|
||||||
|
|||||||
@ -1,3 +1,6 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
. "$(dirname -- "$0")/_/husky.sh"
|
||||||
|
|
||||||
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
|
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
|
||||||
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
|
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
|
||||||
|
|
||||||
|
|||||||
2
.npmrc
2
.npmrc
@ -1,3 +1 @@
|
|||||||
auto-install-peers = true
|
auto-install-peers = true
|
||||||
legacy-peer-deps = true
|
|
||||||
prefer-dedupe = true
|
|
||||||
@ -1,5 +1,3 @@
|
|||||||
import nextra from 'nextra';
|
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
transpilePackages: [
|
transpilePackages: [
|
||||||
@ -11,10 +9,9 @@ const nextConfig = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const withNextra = nextra({
|
const withNextra = require('nextra')({
|
||||||
theme: 'nextra-theme-docs',
|
theme: 'nextra-theme-docs',
|
||||||
themeConfig: './theme.config.tsx',
|
themeConfig: './theme.config.tsx',
|
||||||
codeHighlight: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default withNextra(nextConfig);
|
module.exports = withNextra(nextConfig);
|
||||||
|
|||||||
@ -15,7 +15,7 @@
|
|||||||
"@documenso/tailwind-config": "*",
|
"@documenso/tailwind-config": "*",
|
||||||
"@documenso/trpc": "*",
|
"@documenso/trpc": "*",
|
||||||
"@documenso/ui": "*",
|
"@documenso/ui": "*",
|
||||||
"next": "14.2.28",
|
"next": "14.2.6",
|
||||||
"next-plausible": "^3.12.0",
|
"next-plausible": "^3.12.0",
|
||||||
"nextra": "^2.13.4",
|
"nextra": "^2.13.4",
|
||||||
"nextra-theme-docs": "^2.13.4",
|
"nextra-theme-docs": "^2.13.4",
|
||||||
|
|||||||
@ -6,6 +6,5 @@
|
|||||||
"solid": "Solid Integration",
|
"solid": "Solid Integration",
|
||||||
"preact": "Preact Integration",
|
"preact": "Preact Integration",
|
||||||
"angular": "Angular Integration",
|
"angular": "Angular Integration",
|
||||||
"css-variables": "CSS Variables",
|
"css-variables": "CSS Variables"
|
||||||
"authoring": "Authoring"
|
}
|
||||||
}
|
|
||||||
|
|||||||
@ -1,167 +0,0 @@
|
|||||||
---
|
|
||||||
title: Authoring
|
|
||||||
description: Learn how to use embedded authoring to create documents and templates in your application
|
|
||||||
---
|
|
||||||
|
|
||||||
# Embedded Authoring
|
|
||||||
|
|
||||||
In addition to embedding signing experiences, Documenso now supports embedded authoring, allowing you to integrate document and template creation directly within your application.
|
|
||||||
|
|
||||||
## How Embedded Authoring Works
|
|
||||||
|
|
||||||
The embedded authoring feature enables your users to create new documents without leaving your application. This process works through secure presign tokens that authenticate the embedding session and manage permissions.
|
|
||||||
|
|
||||||
## Creating Documents with Embedded Authoring
|
|
||||||
|
|
||||||
To implement document creation in your application, use the `EmbedCreateDocument` component from our SDK:
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
import { unstable_EmbedCreateDocument as EmbedCreateDocument } from '@documenso/embed-react';
|
|
||||||
|
|
||||||
const DocumentCreator = () => {
|
|
||||||
// You'll need to obtain a presign token using your API key
|
|
||||||
const presignToken = 'YOUR_PRESIGN_TOKEN';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ height: '800px', width: '100%' }}>
|
|
||||||
<EmbedCreateDocument
|
|
||||||
presignToken={presignToken}
|
|
||||||
externalId="order-12345"
|
|
||||||
onDocumentCreated={(data) => {
|
|
||||||
console.log('Document created with ID:', data.documentId);
|
|
||||||
console.log('External reference ID:', data.externalId);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## Obtaining a Presign Token
|
|
||||||
|
|
||||||
Before using the `EmbedCreateDocument` component, you'll need to obtain a presign token from your backend. This token authorizes the embedding session.
|
|
||||||
|
|
||||||
You can create a presign token by making a request to:
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/v2-beta/embedding/create-presign-token
|
|
||||||
```
|
|
||||||
|
|
||||||
This API endpoint requires authentication with your Documenso API key. The token has a default expiration of 1 hour, but you can customize this duration based on your security requirements.
|
|
||||||
|
|
||||||
You can find more details on this request at our [API Documentation](https://openapi.documenso.com/reference#tag/embedding)
|
|
||||||
|
|
||||||
## Configuration Options
|
|
||||||
|
|
||||||
The `EmbedCreateDocument` component accepts several configuration options:
|
|
||||||
|
|
||||||
| Option | Type | Description |
|
|
||||||
| ------------------ | ------- | ------------------------------------------------------------------ |
|
|
||||||
| `presignToken` | string | **Required**. The authentication token for the embedding session. |
|
|
||||||
| `externalId` | string | Optional reference ID from your system to link with the document. |
|
|
||||||
| `host` | string | Optional custom host URL. Defaults to `https://app.documenso.com`. |
|
|
||||||
| `css` | string | Optional custom CSS to style the embedded component. |
|
|
||||||
| `cssVars` | object | Optional CSS variables for colors, spacing, and more. |
|
|
||||||
| `darkModeDisabled` | boolean | Optional flag to disable dark mode. |
|
|
||||||
| `className` | string | Optional CSS class name for the iframe. |
|
|
||||||
|
|
||||||
## Feature Toggles
|
|
||||||
|
|
||||||
You can customize the authoring experience by enabling or disabling specific features:
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
<EmbedCreateDocument
|
|
||||||
presignToken="YOUR_PRESIGN_TOKEN"
|
|
||||||
features={{
|
|
||||||
allowConfigureSignatureTypes: true,
|
|
||||||
allowConfigureLanguage: true,
|
|
||||||
allowConfigureDateFormat: true,
|
|
||||||
allowConfigureTimezone: true,
|
|
||||||
allowConfigureRedirectUrl: true,
|
|
||||||
allowConfigureCommunication: true,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Handling Document Creation Events
|
|
||||||
|
|
||||||
The `onDocumentCreated` callback is triggered when a document is successfully created, providing both the document ID and your external reference ID:
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
<EmbedCreateDocument
|
|
||||||
presignToken="YOUR_PRESIGN_TOKEN"
|
|
||||||
externalId="order-12345"
|
|
||||||
onDocumentCreated={(data) => {
|
|
||||||
// Navigate to a success page
|
|
||||||
navigate(`/documents/success?id=${data.documentId}`);
|
|
||||||
|
|
||||||
// Or update your database with the document ID
|
|
||||||
updateOrderDocument(data.externalId, data.documentId);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Styling the Embedded Component
|
|
||||||
|
|
||||||
You can customize the appearance of the embedded component using standard CSS classes:
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
<EmbedCreateDocument
|
|
||||||
className="h-screen w-full rounded-lg border-none shadow-md"
|
|
||||||
presignToken="YOUR_PRESIGN_TOKEN"
|
|
||||||
css={`
|
|
||||||
.documenso-embed {
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
cssVars={{
|
|
||||||
primary: '#0000FF',
|
|
||||||
background: '#F5F5F5',
|
|
||||||
radius: '8px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Complete Integration Example
|
|
||||||
|
|
||||||
Here's a complete example of integrating document creation in a React application:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { unstable_EmbedCreateDocument as EmbedCreateDocument } from '@documenso/embed-react';
|
|
||||||
|
|
||||||
function DocumentCreator() {
|
|
||||||
// In a real application, you would fetch this token from your backend
|
|
||||||
// using your API key at /api/v2-beta/embedding/create-presign-token
|
|
||||||
const presignToken = 'YOUR_PRESIGN_TOKEN';
|
|
||||||
const [documentId, setDocumentId] = useState<number | null>(null);
|
|
||||||
|
|
||||||
if (documentId) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h2>Document Created Successfully!</h2>
|
|
||||||
<p>Document ID: {documentId}</p>
|
|
||||||
<button onClick={() => setDocumentId(null)}>Create Another Document</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ height: '800px', width: '100%' }}>
|
|
||||||
<EmbedCreateDocument
|
|
||||||
presignToken={presignToken}
|
|
||||||
externalId="order-12345"
|
|
||||||
onDocumentCreated={(data) => {
|
|
||||||
setDocumentId(data.documentId);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default DocumentCreator;
|
|
||||||
```
|
|
||||||
|
|
||||||
With embedded authoring, your users can seamlessly create documents within your application, enhancing the overall user experience and streamlining document workflows.
|
|
||||||
@ -52,9 +52,9 @@ Platform customers have access to advanced styling options to customize the embe
|
|||||||
<EmbedDirectTemplate
|
<EmbedDirectTemplate
|
||||||
token={token}
|
token={token}
|
||||||
cssVars={{
|
cssVars={{
|
||||||
primary: '#0000FF',
|
colorPrimary: '#0000FF',
|
||||||
background: '#F5F5F5',
|
colorBackground: '#F5F5F5',
|
||||||
radius: '8px',
|
borderRadius: '8px',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
```
|
```
|
||||||
@ -169,19 +169,6 @@ Once you've obtained the appropriate tokens, you can integrate the signing exper
|
|||||||
|
|
||||||
If you're using **web components**, the integration process is slightly different. Keep in mind that web components are currently less tested but can still provide flexibility for general use cases.
|
If you're using **web components**, the integration process is slightly different. Keep in mind that web components are currently less tested but can still provide flexibility for general use cases.
|
||||||
|
|
||||||
## Embedded Authoring
|
|
||||||
|
|
||||||
In addition to embedding signing experiences, Documenso now supports **embedded authoring**, allowing your users to create documents and templates directly within your application.
|
|
||||||
|
|
||||||
With embedded authoring, you can:
|
|
||||||
|
|
||||||
- Create new documents with custom fields
|
|
||||||
- Configure document properties and settings
|
|
||||||
- Set up recipients and signing workflows
|
|
||||||
- Customize the authoring experience
|
|
||||||
|
|
||||||
For detailed implementation instructions and code examples, see our [Embedded Authoring](/developers/embedding/authoring) guide.
|
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
- [React Integration](/developers/embedding/react)
|
- [React Integration](/developers/embedding/react)
|
||||||
@ -191,4 +178,3 @@ For detailed implementation instructions and code examples, see our [Embedded Au
|
|||||||
- [Preact Integration](/developers/embedding/preact)
|
- [Preact Integration](/developers/embedding/preact)
|
||||||
- [Angular Integration](/developers/embedding/angular)
|
- [Angular Integration](/developers/embedding/angular)
|
||||||
- [CSS Variables](/developers/embedding/css-variables)
|
- [CSS Variables](/developers/embedding/css-variables)
|
||||||
- [Embedded Authoring](/developers/embedding/authoring)
|
|
||||||
|
|||||||
@ -95,9 +95,9 @@ const MyEmbeddingComponent = () => {
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const cssVars = {
|
const cssVars = {
|
||||||
primary: '#0000FF',
|
colorPrimary: '#0000FF',
|
||||||
background: '#F5F5F5',
|
colorBackground: '#F5F5F5',
|
||||||
radius: '8px',
|
borderRadius: '8px',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -99,9 +99,9 @@ const MyEmbeddingComponent = () => {
|
|||||||
`}
|
`}
|
||||||
// CSS Variables
|
// CSS Variables
|
||||||
cssVars={{
|
cssVars={{
|
||||||
primary: '#0000FF',
|
colorPrimary: '#0000FF',
|
||||||
background: '#F5F5F5',
|
colorBackground: '#F5F5F5',
|
||||||
radius: '8px',
|
borderRadius: '8px',
|
||||||
}}
|
}}
|
||||||
// Dark Mode Control
|
// Dark Mode Control
|
||||||
darkModeDisabled={true}
|
darkModeDisabled={true}
|
||||||
|
|||||||
@ -95,9 +95,9 @@ const MyEmbeddingComponent = () => {
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const cssVars = {
|
const cssVars = {
|
||||||
primary: '#0000FF',
|
colorPrimary: '#0000FF',
|
||||||
background: '#F5F5F5',
|
colorBackground: '#F5F5F5',
|
||||||
radius: '8px',
|
borderRadius: '8px',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -97,9 +97,9 @@ Platform customers have access to advanced styling options:
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const cssVars = {
|
const cssVars = {
|
||||||
primary: '#0000FF',
|
colorPrimary: '#0000FF',
|
||||||
background: '#F5F5F5',
|
colorBackground: '#F5F5F5',
|
||||||
radius: '8px',
|
borderRadius: '8px',
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -97,9 +97,9 @@ Platform customers have access to advanced styling options:
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const cssVars = {
|
const cssVars = {
|
||||||
primary: '#0000FF',
|
colorPrimary: '#0000FF',
|
||||||
background: '#F5F5F5',
|
colorBackground: '#F5F5F5',
|
||||||
radius: '8px',
|
borderRadius: '8px',
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -532,93 +532,3 @@ Replace the `text` value with the corresponding field type:
|
|||||||
- For the `SELECT` field it should be `select`. (check this before merge)
|
- For the `SELECT` field it should be `select`. (check this before merge)
|
||||||
|
|
||||||
You must pass this property at all times, even if you don't need to set any other properties. If you don't, the endpoint will throw an error.
|
You must pass this property at all times, even if you don't need to set any other properties. If you don't, the endpoint will throw an error.
|
||||||
|
|
||||||
## Pre-fill Fields On Document Creation
|
|
||||||
|
|
||||||
The API allows you to pre-fill fields on document creation. This is useful when you want to create a document from an existing template and pre-fill the fields with specific values.
|
|
||||||
|
|
||||||
To pre-fill a field, you need to make a `POST` request to the `/api/v1/templates/{templateId}/generate-document` endpoint with the field information. Here's an example:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"title": "my-document.pdf",
|
|
||||||
"recipients": [
|
|
||||||
{
|
|
||||||
"id": 3,
|
|
||||||
"name": "Example User",
|
|
||||||
"email": "example@documenso.com",
|
|
||||||
"signingOrder": 1,
|
|
||||||
"role": "SIGNER"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"prefillFields": [
|
|
||||||
{
|
|
||||||
"id": 21,
|
|
||||||
"type": "text",
|
|
||||||
"label": "my-label",
|
|
||||||
"placeholder": "my-placeholder",
|
|
||||||
"value": "my-value"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 22,
|
|
||||||
"type": "number",
|
|
||||||
"label": "my-label",
|
|
||||||
"placeholder": "my-placeholder",
|
|
||||||
"value": "123"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 23,
|
|
||||||
"type": "checkbox",
|
|
||||||
"label": "my-label",
|
|
||||||
"placeholder": "my-placeholder",
|
|
||||||
"value": ["option-1", "option-2"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Check out the endpoint in the [API V1 documentation](https://app.documenso.com/api/v1/openapi#:~:text=/%7BtemplateId%7D/-,generate,-%2Ddocument).
|
|
||||||
|
|
||||||
### API V2
|
|
||||||
|
|
||||||
For API V2, you need to make a `POST` request to the `/api/v2-beta/template/use` endpoint with the field(s) information. Here's an example:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"templateId": 111,
|
|
||||||
"recipients": [
|
|
||||||
{
|
|
||||||
"id": 3,
|
|
||||||
"name": "Example User",
|
|
||||||
"email": "example@documenso.com",
|
|
||||||
"signingOrder": 1,
|
|
||||||
"role": "SIGNER"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"prefillFields": [
|
|
||||||
{
|
|
||||||
"id": 21,
|
|
||||||
"type": "text",
|
|
||||||
"label": "my-label",
|
|
||||||
"placeholder": "my-placeholder",
|
|
||||||
"value": "my-value"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 22,
|
|
||||||
"type": "number",
|
|
||||||
"label": "my-label",
|
|
||||||
"placeholder": "my-placeholder",
|
|
||||||
"value": "123"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 23,
|
|
||||||
"type": "checkbox",
|
|
||||||
"label": "my-label",
|
|
||||||
"placeholder": "my-placeholder",
|
|
||||||
"value": ["option-1", "option-2"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Check out the endpoint in the [API V2 documentation](https://openapi.documenso.com/reference#tag/template/POST/template/use).
|
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import NextPlausibleProvider from 'next-plausible';
|
import NextPlausibleProvider from 'next-plausible';
|
||||||
|
|||||||
@ -19,22 +19,6 @@ const themeConfig: DocsThemeConfig = {
|
|||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||||
<script
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: `
|
|
||||||
!function(){
|
|
||||||
if (location.hostname === 'localhost') return;
|
|
||||||
var e="6c236490c9a68c1",
|
|
||||||
t=function(){Reo.init({ clientID: e })},
|
|
||||||
n=document.createElement("script");
|
|
||||||
n.src="https://static.reo.dev/"+e+"/reo.js";
|
|
||||||
n.defer=true;
|
|
||||||
n.onload=t;
|
|
||||||
document.head.appendChild(n);
|
|
||||||
}();
|
|
||||||
`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,54 +0,0 @@
|
|||||||
import { DateTime } from 'luxon';
|
|
||||||
|
|
||||||
export interface TransformedData {
|
|
||||||
labels: string[];
|
|
||||||
datasets: Array<{
|
|
||||||
label: string;
|
|
||||||
data: number[];
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addZeroMonth(transformedData: TransformedData): TransformedData {
|
|
||||||
const result = {
|
|
||||||
labels: [...transformedData.labels],
|
|
||||||
datasets: transformedData.datasets.map((dataset) => ({
|
|
||||||
label: dataset.label,
|
|
||||||
data: [...dataset.data],
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (result.labels.length === 0) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.datasets.every((dataset) => dataset.data[0] === 0)) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
let firstMonth = DateTime.fromFormat(result.labels[0], 'MMM yyyy');
|
|
||||||
if (!firstMonth.isValid) {
|
|
||||||
const formats = ['MMM yyyy', 'MMMM yyyy', 'MM/yyyy', 'yyyy-MM'];
|
|
||||||
|
|
||||||
for (const format of formats) {
|
|
||||||
firstMonth = DateTime.fromFormat(result.labels[0], format);
|
|
||||||
if (firstMonth.isValid) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!firstMonth.isValid) {
|
|
||||||
console.warn(`Could not parse date: "${result.labels[0]}"`);
|
|
||||||
return transformedData;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const zeroMonth = firstMonth.minus({ months: 1 }).toFormat('MMM yyyy');
|
|
||||||
result.labels.unshift(zeroMonth);
|
|
||||||
result.datasets.forEach((dataset) => {
|
|
||||||
dataset.data.unshift(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
return transformedData;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,9 +1,7 @@
|
|||||||
import { DocumentStatus } from '@prisma/client';
|
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
import { kyselyPrisma, sql } from '@documenso/prisma';
|
import { kyselyPrisma, sql } from '@documenso/prisma';
|
||||||
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
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
|
||||||
@ -37,7 +35,7 @@ export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative'
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
return addZeroMonth(transformedData);
|
return transformedData;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetCompletedDocumentsMonthlyResult = Awaited<
|
export type GetCompletedDocumentsMonthlyResult = Awaited<
|
||||||
|
|||||||
@ -2,8 +2,6 @@ import { DateTime } from 'luxon';
|
|||||||
|
|
||||||
import { kyselyPrisma, sql } from '@documenso/prisma';
|
import { kyselyPrisma, sql } from '@documenso/prisma';
|
||||||
|
|
||||||
import { addZeroMonth } from '../add-zero-month';
|
|
||||||
|
|
||||||
export const getSignerConversionMonthly = async (type: 'count' | 'cumulative' = 'count') => {
|
export const getSignerConversionMonthly = async (type: 'count' | 'cumulative' = 'count') => {
|
||||||
const qb = kyselyPrisma.$kysely
|
const qb = kyselyPrisma.$kysely
|
||||||
.selectFrom('Recipient')
|
.selectFrom('Recipient')
|
||||||
@ -36,7 +34,7 @@ export const getSignerConversionMonthly = async (type: 'count' | 'cumulative' =
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
return addZeroMonth(transformedData);
|
return transformedData;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetSignerConversionMonthlyResult = Awaited<
|
export type GetSignerConversionMonthlyResult = Awaited<
|
||||||
|
|||||||
@ -2,8 +2,6 @@ import { DateTime } from 'luxon';
|
|||||||
|
|
||||||
import { kyselyPrisma, sql } from '@documenso/prisma';
|
import { kyselyPrisma, sql } from '@documenso/prisma';
|
||||||
|
|
||||||
import { addZeroMonth } from '../add-zero-month';
|
|
||||||
|
|
||||||
export const getUserMonthlyGrowth = async (type: 'count' | 'cumulative' = 'count') => {
|
export const getUserMonthlyGrowth = async (type: 'count' | 'cumulative' = 'count') => {
|
||||||
const qb = kyselyPrisma.$kysely
|
const qb = kyselyPrisma.$kysely
|
||||||
.selectFrom('User')
|
.selectFrom('User')
|
||||||
@ -34,7 +32,7 @@ export const getUserMonthlyGrowth = async (type: 'count' | 'cumulative' = 'count
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
return addZeroMonth(transformedData);
|
return transformedData;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetUserMonthlyGrowthResult = Awaited<ReturnType<typeof getUserMonthlyGrowth>>;
|
export type GetUserMonthlyGrowthResult = Awaited<ReturnType<typeof getUserMonthlyGrowth>>;
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
import { addZeroMonth } from './add-zero-month';
|
|
||||||
|
|
||||||
type MetricKeys = {
|
type MetricKeys = {
|
||||||
stars: number;
|
stars: number;
|
||||||
forks: number;
|
forks: number;
|
||||||
@ -39,77 +37,31 @@ export function transformData({
|
|||||||
data: DataEntry;
|
data: DataEntry;
|
||||||
metric: MetricKey;
|
metric: MetricKey;
|
||||||
}): TransformData {
|
}): TransformData {
|
||||||
try {
|
const sortedEntries = Object.entries(data).sort(([dateA], [dateB]) => {
|
||||||
if (!data || Object.keys(data).length === 0) {
|
const [yearA, monthA] = dateA.split('-').map(Number);
|
||||||
return {
|
const [yearB, monthB] = dateB.split('-').map(Number);
|
||||||
labels: [],
|
|
||||||
datasets: [{ label: `Total ${FRIENDLY_METRIC_NAMES[metric]}`, data: [] }],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortedEntries = Object.entries(data).sort(([dateA], [dateB]) => {
|
return DateTime.local(yearA, monthA).toMillis() - DateTime.local(yearB, monthB).toMillis();
|
||||||
try {
|
});
|
||||||
const [yearA, monthA] = dateA.split('-').map(Number);
|
|
||||||
const [yearB, monthB] = dateB.split('-').map(Number);
|
|
||||||
|
|
||||||
if (isNaN(yearA) || isNaN(monthA) || isNaN(yearB) || isNaN(monthB)) {
|
const labels = sortedEntries.map(([date]) => {
|
||||||
console.warn(`Invalid date format: ${dateA} or ${dateB}`);
|
const [year, month] = date.split('-');
|
||||||
return 0;
|
const dateTime = DateTime.fromObject({
|
||||||
}
|
year: Number(year),
|
||||||
|
month: Number(month),
|
||||||
return DateTime.local(yearA, monthA).toMillis() - DateTime.local(yearB, monthB).toMillis();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error sorting entries:', error);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
return dateTime.toFormat('MMM yyyy');
|
||||||
|
});
|
||||||
|
|
||||||
const labels = sortedEntries.map(([date]) => {
|
return {
|
||||||
try {
|
labels,
|
||||||
const [year, month] = date.split('-');
|
datasets: [
|
||||||
|
{
|
||||||
if (!year || !month || isNaN(Number(year)) || isNaN(Number(month))) {
|
label: `Total ${FRIENDLY_METRIC_NAMES[metric]}`,
|
||||||
console.warn(`Invalid date format: ${date}`);
|
data: sortedEntries.map(([_, stats]) => stats[metric]),
|
||||||
return date;
|
},
|
||||||
}
|
],
|
||||||
|
};
|
||||||
const dateTime = DateTime.fromObject({
|
|
||||||
year: Number(year),
|
|
||||||
month: Number(month),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!dateTime.isValid) {
|
|
||||||
console.warn(`Invalid DateTime object for: ${date}`);
|
|
||||||
return date;
|
|
||||||
}
|
|
||||||
|
|
||||||
return dateTime.toFormat('MMM yyyy');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error formatting date:', error, date);
|
|
||||||
return date;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const transformedData = {
|
|
||||||
labels,
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
label: `Total ${FRIENDLY_METRIC_NAMES[metric]}`,
|
|
||||||
data: sortedEntries.map(([_, stats]) => {
|
|
||||||
const value = stats[metric];
|
|
||||||
return typeof value === 'number' && !isNaN(value) ? value : 0;
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
return addZeroMonth(transformedData);
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
labels: [],
|
|
||||||
datasets: [{ label: `Total ${FRIENDLY_METRIC_NAMES[metric]}`, data: [] }],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// To be on the safer side
|
// To be on the safer side
|
||||||
|
|||||||
@ -12,7 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@documenso/prisma": "*",
|
"@documenso/prisma": "*",
|
||||||
"luxon": "^3.5.0",
|
"luxon": "^3.5.0",
|
||||||
"next": "14.2.28"
|
"next": "14.2.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
# Exit on error.
|
# Exit on error.
|
||||||
set -e
|
set -eo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
|
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
|
||||||
WEB_APP_DIR="$SCRIPT_DIR/.."
|
WEB_APP_DIR="$SCRIPT_DIR/.."
|
||||||
|
|||||||
@ -1,197 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { useLingui } from '@lingui/react/macro';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { useNavigate } from 'react-router';
|
|
||||||
import type { z } from 'zod';
|
|
||||||
|
|
||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { ZCreateAdminOrganisationRequestSchema } from '@documenso/trpc/server/admin-router/create-admin-organisation.types';
|
|
||||||
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 {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
export type OrganisationCreateDialogProps = {
|
|
||||||
trigger?: React.ReactNode;
|
|
||||||
ownerUserId: number;
|
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
|
||||||
|
|
||||||
const ZCreateAdminOrganisationFormSchema = ZCreateAdminOrganisationRequestSchema.shape.data.pick({
|
|
||||||
name: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
type TCreateOrganisationFormSchema = z.infer<typeof ZCreateAdminOrganisationFormSchema>;
|
|
||||||
|
|
||||||
export const AdminOrganisationCreateDialog = ({
|
|
||||||
trigger,
|
|
||||||
ownerUserId,
|
|
||||||
...props
|
|
||||||
}: OrganisationCreateDialogProps) => {
|
|
||||||
const { t } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
resolver: zodResolver(ZCreateAdminOrganisationFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
name: '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutateAsync: createOrganisation } = trpc.admin.organisation.create.useMutation();
|
|
||||||
|
|
||||||
const onFormSubmit = async ({ name }: TCreateOrganisationFormSchema) => {
|
|
||||||
try {
|
|
||||||
const { organisationId } = await createOrganisation({
|
|
||||||
ownerUserId,
|
|
||||||
data: {
|
|
||||||
name,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await navigate(`/admin/organisations/${organisationId}`);
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: t`Success`,
|
|
||||||
description: t`Organisation created`,
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
const error = AppError.parseError(err);
|
|
||||||
|
|
||||||
console.error(error);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: t`An unknown error occurred`,
|
|
||||||
description: t`We encountered an unknown error while attempting to create a organisation. Please try again later.`,
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
form.reset();
|
|
||||||
}, [open, form]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
{...props}
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
|
||||||
>
|
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
|
||||||
{trigger ?? (
|
|
||||||
<Button className="flex-shrink-0" variant="secondary">
|
|
||||||
<Trans>Create organisation</Trans>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent position="center">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Create organisation</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription>
|
|
||||||
<Trans>Create an organisation for this user</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
|
||||||
<fieldset
|
|
||||||
className="flex h-full flex-col space-y-4"
|
|
||||||
disabled={form.formState.isSubmitting}
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel required>
|
|
||||||
<Trans>Organisation Name</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Alert variant="neutral">
|
|
||||||
<AlertDescription className="mt-0">
|
|
||||||
<Trans>
|
|
||||||
You will need to configure any claims or subscription after creating this
|
|
||||||
organisation
|
|
||||||
</Trans>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
{/* <FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Default claim ID</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
<Trans>Leave blank to use the default free claim</Trans>
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/> */}
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
data-testid="dialog-create-organisation-button"
|
|
||||||
loading={form.formState.isSubmitting}
|
|
||||||
>
|
|
||||||
<Trans>Create</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,9 +1,4 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@ -14,192 +9,64 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} 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 { DocumentSigningDisclosure } from '../general/document-signing/document-signing-disclosure';
|
import { DocumentSigningDisclosure } from '../general/document-signing/document-signing-disclosure';
|
||||||
|
|
||||||
export type NextSigner = {
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ConfirmationDialogProps = {
|
type ConfirmationDialogProps = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onConfirm: (nextSigner?: NextSigner) => void;
|
onConfirm: () => void;
|
||||||
hasUninsertedFields: boolean;
|
hasUninsertedFields: boolean;
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
allowDictateNextSigner?: boolean;
|
|
||||||
defaultNextSigner?: NextSigner;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const ZNextSignerFormSchema = z.object({
|
|
||||||
name: z.string().min(1, 'Name is required'),
|
|
||||||
email: z.string().email('Invalid email address'),
|
|
||||||
});
|
|
||||||
|
|
||||||
type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
|
|
||||||
|
|
||||||
export function AssistantConfirmationDialog({
|
export function AssistantConfirmationDialog({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
hasUninsertedFields,
|
hasUninsertedFields,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
allowDictateNextSigner = false,
|
|
||||||
defaultNextSigner,
|
|
||||||
}: ConfirmationDialogProps) {
|
}: ConfirmationDialogProps) {
|
||||||
const [isEditingNextSigner, setIsEditingNextSigner] = useState(false);
|
|
||||||
|
|
||||||
const form = useForm<TNextSignerFormSchema>({
|
|
||||||
resolver: zodResolver(ZNextSignerFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
name: defaultNextSigner?.name ?? '',
|
|
||||||
email: defaultNextSigner?.email ?? '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onOpenChange = () => {
|
const onOpenChange = () => {
|
||||||
if (isSubmitting) {
|
if (isSubmitting) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
form.reset({
|
|
||||||
name: defaultNextSigner?.name ?? '',
|
|
||||||
email: defaultNextSigner?.email ?? '',
|
|
||||||
});
|
|
||||||
|
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
// Validate the form and submit it if dictate signer is enabled.
|
|
||||||
if (allowDictateNextSigner) {
|
|
||||||
void form.handleSubmit(onConfirm)();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
onConfirm();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<Form {...form}>
|
<DialogHeader>
|
||||||
<form>
|
<DialogTitle>
|
||||||
<fieldset disabled={isSubmitting} className="border-none p-0">
|
<Trans>Complete Document</Trans>
|
||||||
<DialogHeader>
|
</DialogTitle>
|
||||||
<DialogTitle>
|
<DialogDescription>
|
||||||
<Trans>Complete Document</Trans>
|
<Trans>
|
||||||
</DialogTitle>
|
Are you sure you want to complete the document? This action cannot be undone. Please
|
||||||
<DialogDescription>
|
ensure that you have completed prefilling all relevant fields before proceeding.
|
||||||
<Trans>
|
</Trans>
|
||||||
Are you sure you want to complete the document? This action cannot be undone.
|
</DialogDescription>
|
||||||
Please ensure that you have completed prefilling all relevant fields before
|
</DialogHeader>
|
||||||
proceeding.
|
|
||||||
</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="mt-4 flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{allowDictateNextSigner && (
|
<DocumentSigningDisclosure />
|
||||||
<div className="mt-4 flex flex-col gap-4">
|
</div>
|
||||||
{!isEditingNextSigner && (
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
The next recipient to sign this document will be{' '}
|
|
||||||
<span className="font-semibold">{form.watch('name')}</span> (
|
|
||||||
<span className="font-semibold">{form.watch('email')}</span>).
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button
|
<DialogFooter className="mt-4">
|
||||||
type="button"
|
<Button variant="secondary" onClick={onClose} disabled={isSubmitting}>
|
||||||
className="mt-2"
|
Cancel
|
||||||
variant="outline"
|
</Button>
|
||||||
size="sm"
|
<Button
|
||||||
onClick={() => setIsEditingNextSigner((prev) => !prev)}
|
variant={hasUninsertedFields ? 'destructive' : 'default'}
|
||||||
>
|
onClick={onConfirm}
|
||||||
<Trans>Update Recipient</Trans>
|
disabled={isSubmitting}
|
||||||
</Button>
|
loading={isSubmitting}
|
||||||
</div>
|
>
|
||||||
)}
|
{isSubmitting ? 'Submitting...' : hasUninsertedFields ? 'Proceed' : 'Continue'}
|
||||||
|
</Button>
|
||||||
{isEditingNextSigner && (
|
</DialogFooter>
|
||||||
<div className="flex flex-col gap-4 md:flex-row">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex-1">
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Name</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
className="mt-2"
|
|
||||||
placeholder="Enter the next signer's name"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="email"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex-1">
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Email</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
type="email"
|
|
||||||
className="mt-2"
|
|
||||||
placeholder="Enter the next signer's email"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DocumentSigningDisclosure className="mt-4" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="mt-4">
|
|
||||||
<Button type="button" variant="secondary" onClick={onClose} disabled={isSubmitting}>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant={hasUninsertedFields ? 'destructive' : 'default'}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
onClick={handleSubmit}
|
|
||||||
loading={isSubmitting}
|
|
||||||
>
|
|
||||||
{hasUninsertedFields ? <Trans>Proceed</Trans> : <Trans>Continue</Trans>}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,90 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
|
||||||
import type { z } from 'zod';
|
|
||||||
|
|
||||||
import { generateDefaultSubscriptionClaim } from '@documenso/lib/utils/organisations-claims';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import type { ZCreateSubscriptionClaimRequestSchema } from '@documenso/trpc/server/admin-router/create-subscription-claim.types';
|
|
||||||
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 { SubscriptionClaimForm } from '../forms/subscription-claim-form';
|
|
||||||
|
|
||||||
export type CreateClaimFormValues = z.infer<typeof ZCreateSubscriptionClaimRequestSchema>;
|
|
||||||
|
|
||||||
export const ClaimCreateDialog = () => {
|
|
||||||
const { t } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const { mutateAsync: createClaim, isPending } = trpc.admin.claims.create.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({
|
|
||||||
title: t`Subscription claim created successfully.`,
|
|
||||||
});
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast({
|
|
||||||
title: t`Failed to create subscription claim.`,
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
|
||||||
<Button className="flex-shrink-0" variant="secondary">
|
|
||||||
<Trans>Create claim</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Create Subscription Claim</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
<Trans>Fill in the details to create a new subscription claim.</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<SubscriptionClaimForm
|
|
||||||
subscriptionClaim={{
|
|
||||||
...generateDefaultSubscriptionClaim(),
|
|
||||||
}}
|
|
||||||
onFormSubmit={createClaim}
|
|
||||||
formSubmitTrigger={
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
disabled={isPending}
|
|
||||||
>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button type="submit" loading={isPending}>
|
|
||||||
<Trans>Create Claim</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,96 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { Trans, useLingui } 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 ClaimDeleteDialogProps = {
|
|
||||||
claimId: string;
|
|
||||||
claimName: string;
|
|
||||||
claimLocked: boolean;
|
|
||||||
trigger: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ClaimDeleteDialog = ({
|
|
||||||
claimId,
|
|
||||||
claimName,
|
|
||||||
claimLocked,
|
|
||||||
trigger,
|
|
||||||
}: ClaimDeleteDialogProps) => {
|
|
||||||
const { t } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const { mutateAsync: deleteClaim, isPending } = trpc.admin.claims.delete.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({
|
|
||||||
title: t`Subscription claim deleted successfully.`,
|
|
||||||
});
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
console.error(err);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: t`Failed to delete subscription claim.`,
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
|
|
||||||
<DialogTrigger asChild onClick={(e) => e.stopPropagation()}>
|
|
||||||
{trigger}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Delete Subscription Claim</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
<Trans>Are you sure you want to delete the following claim?</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Alert variant="neutral">
|
|
||||||
<AlertDescription className="text-center font-semibold">
|
|
||||||
{claimLocked ? <Trans>This claim is locked and cannot be deleted.</Trans> : claimName}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{!claimLocked && (
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="destructive"
|
|
||||||
loading={isPending}
|
|
||||||
onClick={async () => deleteClaim({ id: claimId })}
|
|
||||||
>
|
|
||||||
<Trans>Delete</Trans>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,92 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
|
||||||
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import type { TFindSubscriptionClaimsResponse } from '@documenso/trpc/server/admin-router/find-subscription-claims.types';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { SubscriptionClaimForm } from '../forms/subscription-claim-form';
|
|
||||||
|
|
||||||
export type ClaimUpdateDialogProps = {
|
|
||||||
claim: TFindSubscriptionClaimsResponse['data'][number];
|
|
||||||
trigger: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ClaimUpdateDialog = ({ claim, trigger }: ClaimUpdateDialogProps) => {
|
|
||||||
const { t } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const { mutateAsync: updateClaim, isPending } = trpc.admin.claims.update.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({
|
|
||||||
title: t`Subscription claim updated successfully.`,
|
|
||||||
});
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast({
|
|
||||||
title: t`Failed to update subscription claim.`,
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogTrigger asChild onClick={(e) => e.stopPropagation()}>
|
|
||||||
{trigger}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Update Subscription Claim</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
<Trans>Modify the details of the subscription claim.</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<SubscriptionClaimForm
|
|
||||||
subscriptionClaim={claim}
|
|
||||||
onFormSubmit={async (data) =>
|
|
||||||
await updateClaim({
|
|
||||||
id: claim.id,
|
|
||||||
data,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
formSubmitTrigger={
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
disabled={isPending}
|
|
||||||
>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button type="submit" loading={isPending}>
|
|
||||||
<Trans>Update Claim</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -4,7 +4,7 @@ import { msg } from '@lingui/core/macro';
|
|||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { DocumentStatus } from '@prisma/client';
|
import { DocumentStatus } from '@prisma/client';
|
||||||
import { P, match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
@ -28,6 +28,7 @@ type DocumentDeleteDialogProps = {
|
|||||||
onDelete?: () => Promise<void> | void;
|
onDelete?: () => Promise<void> | void;
|
||||||
status: DocumentStatus;
|
status: DocumentStatus;
|
||||||
documentTitle: string;
|
documentTitle: string;
|
||||||
|
teamId?: number;
|
||||||
canManageDocument: boolean;
|
canManageDocument: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -145,7 +146,7 @@ export const DocumentDeleteDialog = ({
|
|||||||
</ul>
|
</ul>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
))
|
))
|
||||||
.with(P.union(DocumentStatus.COMPLETED, DocumentStatus.REJECTED), () => (
|
.with(DocumentStatus.COMPLETED, () => (
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<p>
|
<p>
|
||||||
<Trans>By deleting this document, the following will occur:</Trans>
|
<Trans>By deleting this document, the following will occur:</Trans>
|
||||||
|
|||||||
@ -16,7 +16,7 @@ import {
|
|||||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
type DocumentDuplicateDialogProps = {
|
type DocumentDuplicateDialogProps = {
|
||||||
id: number;
|
id: number;
|
||||||
@ -34,7 +34,7 @@ export const DocumentDuplicateDialog = ({
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const team = useCurrentTeam();
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery(
|
const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery(
|
||||||
{
|
{
|
||||||
@ -52,7 +52,7 @@ export const DocumentDuplicateDialog = ({
|
|||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
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.duplicateDocument.useMutation({
|
||||||
|
|||||||
124
apps/remix/app/components/dialogs/document-move-dialog.tsx
Normal file
124
apps/remix/app/components/dialogs/document-move-dialog.tsx
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
|
||||||
|
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@documenso/ui/primitives/select';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
type DocumentMoveDialogProps = {
|
||||||
|
documentId: number;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (_open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DocumentMoveDialog = ({ documentId, open, onOpenChange }: DocumentMoveDialogProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [selectedTeamId, setSelectedTeamId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const { data: teams, isPending: isLoadingTeams } = trpc.team.getTeams.useQuery();
|
||||||
|
|
||||||
|
const { mutateAsync: moveDocument, isPending } = trpc.document.moveDocumentToTeam.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: _(msg`Document moved`),
|
||||||
|
description: _(msg`The document has been successfully moved to the selected team.`),
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: _(msg`Error`),
|
||||||
|
description: error.message || _(msg`An error occurred while moving the document.`),
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 7500,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onMove = async () => {
|
||||||
|
if (!selectedTeamId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await moveDocument({ documentId, teamId: selectedTeamId });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Move Document to Team</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>Select a team to move this document to. This action cannot be undone.</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Select onValueChange={(value) => setSelectedTeamId(Number(value))}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={_(msg`Select a team`)} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{isLoadingTeams ? (
|
||||||
|
<SelectItem value="loading" disabled>
|
||||||
|
<Trans>Loading teams...</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
) : (
|
||||||
|
teams?.map((team) => (
|
||||||
|
<SelectItem key={team.id} value={team.id.toString()}>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Avatar className="h-8 w-8">
|
||||||
|
{team.avatarImageId && (
|
||||||
|
<AvatarImage src={formatAvatarUrl(team.avatarImageId)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AvatarFallback className="text-sm text-gray-400">
|
||||||
|
{team.name.slice(0, 1).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<span>{team.name}</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onMove} loading={isPending} disabled={!selectedTeamId || isPending}>
|
||||||
|
{isPending ? <Trans>Moving...</Trans> : <Trans>Move</Trans>}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,217 +0,0 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
|
||||||
import { FolderIcon, HomeIcon, Loader2 } from 'lucide-react';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { useNavigate } from 'react-router';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
||||||
import { FolderType } from '@documenso/lib/types/folder-type';
|
|
||||||
import { formatDocumentsPath } 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,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
|
||||||
|
|
||||||
export type DocumentMoveToFolderDialogProps = {
|
|
||||||
documentId: number;
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
currentFolderId?: string;
|
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
|
||||||
|
|
||||||
const ZMoveDocumentFormSchema = z.object({
|
|
||||||
folderId: z.string().nullable().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type TMoveDocumentFormSchema = z.infer<typeof ZMoveDocumentFormSchema>;
|
|
||||||
|
|
||||||
export const DocumentMoveToFolderDialog = ({
|
|
||||||
documentId,
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
currentFolderId,
|
|
||||||
...props
|
|
||||||
}: DocumentMoveToFolderDialogProps) => {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const team = useCurrentTeam();
|
|
||||||
|
|
||||||
const form = useForm<TMoveDocumentFormSchema>({
|
|
||||||
resolver: zodResolver(ZMoveDocumentFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
folderId: currentFolderId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFolders.useQuery(
|
|
||||||
{
|
|
||||||
parentId: currentFolderId,
|
|
||||||
type: FolderType.DOCUMENT,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: open,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const { mutateAsync: moveDocumentToFolder } = trpc.folder.moveDocumentToFolder.useMutation();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
form.reset();
|
|
||||||
} else {
|
|
||||||
form.reset({ folderId: currentFolderId });
|
|
||||||
}
|
|
||||||
}, [open, currentFolderId, form]);
|
|
||||||
|
|
||||||
const onSubmit = async (data: TMoveDocumentFormSchema) => {
|
|
||||||
try {
|
|
||||||
await moveDocumentToFolder({
|
|
||||||
documentId,
|
|
||||||
folderId: data.folderId ?? null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const documentsPath = formatDocumentsPath(team.url);
|
|
||||||
|
|
||||||
if (data.folderId) {
|
|
||||||
await navigate(`${documentsPath}/f/${data.folderId}`);
|
|
||||||
} else {
|
|
||||||
await navigate(documentsPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`Document moved`),
|
|
||||||
description: _(msg`The document has been moved successfully.`),
|
|
||||||
variant: 'default',
|
|
||||||
});
|
|
||||||
|
|
||||||
onOpenChange(false);
|
|
||||||
} catch (err) {
|
|
||||||
const error = AppError.parseError(err);
|
|
||||||
|
|
||||||
if (error.code === AppErrorCode.NOT_FOUND) {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Error`),
|
|
||||||
description: _(msg`The folder you are trying to move the document to does not exist.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`Error`),
|
|
||||||
description: _(msg`An error occurred while moving the document.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog {...props} open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Move Document to Folder</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription>
|
|
||||||
<Trans>Select a folder to move this document to.</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-4 flex flex-col gap-y-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="folderId"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Folder</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{isFoldersLoading ? (
|
|
||||||
<div className="flex h-10 items-center justify-center">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant={field.value === null ? 'default' : 'outline'}
|
|
||||||
className="w-full justify-start"
|
|
||||||
onClick={() => field.onChange(null)}
|
|
||||||
disabled={currentFolderId === null}
|
|
||||||
>
|
|
||||||
<HomeIcon className="mr-2 h-4 w-4" />
|
|
||||||
<Trans>Root (No Folder)</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{folders?.data.map((folder) => (
|
|
||||||
<Button
|
|
||||||
key={folder.id}
|
|
||||||
type="button"
|
|
||||||
variant={field.value === folder.id ? 'default' : 'outline'}
|
|
||||||
className="w-full justify-start"
|
|
||||||
onClick={() => field.onChange(folder.id)}
|
|
||||||
disabled={currentFolderId === folder.id}
|
|
||||||
>
|
|
||||||
<FolderIcon className="mr-2 h-4 w-4" />
|
|
||||||
{folder.name}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={
|
|
||||||
isFoldersLoading || form.formState.isSubmitting || currentFolderId === null
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Trans>Move</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -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 { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { type Recipient, SigningStatus } from '@prisma/client';
|
import type { Team } from '@prisma/client';
|
||||||
|
import { type Document, type Recipient, SigningStatus } from '@prisma/client';
|
||||||
import { History } from 'lucide-react';
|
import { History } from 'lucide-react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } 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 { 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';
|
||||||
@ -36,14 +36,16 @@ import {
|
|||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
import { StackAvatar } from '../general/stack-avatar';
|
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: Document & {
|
||||||
|
team: Pick<Team, 'id' | 'url'> | null;
|
||||||
|
};
|
||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -57,7 +59,7 @@ export type TResendDocumentFormSchema = z.infer<typeof ZResendDocumentFormSchema
|
|||||||
|
|
||||||
export const DocumentResendDialog = ({ document, recipients }: DocumentResendDialogProps) => {
|
export const DocumentResendDialog = ({ document, recipients }: DocumentResendDialogProps) => {
|
||||||
const { user } = useSession();
|
const { user } = useSession();
|
||||||
const team = useCurrentTeam();
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
|||||||
@ -1,163 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
|
||||||
import { FolderPlusIcon } from 'lucide-react';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { useNavigate, useParams } from 'react-router';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
||||||
import { FolderType } from '@documenso/lib/types/folder-type';
|
|
||||||
import { formatDocumentsPath } 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 {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
|
||||||
|
|
||||||
const ZCreateFolderFormSchema = z.object({
|
|
||||||
name: z.string().min(1, { message: 'Folder name is required' }),
|
|
||||||
});
|
|
||||||
|
|
||||||
type TCreateFolderFormSchema = z.infer<typeof ZCreateFolderFormSchema>;
|
|
||||||
|
|
||||||
export type CreateFolderDialogProps = {
|
|
||||||
trigger?: React.ReactNode;
|
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
|
||||||
|
|
||||||
export const CreateFolderDialog = ({ trigger, ...props }: CreateFolderDialogProps) => {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { folderId } = useParams();
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const team = useCurrentTeam();
|
|
||||||
|
|
||||||
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
|
|
||||||
|
|
||||||
const { mutateAsync: createFolder } = trpc.folder.createFolder.useMutation();
|
|
||||||
|
|
||||||
const form = useForm<TCreateFolderFormSchema>({
|
|
||||||
resolver: zodResolver(ZCreateFolderFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
name: '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSubmit = async (data: TCreateFolderFormSchema) => {
|
|
||||||
try {
|
|
||||||
const newFolder = await createFolder({
|
|
||||||
name: data.name,
|
|
||||||
parentId: folderId,
|
|
||||||
type: FolderType.DOCUMENT,
|
|
||||||
});
|
|
||||||
|
|
||||||
setIsCreateFolderOpen(false);
|
|
||||||
|
|
||||||
const documentsPath = formatDocumentsPath(team.url);
|
|
||||||
|
|
||||||
await navigate(`${documentsPath}/f/${newFolder.id}`);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
description: 'Folder created successfully',
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
const error = AppError.parseError(err);
|
|
||||||
|
|
||||||
if (error.code === AppErrorCode.ALREADY_EXISTS) {
|
|
||||||
toast({
|
|
||||||
title: 'Failed to create folder',
|
|
||||||
description: _(msg`This folder name is already taken.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Failed to create folder',
|
|
||||||
description: _(msg`An unknown error occurred while creating the folder.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isCreateFolderOpen) {
|
|
||||||
form.reset();
|
|
||||||
}
|
|
||||||
}, [isCreateFolderOpen, form]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog {...props} open={isCreateFolderOpen} onOpenChange={setIsCreateFolderOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
{trigger ?? (
|
|
||||||
<Button variant="outline" className="flex items-center space-x-2">
|
|
||||||
<FolderPlusIcon className="h-4 w-4" />
|
|
||||||
<span>Create Folder</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Create New Folder</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Enter a name for your new folder. Folders help you organise your documents.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Folder Name</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="My Folder" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => setIsCreateFolderOpen(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button type="submit">Create</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,159 +0,0 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
export type FolderDeleteDialogProps = {
|
|
||||||
folder: TFolderWithSubfolders | null;
|
|
||||||
isOpen: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
|
||||||
|
|
||||||
export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDeleteDialogProps) => {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { mutateAsync: deleteFolder } = trpc.folder.deleteFolder.useMutation();
|
|
||||||
|
|
||||||
const deleteMessage = _(msg`delete ${folder?.name ?? 'folder'}`);
|
|
||||||
|
|
||||||
const ZDeleteFolderFormSchema = z.object({
|
|
||||||
confirmText: z.literal(deleteMessage, {
|
|
||||||
errorMap: () => ({ message: _(msg`You must type '${deleteMessage}' to confirm`) }),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
type TDeleteFolderFormSchema = z.infer<typeof ZDeleteFolderFormSchema>;
|
|
||||||
|
|
||||||
const form = useForm<TDeleteFolderFormSchema>({
|
|
||||||
resolver: zodResolver(ZDeleteFolderFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
confirmText: '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onFormSubmit = async () => {
|
|
||||||
if (!folder) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await deleteFolder({
|
|
||||||
id: folder.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
onOpenChange(false);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Folder deleted successfully',
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
const error = AppError.parseError(err);
|
|
||||||
|
|
||||||
if (error.code === AppErrorCode.NOT_FOUND) {
|
|
||||||
toast({
|
|
||||||
title: 'Folder not found',
|
|
||||||
description: _(msg`The folder you are trying to delete does not exist.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Failed to delete folder',
|
|
||||||
description: _(msg`An unknown error occurred while deleting the folder.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) {
|
|
||||||
form.reset();
|
|
||||||
}
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Delete Folder</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Are you sure you want to delete this folder?
|
|
||||||
{folder && folder._count.documents > 0 && (
|
|
||||||
<span className="text-destructive mt-2 block">
|
|
||||||
This folder contains {folder._count.documents} document(s). Deleting it will also
|
|
||||||
delete all documents in the folder.
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{folder && folder._count.subfolders > 0 && (
|
|
||||||
<span className="text-destructive mt-2 block">
|
|
||||||
This folder contains {folder._count.subfolders} subfolder(s). Deleting it will
|
|
||||||
delete all subfolders and their contents.
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="confirmText"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>
|
|
||||||
Confirm by typing:{' '}
|
|
||||||
<span className="font-sm text-destructive font-semibold">
|
|
||||||
{deleteMessage}
|
|
||||||
</span>
|
|
||||||
</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} placeholder={deleteMessage} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="secondary" onClick={() => onOpenChange(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button variant="destructive" type="submit" disabled={!form.formState.isValid}>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,169 +0,0 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
|
||||||
import { FolderIcon, HomeIcon } from 'lucide-react';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
|
||||||
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 { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
export type FolderMoveDialogProps = {
|
|
||||||
foldersData: TFolderWithSubfolders[] | undefined;
|
|
||||||
folder: TFolderWithSubfolders | null;
|
|
||||||
isOpen: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
|
||||||
|
|
||||||
const ZMoveFolderFormSchema = z.object({
|
|
||||||
targetFolderId: z.string().nullable(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type TMoveFolderFormSchema = z.infer<typeof ZMoveFolderFormSchema>;
|
|
||||||
|
|
||||||
export const FolderMoveDialog = ({
|
|
||||||
foldersData,
|
|
||||||
folder,
|
|
||||||
isOpen,
|
|
||||||
onOpenChange,
|
|
||||||
}: FolderMoveDialogProps) => {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { mutateAsync: moveFolder } = trpc.folder.moveFolder.useMutation();
|
|
||||||
|
|
||||||
const form = useForm<TMoveFolderFormSchema>({
|
|
||||||
resolver: zodResolver(ZMoveFolderFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
targetFolderId: folder?.parentId ?? null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onFormSubmit = async ({ targetFolderId }: TMoveFolderFormSchema) => {
|
|
||||||
if (!folder) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await moveFolder({
|
|
||||||
id: folder.id,
|
|
||||||
parentId: targetFolderId || null,
|
|
||||||
});
|
|
||||||
|
|
||||||
onOpenChange(false);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Folder moved successfully',
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
const error = AppError.parseError(err);
|
|
||||||
|
|
||||||
if (error.code === AppErrorCode.NOT_FOUND) {
|
|
||||||
toast({
|
|
||||||
title: 'Folder not found',
|
|
||||||
description: _(msg`The folder you are trying to move does not exist.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Failed to move folder',
|
|
||||||
description: _(msg`An unknown error occurred while moving the folder.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) {
|
|
||||||
form.reset();
|
|
||||||
}
|
|
||||||
}, [isOpen, form]);
|
|
||||||
|
|
||||||
// Filter out the current folder and only show folders of the same type
|
|
||||||
const filteredFolders = foldersData?.filter(
|
|
||||||
(f) => f.id !== folder?.id && f.type === folder?.type,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Move Folder</DialogTitle>
|
|
||||||
<DialogDescription>Select a destination for this folder.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4 py-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="targetFolderId"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormControl>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant={!field.value ? 'default' : 'outline'}
|
|
||||||
className="w-full justify-start"
|
|
||||||
disabled={!folder?.parentId}
|
|
||||||
onClick={() => field.onChange(null)}
|
|
||||||
>
|
|
||||||
<HomeIcon className="mr-2 h-4 w-4" />
|
|
||||||
Root
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{filteredFolders &&
|
|
||||||
filteredFolders.map((f) => (
|
|
||||||
<Button
|
|
||||||
key={f.id}
|
|
||||||
type="button"
|
|
||||||
disabled={f.id === folder?.parentId}
|
|
||||||
variant={field.value === f.id ? 'default' : 'outline'}
|
|
||||||
className="w-full justify-start"
|
|
||||||
onClick={() => field.onChange(f.id)}
|
|
||||||
>
|
|
||||||
<FolderIcon className="mr-2 h-4 w-4" />
|
|
||||||
{f.name}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
|
||||||
Move Folder
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,173 +0,0 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
||||||
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@documenso/ui/primitives/select';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
|
||||||
|
|
||||||
export type FolderSettingsDialogProps = {
|
|
||||||
folder: TFolderWithSubfolders | null;
|
|
||||||
isOpen: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
|
||||||
|
|
||||||
export const ZUpdateFolderFormSchema = z.object({
|
|
||||||
name: z.string().min(1),
|
|
||||||
visibility: z.nativeEnum(DocumentVisibility).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type TUpdateFolderFormSchema = z.infer<typeof ZUpdateFolderFormSchema>;
|
|
||||||
|
|
||||||
export const FolderSettingsDialog = ({
|
|
||||||
folder,
|
|
||||||
isOpen,
|
|
||||||
onOpenChange,
|
|
||||||
}: FolderSettingsDialogProps) => {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const team = useOptionalCurrentTeam();
|
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation();
|
|
||||||
|
|
||||||
const isTeamContext = !!team;
|
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof ZUpdateFolderFormSchema>>({
|
|
||||||
resolver: zodResolver(ZUpdateFolderFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
name: folder?.name ?? '',
|
|
||||||
visibility: folder?.visibility ?? DocumentVisibility.EVERYONE,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (folder) {
|
|
||||||
form.reset({
|
|
||||||
name: folder.name,
|
|
||||||
visibility: folder.visibility ?? DocumentVisibility.EVERYONE,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [folder, form]);
|
|
||||||
|
|
||||||
const onFormSubmit = async (data: TUpdateFolderFormSchema) => {
|
|
||||||
if (!folder) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await updateFolder({
|
|
||||||
id: folder.id,
|
|
||||||
name: data.name,
|
|
||||||
visibility: isTeamContext
|
|
||||||
? (data.visibility ?? DocumentVisibility.EVERYONE)
|
|
||||||
: DocumentVisibility.EVERYONE,
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`Folder updated successfully`),
|
|
||||||
});
|
|
||||||
|
|
||||||
onOpenChange(false);
|
|
||||||
} catch (err) {
|
|
||||||
const error = AppError.parseError(err);
|
|
||||||
|
|
||||||
if (error.code === AppErrorCode.NOT_FOUND) {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Folder not found`),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Folder Settings</DialogTitle>
|
|
||||||
<DialogDescription>Manage the settings for this folder.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Name</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isTeamContext && (
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="visibility"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Visibility</FormLabel>
|
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
|
||||||
<FormControl>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select visibility" />
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value={DocumentVisibility.EVERYONE}>Everyone</SelectItem>
|
|
||||||
<SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE}>
|
|
||||||
Managers and above
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value={DocumentVisibility.ADMIN}>Admins only</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="submit">Save Changes</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,423 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import type { MessageDescriptor } from '@lingui/core';
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react/macro';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
|
||||||
import { ExternalLinkIcon } from 'lucide-react';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { Link, useSearchParams } from 'react-router';
|
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
import type { z } from 'zod';
|
|
||||||
|
|
||||||
import type { InternalClaimPlans } from '@documenso/ee/server-only/stripe/get-internal-claim-plans';
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
|
||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
|
||||||
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
|
||||||
import { parseMessageDescriptorMacro } from '@documenso/lib/utils/i18n';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { ZCreateOrganisationRequestSchema } from '@documenso/trpc/server/organisation-router/create-organisation.types';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Badge } from '@documenso/ui/primitives/badge';
|
|
||||||
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 { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
|
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
export type OrganisationCreateDialogProps = {
|
|
||||||
trigger?: React.ReactNode;
|
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
|
||||||
|
|
||||||
export const ZCreateOrganisationFormSchema = ZCreateOrganisationRequestSchema.pick({
|
|
||||||
name: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
export type TCreateOrganisationFormSchema = z.infer<typeof ZCreateOrganisationFormSchema>;
|
|
||||||
|
|
||||||
export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCreateDialogProps) => {
|
|
||||||
const { t } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { refreshSession } = useSession();
|
|
||||||
|
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
|
||||||
|
|
||||||
const actionSearchParam = searchParams?.get('action');
|
|
||||||
|
|
||||||
const [step, setStep] = useState<'billing' | 'create'>(
|
|
||||||
IS_BILLING_ENABLED() ? 'billing' : 'create',
|
|
||||||
);
|
|
||||||
|
|
||||||
const [selectedPriceId, setSelectedPriceId] = useState<string>('');
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
resolver: zodResolver(ZCreateOrganisationFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
name: '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutateAsync: createOrganisation } = trpc.organisation.create.useMutation();
|
|
||||||
|
|
||||||
const { data: plansData } = trpc.billing.plans.get.useQuery();
|
|
||||||
|
|
||||||
const onFormSubmit = async ({ name }: TCreateOrganisationFormSchema) => {
|
|
||||||
try {
|
|
||||||
const response = await createOrganisation({
|
|
||||||
name,
|
|
||||||
priceId: selectedPriceId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.paymentRequired) {
|
|
||||||
window.open(response.checkoutUrl, '_blank');
|
|
||||||
setOpen(false);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await refreshSession();
|
|
||||||
setOpen(false);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: t`Success`,
|
|
||||||
description: t`Your organisation has been created.`,
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
const error = AppError.parseError(err);
|
|
||||||
|
|
||||||
console.error(error);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: t`An unknown error occurred`,
|
|
||||||
description: t`We encountered an unknown error while attempting to create a organisation. Please try again later.`,
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (actionSearchParam === 'add-organisation') {
|
|
||||||
setOpen(true);
|
|
||||||
updateSearchParams({ action: null });
|
|
||||||
}
|
|
||||||
}, [actionSearchParam, open]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
form.reset();
|
|
||||||
}, [open, form]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
{...props}
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
|
||||||
>
|
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
|
||||||
{trigger ?? (
|
|
||||||
<Button className="flex-shrink-0" variant="secondary">
|
|
||||||
<Trans>Create organisation</Trans>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent position="center">
|
|
||||||
{match(step)
|
|
||||||
.with('billing', () => (
|
|
||||||
<>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Select a plan</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription>
|
|
||||||
<Trans>Select a plan to continue</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<fieldset aria-label="Plan select">
|
|
||||||
{plansData ? (
|
|
||||||
<BillingPlanForm
|
|
||||||
value={selectedPriceId}
|
|
||||||
onChange={setSelectedPriceId}
|
|
||||||
plans={plansData.plans}
|
|
||||||
canCreateFreeOrganisation={plansData.canCreateFreeOrganisation}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<SpinnerBox className="py-32" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DialogFooter className="mt-4">
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button type="submit" onClick={() => setStep('create')}>
|
|
||||||
<Trans>Continue</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</fieldset>
|
|
||||||
</>
|
|
||||||
))
|
|
||||||
.with('create', () => (
|
|
||||||
<>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Create organisation</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription>
|
|
||||||
<Trans>Create an organisation to collaborate with teams</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
|
||||||
<fieldset
|
|
||||||
className="flex h-full flex-col space-y-4"
|
|
||||||
disabled={form.formState.isSubmitting}
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel required>
|
|
||||||
<Trans>Organisation Name</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
{IS_BILLING_ENABLED() ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => setStep('billing')}
|
|
||||||
>
|
|
||||||
<Trans>Back</Trans>
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
data-testid="dialog-create-organisation-button"
|
|
||||||
loading={form.formState.isSubmitting}
|
|
||||||
>
|
|
||||||
{selectedPriceId ? <Trans>Checkout</Trans> : <Trans>Create</Trans>}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</>
|
|
||||||
))
|
|
||||||
|
|
||||||
.exhaustive()}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// This is separated from the internal claims constant because we need to use the msg
|
|
||||||
// macro which would cause import issues.
|
|
||||||
const internalClaimsDescription: {
|
|
||||||
[key in INTERNAL_CLAIM_ID]: MessageDescriptor | string;
|
|
||||||
} = {
|
|
||||||
[INTERNAL_CLAIM_ID.FREE]: msg`5 Documents a month`,
|
|
||||||
[INTERNAL_CLAIM_ID.INDIVIDUAL]: msg`Unlimited documents, API and more`,
|
|
||||||
[INTERNAL_CLAIM_ID.TEAM]: msg`Embedding, 5 members included and more`,
|
|
||||||
[INTERNAL_CLAIM_ID.PLATFORM]: msg`Whitelabeling, unlimited members and more`,
|
|
||||||
[INTERNAL_CLAIM_ID.ENTERPRISE]: '',
|
|
||||||
[INTERNAL_CLAIM_ID.EARLY_ADOPTER]: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
type BillingPlanFormProps = {
|
|
||||||
value: string;
|
|
||||||
onChange: (priceId: string) => void;
|
|
||||||
plans: InternalClaimPlans;
|
|
||||||
canCreateFreeOrganisation: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const BillingPlanForm = ({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
plans,
|
|
||||||
canCreateFreeOrganisation,
|
|
||||||
}: BillingPlanFormProps) => {
|
|
||||||
const { t } = useLingui();
|
|
||||||
|
|
||||||
const [billingPeriod, setBillingPeriod] = useState<'monthlyPrice' | 'yearlyPrice'>('yearlyPrice');
|
|
||||||
|
|
||||||
const dynamicPlans = useMemo(() => {
|
|
||||||
return [INTERNAL_CLAIM_ID.INDIVIDUAL, INTERNAL_CLAIM_ID.TEAM, INTERNAL_CLAIM_ID.PLATFORM].map(
|
|
||||||
(planId) => {
|
|
||||||
const plan = plans[planId];
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: planId,
|
|
||||||
name: plan.name,
|
|
||||||
description: parseMessageDescriptorMacro(t, internalClaimsDescription[planId]),
|
|
||||||
monthlyPrice: plan.monthlyPrice,
|
|
||||||
yearlyPrice: plan.yearlyPrice,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}, [plans]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (value === '' && !canCreateFreeOrganisation) {
|
|
||||||
onChange(dynamicPlans[0][billingPeriod]?.id ?? '');
|
|
||||||
}
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
const onBillingPeriodChange = (billingPeriod: 'monthlyPrice' | 'yearlyPrice') => {
|
|
||||||
const plan = dynamicPlans.find((plan) => plan[billingPeriod]?.id === value);
|
|
||||||
|
|
||||||
setBillingPeriod(billingPeriod);
|
|
||||||
|
|
||||||
onChange(plan?.[billingPeriod]?.id ?? Object.keys(plans)[0]);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Tabs
|
|
||||||
className="flex w-full items-center justify-center"
|
|
||||||
defaultValue="monthlyPrice"
|
|
||||||
value={billingPeriod}
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
||||||
onValueChange={(value) => onBillingPeriodChange(value as 'monthlyPrice' | 'yearlyPrice')}
|
|
||||||
>
|
|
||||||
<TabsList className="flex w-full justify-center">
|
|
||||||
<TabsTrigger className="w-full" value="monthlyPrice">
|
|
||||||
<Trans>Monthly</Trans>
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger className="w-full" value="yearlyPrice">
|
|
||||||
<Trans>Yearly</Trans>
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
<div className="mt-4 grid gap-4 text-sm">
|
|
||||||
<button
|
|
||||||
onClick={() => onChange('')}
|
|
||||||
className={cn(
|
|
||||||
'hover:border-primary flex cursor-pointer items-center space-x-2 rounded-md border p-4 transition-all hover:shadow-sm',
|
|
||||||
{
|
|
||||||
'ring-primary/10 border-primary ring-2 ring-offset-1': '' === value,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
disabled={!canCreateFreeOrganisation}
|
|
||||||
>
|
|
||||||
<div className="w-full text-left">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-medium">
|
|
||||||
<Trans>Free</Trans>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Badge size="small" variant="neutral" className="ml-1.5">
|
|
||||||
{canCreateFreeOrganisation ? (
|
|
||||||
<Trans>1 Free organisations left</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>0 Free organisations left</Trans>
|
|
||||||
)}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-muted-foreground">
|
|
||||||
<Trans>5 documents a month</Trans>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{dynamicPlans.map((plan) => (
|
|
||||||
<button
|
|
||||||
key={plan[billingPeriod]?.id}
|
|
||||||
onClick={() => onChange(plan[billingPeriod]?.id ?? '')}
|
|
||||||
className={cn(
|
|
||||||
'hover:border-primary flex cursor-pointer items-center space-x-2 rounded-md border p-4 transition-all hover:shadow-sm',
|
|
||||||
{
|
|
||||||
'ring-primary/10 border-primary ring-2 ring-offset-1':
|
|
||||||
plan[billingPeriod]?.id === value,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="w-full text-left">
|
|
||||||
<p className="font-medium">{plan.name}</p>
|
|
||||||
<p className="text-muted-foreground">{plan.description}</p>
|
|
||||||
</div>
|
|
||||||
<div className="whitespace-nowrap text-right text-sm font-medium">
|
|
||||||
<p>{plan[billingPeriod]?.friendlyPrice}</p>
|
|
||||||
<span className="text-muted-foreground text-xs">
|
|
||||||
{billingPeriod === 'monthlyPrice' ? (
|
|
||||||
<Trans>per month</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>per year</Trans>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<Link
|
|
||||||
to="https://documen.so/enterprise-cta"
|
|
||||||
target="_blank"
|
|
||||||
className="bg-muted/30 flex items-center space-x-2 rounded-md border p-4"
|
|
||||||
>
|
|
||||||
<div className="flex-1 font-normal">
|
|
||||||
<p className="text-muted-foreground font-medium">
|
|
||||||
<Trans>Enterprise</Trans>
|
|
||||||
</p>
|
|
||||||
<p className="text-muted-foreground flex flex-row items-center gap-1">
|
|
||||||
<Trans>Contact sales here</Trans>
|
|
||||||
<ExternalLinkIcon className="h-4 w-4" />
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 text-center">
|
|
||||||
<Link
|
|
||||||
to="https://documenso.com/pricing"
|
|
||||||
className="text-primary hover:text-primary/80 flex items-center justify-center gap-1 text-sm hover:underline"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<Trans>Compare all plans and features in detail</Trans>
|
|
||||||
<ExternalLinkIcon className="h-4 w-4" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,166 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { useNavigate } from 'react-router';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
|
||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
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 { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
export type OrganisationDeleteDialogProps = {
|
|
||||||
trigger?: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const OrganisationDeleteDialog = ({ trigger }: OrganisationDeleteDialogProps) => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { refreshSession } = useSession();
|
|
||||||
|
|
||||||
const organisation = useCurrentOrganisation();
|
|
||||||
|
|
||||||
const deleteMessage = _(msg`delete ${organisation.name}`);
|
|
||||||
|
|
||||||
const ZDeleteOrganisationFormSchema = z.object({
|
|
||||||
organisationName: z.literal(deleteMessage, {
|
|
||||||
errorMap: () => ({ message: _(msg`You must enter '${deleteMessage}' to proceed`) }),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
resolver: zodResolver(ZDeleteOrganisationFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
organisationName: '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutateAsync: deleteOrganisation } = trpc.organisation.delete.useMutation();
|
|
||||||
|
|
||||||
const onFormSubmit = async () => {
|
|
||||||
try {
|
|
||||||
await deleteOrganisation({ organisationId: organisation.id });
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`Success`),
|
|
||||||
description: _(msg`Your organisation has been successfully deleted.`),
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
await navigate('/settings/organisations');
|
|
||||||
await refreshSession();
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
} catch (err) {
|
|
||||||
const error = AppError.parseError(err);
|
|
||||||
console.error(error);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`An unknown error occurred`),
|
|
||||||
description: _(
|
|
||||||
msg`We encountered an unknown error while attempting to delete this organisation. Please try again later.`,
|
|
||||||
),
|
|
||||||
variant: 'destructive',
|
|
||||||
duration: 10000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
form.reset();
|
|
||||||
}
|
|
||||||
}, [open, form]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
{trigger ?? (
|
|
||||||
<Button variant="destructive">
|
|
||||||
<Trans>Delete</Trans>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent position="center">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Are you sure you wish to delete this organisation?</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription>
|
|
||||||
<Trans>
|
|
||||||
You are about to delete <span className="font-semibold">{organisation.name}</span>.
|
|
||||||
All data related to this organisation such as teams, documents, and all other
|
|
||||||
resources will be deleted. This action is irreversible.
|
|
||||||
</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
|
||||||
<fieldset
|
|
||||||
className="flex h-full flex-col space-y-4"
|
|
||||||
disabled={form.formState.isSubmitting}
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="organisationName"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>
|
|
||||||
Confirm by typing <span className="text-destructive">{deleteMessage}</span>
|
|
||||||
</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input className="bg-background" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button type="submit" variant="destructive" loading={form.formState.isSubmitting}>
|
|
||||||
<Trans>Delete</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,251 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { useLingui } from '@lingui/react/macro';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { OrganisationMemberRole } from '@prisma/client';
|
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import type { z } from 'zod';
|
|
||||||
|
|
||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
|
||||||
import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/organisations';
|
|
||||||
import { EXTENDED_ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
|
|
||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { ZCreateOrganisationGroupRequestSchema } from '@documenso/trpc/server/organisation-router/create-organisation-group.types';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@documenso/ui/primitives/select';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
export type OrganisationGroupCreateDialogProps = {
|
|
||||||
trigger?: React.ReactNode;
|
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
|
||||||
|
|
||||||
const ZCreateOrganisationGroupFormSchema = ZCreateOrganisationGroupRequestSchema.pick({
|
|
||||||
name: true,
|
|
||||||
memberIds: true,
|
|
||||||
organisationRole: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
type TCreateOrganisationGroupFormSchema = z.infer<typeof ZCreateOrganisationGroupFormSchema>;
|
|
||||||
|
|
||||||
export const OrganisationGroupCreateDialog = ({
|
|
||||||
trigger,
|
|
||||||
...props
|
|
||||||
}: OrganisationGroupCreateDialogProps) => {
|
|
||||||
const { t } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const organisation = useCurrentOrganisation();
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
resolver: zodResolver(ZCreateOrganisationGroupFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
name: '',
|
|
||||||
organisationRole: OrganisationMemberRole.MEMBER,
|
|
||||||
memberIds: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutateAsync: createOrganisationGroup } = trpc.organisation.group.create.useMutation();
|
|
||||||
|
|
||||||
const { data: membersFindResult, isLoading: isLoadingMembers } =
|
|
||||||
trpc.organisation.member.find.useQuery({
|
|
||||||
organisationId: organisation.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const members = membersFindResult?.data ?? [];
|
|
||||||
|
|
||||||
const onFormSubmit = async ({
|
|
||||||
name,
|
|
||||||
organisationRole,
|
|
||||||
memberIds,
|
|
||||||
}: TCreateOrganisationGroupFormSchema) => {
|
|
||||||
try {
|
|
||||||
await createOrganisationGroup({
|
|
||||||
organisationId: organisation.id,
|
|
||||||
name,
|
|
||||||
organisationRole,
|
|
||||||
memberIds,
|
|
||||||
});
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: t`Success`,
|
|
||||||
description: t`Group has been created.`,
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
const error = AppError.parseError(err);
|
|
||||||
|
|
||||||
console.error(error);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: t`An unknown error occurred`,
|
|
||||||
description: t`We encountered an unknown error while attempting to create a group. Please try again later.`,
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
form.reset();
|
|
||||||
}, [open, form]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
{...props}
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
|
||||||
>
|
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
|
||||||
{trigger ?? (
|
|
||||||
<Button className="flex-shrink-0" variant="secondary">
|
|
||||||
<Trans>Create group</Trans>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent position="center">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Create group</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription>
|
|
||||||
<Trans>Organise your members into groups which can be assigned to teams</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
|
||||||
<fieldset
|
|
||||||
className="flex h-full flex-col space-y-4"
|
|
||||||
disabled={form.formState.isSubmitting}
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel required>
|
|
||||||
<Trans>Group Name</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input className="bg-background" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="organisationRole"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel required>
|
|
||||||
<Trans>Organisation role</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Select {...field} onValueChange={field.onChange}>
|
|
||||||
<SelectTrigger className="text-muted-foreground w-full">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
|
|
||||||
<SelectContent position="popper">
|
|
||||||
{ORGANISATION_MEMBER_ROLE_HIERARCHY[
|
|
||||||
organisation.currentOrganisationRole
|
|
||||||
].map((role) => (
|
|
||||||
<SelectItem key={role} value={role}>
|
|
||||||
{t(EXTENDED_ORGANISATION_MEMBER_ROLE_MAP[role]) ?? role}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="memberIds"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Members</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
|
|
||||||
<FormControl>
|
|
||||||
<MultiSelectCombobox
|
|
||||||
options={members.map((member) => ({
|
|
||||||
label: member.name,
|
|
||||||
value: member.id,
|
|
||||||
}))}
|
|
||||||
loading={isLoadingMembers}
|
|
||||||
selectedValues={field.value}
|
|
||||||
onChange={field.onChange}
|
|
||||||
className="bg-background w-full"
|
|
||||||
emptySelectionPlaceholder={t`Select members`}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormDescription>
|
|
||||||
<Trans>Select the members to add to this group</Trans>
|
|
||||||
</FormDescription>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
data-testid="dialog-create-organisation-button"
|
|
||||||
loading={form.formState.isSubmitting}
|
|
||||||
>
|
|
||||||
<Trans>Create</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,117 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
|
|
||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
|
||||||
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 OrganisationGroupDeleteDialogProps = {
|
|
||||||
organisationGroupId: string;
|
|
||||||
organisationGroupName: string;
|
|
||||||
trigger?: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const OrganisationGroupDeleteDialog = ({
|
|
||||||
trigger,
|
|
||||||
organisationGroupId,
|
|
||||||
organisationGroupName,
|
|
||||||
}: OrganisationGroupDeleteDialogProps) => {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const organisation = useCurrentOrganisation();
|
|
||||||
|
|
||||||
const { mutateAsync: deleteGroup, isPending: isDeleting } =
|
|
||||||
trpc.organisation.group.delete.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Success`),
|
|
||||||
description: _(msg`You have successfully removed this group from the organisation.`),
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast({
|
|
||||||
title: _(msg`An unknown error occurred`),
|
|
||||||
description: _(
|
|
||||||
msg`We encountered an unknown error while attempting to remove this group. Please try again later.`,
|
|
||||||
),
|
|
||||||
variant: 'destructive',
|
|
||||||
duration: 10000,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={(value) => !isDeleting && setOpen(value)}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
{trigger ?? (
|
|
||||||
<Button variant="secondary">
|
|
||||||
<Trans>Delete organisation group</Trans>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent position="center">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Are you sure?</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription className="mt-4">
|
|
||||||
<Trans>
|
|
||||||
You are about to remove the following group from{' '}
|
|
||||||
<span className="font-semibold">{organisation.name}</span>.
|
|
||||||
</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Alert variant="neutral">
|
|
||||||
<AlertDescription className="text-center font-semibold">
|
|
||||||
{organisationGroupName}
|
|
||||||
</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 () =>
|
|
||||||
deleteGroup({
|
|
||||||
organisationId: organisation.id,
|
|
||||||
groupId: organisationGroupId,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Trans>Delete</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</fieldset>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,115 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { useLingui } from '@lingui/react/macro';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import type { OrganisationMemberRole } from '@prisma/client';
|
|
||||||
|
|
||||||
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
|
|
||||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Alert } from '@documenso/ui/primitives/alert';
|
|
||||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
|
||||||
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 OrganisationLeaveDialogProps = {
|
|
||||||
organisationId: string;
|
|
||||||
organisationName: string;
|
|
||||||
organisationAvatarImageId?: string | null;
|
|
||||||
role: OrganisationMemberRole;
|
|
||||||
trigger?: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const OrganisationLeaveDialog = ({
|
|
||||||
trigger,
|
|
||||||
organisationId,
|
|
||||||
organisationName,
|
|
||||||
organisationAvatarImageId,
|
|
||||||
role,
|
|
||||||
}: OrganisationLeaveDialogProps) => {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const { t } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const { mutateAsync: leaveOrganisation, isPending: isLeavingOrganisation } =
|
|
||||||
trpc.organisation.leave.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({
|
|
||||||
title: t`Success`,
|
|
||||||
description: t`You have successfully left this organisation.`,
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast({
|
|
||||||
title: t`An unknown error occurred`,
|
|
||||||
description: t`We encountered an unknown error while attempting to leave this organisation. Please try again later.`,
|
|
||||||
variant: 'destructive',
|
|
||||||
duration: 10000,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={(value) => !isLeavingOrganisation && setOpen(value)}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
{trigger ?? (
|
|
||||||
<Button variant="destructive">
|
|
||||||
<Trans>Leave organisation</Trans>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent position="center">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Are you sure?</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription className="mt-4">
|
|
||||||
<Trans>You are about to leave the following organisation.</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Alert variant="neutral" padding="tight">
|
|
||||||
<AvatarWithText
|
|
||||||
avatarClass="h-12 w-12"
|
|
||||||
avatarSrc={formatAvatarUrl(organisationAvatarImageId)}
|
|
||||||
avatarFallback={organisationName.slice(0, 1).toUpperCase()}
|
|
||||||
primaryText={organisationName}
|
|
||||||
secondaryText={t(ORGANISATION_MEMBER_ROLE_MAP[role])}
|
|
||||||
/>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<fieldset disabled={isLeavingOrganisation}>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="destructive"
|
|
||||||
loading={isLeavingOrganisation}
|
|
||||||
onClick={async () => leaveOrganisation({ organisationId })}
|
|
||||||
>
|
|
||||||
<Trans>Leave</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</fieldset>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,123 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
|
|
||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Alert } from '@documenso/ui/primitives/alert';
|
|
||||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
|
||||||
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 OrganisationMemberDeleteDialogProps = {
|
|
||||||
organisationMemberId: string;
|
|
||||||
organisationMemberName: string;
|
|
||||||
organisationMemberEmail: string;
|
|
||||||
trigger?: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const OrganisationMemberDeleteDialog = ({
|
|
||||||
trigger,
|
|
||||||
organisationMemberId,
|
|
||||||
organisationMemberName,
|
|
||||||
organisationMemberEmail,
|
|
||||||
}: OrganisationMemberDeleteDialogProps) => {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const organisation = useCurrentOrganisation();
|
|
||||||
|
|
||||||
const { mutateAsync: deleteOrganisationMembers, isPending: isDeletingOrganisationMember } =
|
|
||||||
trpc.organisation.member.delete.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Success`),
|
|
||||||
description: _(msg`You have successfully removed this user from the organisation.`),
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast({
|
|
||||||
title: _(msg`An unknown error occurred`),
|
|
||||||
description: _(
|
|
||||||
msg`We encountered an unknown error while attempting to remove this user. Please try again later.`,
|
|
||||||
),
|
|
||||||
variant: 'destructive',
|
|
||||||
duration: 10000,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={(value) => !isDeletingOrganisationMember && setOpen(value)}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
{trigger ?? (
|
|
||||||
<Button variant="secondary">
|
|
||||||
<Trans>Delete organisation member</Trans>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent position="center">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Are you sure?</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription className="mt-4">
|
|
||||||
<Trans>
|
|
||||||
You are about to remove the following user from{' '}
|
|
||||||
<span className="font-semibold">{organisation.name}</span>.
|
|
||||||
</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Alert variant="neutral" padding="tight">
|
|
||||||
<AvatarWithText
|
|
||||||
avatarClass="h-12 w-12"
|
|
||||||
avatarFallback={organisationMemberName.slice(0, 1).toUpperCase()}
|
|
||||||
primaryText={<span className="font-semibold">{organisationMemberName}</span>}
|
|
||||||
secondaryText={organisationMemberEmail}
|
|
||||||
/>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<fieldset disabled={isDeletingOrganisationMember}>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="destructive"
|
|
||||||
loading={isDeletingOrganisationMember}
|
|
||||||
onClick={async () =>
|
|
||||||
deleteOrganisationMembers({
|
|
||||||
organisationId: organisation.id,
|
|
||||||
organisationMemberId,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Trans>Delete</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</fieldset>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,478 +0,0 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { OrganisationMemberRole } from '@prisma/client';
|
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
|
||||||
import { Download, Mail, MailIcon, PlusCircle, Trash, Upload, UsersIcon } from 'lucide-react';
|
|
||||||
import Papa, { type ParseResult } from 'papaparse';
|
|
||||||
import { useFieldArray, useForm } from 'react-hook-form';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { downloadFile } from '@documenso/lib/client-only/download-file';
|
|
||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
|
||||||
import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/organisations';
|
|
||||||
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
|
|
||||||
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { ZCreateOrganisationMemberInvitesRequestSchema } from '@documenso/trpc/server/organisation-router/create-organisation-member-invites.types';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
|
||||||
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 { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@documenso/ui/primitives/select';
|
|
||||||
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
export type OrganisationMemberInviteDialogProps = {
|
|
||||||
trigger?: React.ReactNode;
|
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
|
||||||
|
|
||||||
const ZInviteOrganisationMembersFormSchema = z
|
|
||||||
.object({
|
|
||||||
invitations: ZCreateOrganisationMemberInvitesRequestSchema.shape.invitations,
|
|
||||||
})
|
|
||||||
// Display exactly which rows are duplicates.
|
|
||||||
.superRefine((items, ctx) => {
|
|
||||||
const uniqueEmails = new Map<string, number>();
|
|
||||||
|
|
||||||
for (const [index, invitation] of items.invitations.entries()) {
|
|
||||||
const email = invitation.email.toLowerCase();
|
|
||||||
|
|
||||||
const firstFoundIndex = uniqueEmails.get(email);
|
|
||||||
|
|
||||||
if (firstFoundIndex === undefined) {
|
|
||||||
uniqueEmails.set(email, index);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: 'Emails must be unique',
|
|
||||||
path: ['invitations', index, 'email'],
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: 'Emails must be unique',
|
|
||||||
path: ['invitations', firstFoundIndex, 'email'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
type TInviteOrganisationMembersFormSchema = z.infer<typeof ZInviteOrganisationMembersFormSchema>;
|
|
||||||
|
|
||||||
type TabTypes = 'INDIVIDUAL' | 'BULK';
|
|
||||||
|
|
||||||
const ZImportOrganisationMemberSchema = z.array(
|
|
||||||
z.object({
|
|
||||||
email: z.string().email(),
|
|
||||||
organisationRole: z.nativeEnum(OrganisationMemberRole),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const OrganisationMemberInviteDialog = ({
|
|
||||||
trigger,
|
|
||||||
...props
|
|
||||||
}: OrganisationMemberInviteDialogProps) => {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const [invitationType, setInvitationType] = useState<TabTypes>('INDIVIDUAL');
|
|
||||||
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const organisation = useCurrentOrganisation();
|
|
||||||
|
|
||||||
const form = useForm<TInviteOrganisationMembersFormSchema>({
|
|
||||||
resolver: zodResolver(ZInviteOrganisationMembersFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
invitations: [
|
|
||||||
{
|
|
||||||
email: '',
|
|
||||||
organisationRole: OrganisationMemberRole.MEMBER,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
append: appendOrganisationMemberInvite,
|
|
||||||
fields: organisationMemberInvites,
|
|
||||||
remove: removeOrganisationMemberInvite,
|
|
||||||
} = useFieldArray({
|
|
||||||
control: form.control,
|
|
||||||
name: 'invitations',
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutateAsync: createOrganisationMemberInvites } =
|
|
||||||
trpc.organisation.member.invite.createMany.useMutation();
|
|
||||||
|
|
||||||
const { data: fullOrganisation } = trpc.organisation.get.useQuery({
|
|
||||||
organisationReference: organisation.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const onAddOrganisationMemberInvite = () => {
|
|
||||||
appendOrganisationMemberInvite({
|
|
||||||
email: '',
|
|
||||||
organisationRole: OrganisationMemberRole.MEMBER,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onFormSubmit = async ({ invitations }: TInviteOrganisationMembersFormSchema) => {
|
|
||||||
try {
|
|
||||||
await createOrganisationMemberInvites({
|
|
||||||
organisationId: organisation.id,
|
|
||||||
invitations,
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`Success`),
|
|
||||||
description: _(msg`Organisation invitations have been sent.`),
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
} catch {
|
|
||||||
toast({
|
|
||||||
title: _(msg`An unknown error occurred`),
|
|
||||||
description: _(
|
|
||||||
msg`We encountered an unknown error while attempting to invite organisation members. Please try again later.`,
|
|
||||||
),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const dialogState = useMemo(() => {
|
|
||||||
if (!fullOrganisation) {
|
|
||||||
return 'loading';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!IS_BILLING_ENABLED()) {
|
|
||||||
return 'form';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fullOrganisation.organisationClaim.memberCount === 0) {
|
|
||||||
return 'form';
|
|
||||||
}
|
|
||||||
|
|
||||||
// This is probably going to screw us over in the future.
|
|
||||||
if (fullOrganisation.organisationClaim.originalSubscriptionClaimId !== INTERNAL_CLAIM_ID.TEAM) {
|
|
||||||
return 'alert';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'form';
|
|
||||||
}, [fullOrganisation]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
form.reset();
|
|
||||||
setInvitationType('INDIVIDUAL');
|
|
||||||
}
|
|
||||||
}, [open, form]);
|
|
||||||
|
|
||||||
const onFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
if (!e.target.files?.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const csvFile = e.target.files[0];
|
|
||||||
|
|
||||||
Papa.parse(csvFile, {
|
|
||||||
skipEmptyLines: true,
|
|
||||||
comments: 'Work email,Job title',
|
|
||||||
complete: (results: ParseResult<string[]>) => {
|
|
||||||
const members = results.data.map((row) => {
|
|
||||||
const [email, role] = row;
|
|
||||||
|
|
||||||
return {
|
|
||||||
email: email.trim(),
|
|
||||||
organisationRole: role.trim().toUpperCase(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Remove the first row if it contains the headers.
|
|
||||||
if (members.length > 1 && members[0].organisationRole.toUpperCase() === 'ROLE') {
|
|
||||||
members.shift();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const importedInvitations = ZImportOrganisationMemberSchema.parse(members);
|
|
||||||
|
|
||||||
form.setValue('invitations', importedInvitations);
|
|
||||||
form.clearErrors('invitations');
|
|
||||||
|
|
||||||
setInvitationType('INDIVIDUAL');
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`Something went wrong`),
|
|
||||||
description: _(
|
|
||||||
msg`Please check the CSV file and make sure it is according to our format`,
|
|
||||||
),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadTemplate = () => {
|
|
||||||
const data = [
|
|
||||||
{ email: 'admin@documenso.com', role: 'Admin' },
|
|
||||||
{ email: 'manager@documenso.com', role: 'Manager' },
|
|
||||||
{ email: 'member@documenso.com', role: 'Member' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const csvContent =
|
|
||||||
'Email address,Role\n' + data.map((row) => `${row.email},${row.role}`).join('\n');
|
|
||||||
|
|
||||||
const blob = new Blob([csvContent], {
|
|
||||||
type: 'text/csv',
|
|
||||||
});
|
|
||||||
|
|
||||||
downloadFile({
|
|
||||||
filename: 'documenso-organisation-member-invites-template.csv',
|
|
||||||
data: blob,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
{...props}
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
|
||||||
>
|
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
|
||||||
{trigger ?? (
|
|
||||||
<Button variant="secondary">
|
|
||||||
<Trans>Invite member</Trans>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent position="center">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Invite organisation members</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription className="mt-4">
|
|
||||||
<Trans>An email containing an invitation will be sent to each member.</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{dialogState === 'loading' && <SpinnerBox className="py-32" />}
|
|
||||||
|
|
||||||
{dialogState === 'alert' && (
|
|
||||||
<>
|
|
||||||
<Alert
|
|
||||||
className="flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
|
||||||
variant="neutral"
|
|
||||||
>
|
|
||||||
<AlertDescription>
|
|
||||||
<Trans>
|
|
||||||
Your plan does not support inviting members. Please upgrade or your plan or
|
|
||||||
contact sales at <a href="mailto:support@documenso.com">support@documenso.com</a>{' '}
|
|
||||||
if you would like to discuss your options.
|
|
||||||
</Trans>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{dialogState === 'form' && (
|
|
||||||
<Tabs
|
|
||||||
defaultValue="INDIVIDUAL"
|
|
||||||
value={invitationType}
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
||||||
onValueChange={(value) => setInvitationType(value as TabTypes)}
|
|
||||||
>
|
|
||||||
<TabsList className="w-full">
|
|
||||||
<TabsTrigger value="INDIVIDUAL" className="hover:text-foreground w-full">
|
|
||||||
<MailIcon size={20} className="mr-2" />
|
|
||||||
<Trans>Invite Members</Trans>
|
|
||||||
</TabsTrigger>
|
|
||||||
|
|
||||||
<TabsTrigger value="BULK" className="hover:text-foreground w-full">
|
|
||||||
<UsersIcon size={20} className="mr-2" /> <Trans>Bulk Import</Trans>
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="INDIVIDUAL">
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
|
||||||
<fieldset
|
|
||||||
className="flex h-full flex-col space-y-4"
|
|
||||||
disabled={form.formState.isSubmitting}
|
|
||||||
>
|
|
||||||
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
|
|
||||||
{organisationMemberInvites.map((organisationMemberInvite, index) => (
|
|
||||||
<div
|
|
||||||
className="flex w-full flex-row space-x-4"
|
|
||||||
key={organisationMemberInvite.id}
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name={`invitations.${index}.email`}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="w-full">
|
|
||||||
{index === 0 && (
|
|
||||||
<FormLabel required>
|
|
||||||
<Trans>Email address</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
)}
|
|
||||||
<FormControl>
|
|
||||||
<Input className="bg-background" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name={`invitations.${index}.organisationRole`}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="w-full">
|
|
||||||
{index === 0 && (
|
|
||||||
<FormLabel required>
|
|
||||||
<Trans>Organisation Role</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
)}
|
|
||||||
<FormControl>
|
|
||||||
<Select {...field} onValueChange={field.onChange}>
|
|
||||||
<SelectTrigger className="text-muted-foreground max-w-[200px]">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
|
|
||||||
<SelectContent position="popper">
|
|
||||||
{ORGANISATION_MEMBER_ROLE_HIERARCHY[
|
|
||||||
organisation.currentOrganisationRole
|
|
||||||
].map((role) => (
|
|
||||||
<SelectItem key={role} value={role}>
|
|
||||||
{_(ORGANISATION_MEMBER_ROLE_MAP[role]) ?? role}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
'justify-left inline-flex h-10 w-10 items-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50',
|
|
||||||
index === 0 ? 'mt-8' : 'mt-0',
|
|
||||||
)}
|
|
||||||
disabled={organisationMemberInvites.length === 1}
|
|
||||||
onClick={() => removeOrganisationMemberInvite(index)}
|
|
||||||
>
|
|
||||||
<Trash className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="w-fit"
|
|
||||||
onClick={() => onAddOrganisationMemberInvite()}
|
|
||||||
>
|
|
||||||
<PlusCircle className="mr-2 h-4 w-4" />
|
|
||||||
<Trans>Add more</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
|
||||||
{!form.formState.isSubmitting && <Mail className="mr-2 h-4 w-4" />}
|
|
||||||
<Trans>Invite</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="BULK">
|
|
||||||
<div className="mt-4 space-y-4">
|
|
||||||
<Card gradient className="h-32">
|
|
||||||
<CardContent
|
|
||||||
className="text-muted-foreground/80 hover:text-muted-foreground/90 flex h-full cursor-pointer flex-col items-center justify-center rounded-lg p-0 transition-colors"
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
>
|
|
||||||
<Upload className="h-5 w-5" />
|
|
||||||
|
|
||||||
<p className="mt-1 text-sm">
|
|
||||||
<Trans>Click here to upload</Trans>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<input
|
|
||||||
onChange={onFileInputChange}
|
|
||||||
type="file"
|
|
||||||
ref={fileInputRef}
|
|
||||||
accept=".csv"
|
|
||||||
hidden
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="secondary" onClick={downloadTemplate}>
|
|
||||||
<Download className="mr-2 h-4 w-4" />
|
|
||||||
<Trans>Template</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,205 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { OrganisationMemberRole } from '@prisma/client';
|
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/organisations';
|
|
||||||
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
|
|
||||||
import { isOrganisationRoleWithinUserHierarchy } from '@documenso/lib/utils/organisations';
|
|
||||||
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 {
|
|
||||||
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 OrganisationMemberUpdateDialogProps = {
|
|
||||||
currentUserOrganisationRole: OrganisationMemberRole;
|
|
||||||
trigger?: React.ReactNode;
|
|
||||||
organisationId: string;
|
|
||||||
organisationMemberId: string;
|
|
||||||
organisationMemberName: string;
|
|
||||||
organisationMemberRole: OrganisationMemberRole;
|
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
|
||||||
|
|
||||||
const ZUpdateOrganisationMemberFormSchema = z.object({
|
|
||||||
role: z.nativeEnum(OrganisationMemberRole),
|
|
||||||
});
|
|
||||||
|
|
||||||
type ZUpdateOrganisationMemberSchema = z.infer<typeof ZUpdateOrganisationMemberFormSchema>;
|
|
||||||
|
|
||||||
export const OrganisationMemberUpdateDialog = ({
|
|
||||||
currentUserOrganisationRole,
|
|
||||||
trigger,
|
|
||||||
organisationId,
|
|
||||||
organisationMemberId,
|
|
||||||
organisationMemberName,
|
|
||||||
organisationMemberRole,
|
|
||||||
...props
|
|
||||||
}: OrganisationMemberUpdateDialogProps) => {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const form = useForm<ZUpdateOrganisationMemberSchema>({
|
|
||||||
resolver: zodResolver(ZUpdateOrganisationMemberFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
role: organisationMemberRole,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutateAsync: updateOrganisationMember } = trpc.organisation.member.update.useMutation();
|
|
||||||
|
|
||||||
const onFormSubmit = async ({ role }: ZUpdateOrganisationMemberSchema) => {
|
|
||||||
try {
|
|
||||||
await updateOrganisationMember({
|
|
||||||
organisationId,
|
|
||||||
organisationMemberId,
|
|
||||||
data: {
|
|
||||||
role,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`Success`),
|
|
||||||
description: _(msg`You have updated ${organisationMemberName}.`),
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
} catch {
|
|
||||||
toast({
|
|
||||||
title: _(msg`An unknown error occurred`),
|
|
||||||
description: _(
|
|
||||||
msg`We encountered an unknown error while attempting to update this organisation member. Please try again later.`,
|
|
||||||
),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<Dialog
|
|
||||||
{...props}
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
|
||||||
>
|
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
|
||||||
{trigger ?? (
|
|
||||||
<Button variant="secondary">
|
|
||||||
<Trans>Update organisation member</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">
|
|
||||||
{ORGANISATION_MEMBER_ROLE_HIERARCHY[currentUserOrganisationRole].map(
|
|
||||||
(role) => (
|
|
||||||
<SelectItem key={role} value={role}>
|
|
||||||
{_(ORGANISATION_MEMBER_ROLE_MAP[role]) ?? role}
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -49,7 +49,7 @@ import {
|
|||||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export type ManagePublicTemplateDialogProps = {
|
export type ManagePublicTemplateDialogProps = {
|
||||||
directTemplates: (Template & {
|
directTemplates: (Template & {
|
||||||
@ -95,7 +95,7 @@ export const ManagePublicTemplateDialog = ({
|
|||||||
|
|
||||||
const [open, onOpenChange] = useState(isOpen);
|
const [open, onOpenChange] = useState(isOpen);
|
||||||
|
|
||||||
const team = useCurrentTeam();
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
const [selectedTemplateId, setSelectedTemplateId] = useState<number | null>(initialTemplateId);
|
const [selectedTemplateId, setSelectedTemplateId] = useState<number | null>(initialTemplateId);
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,188 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
import { Loader, TagIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type TeamCheckoutCreateDialogProps = {
|
||||||
|
pendingTeamId: number | null;
|
||||||
|
onClose: () => void;
|
||||||
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
|
const MotionCard = motion(Card);
|
||||||
|
|
||||||
|
export const TeamCheckoutCreateDialog = ({
|
||||||
|
pendingTeamId,
|
||||||
|
onClose,
|
||||||
|
...props
|
||||||
|
}: TeamCheckoutCreateDialogProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [interval, setInterval] = useState<'monthly' | 'yearly'>('monthly');
|
||||||
|
|
||||||
|
const { data, isLoading } = trpc.team.getTeamPrices.useQuery();
|
||||||
|
|
||||||
|
const { mutateAsync: createCheckout, isPending: isCreatingCheckout } =
|
||||||
|
trpc.team.createTeamPendingCheckout.useMutation({
|
||||||
|
onSuccess: (checkoutUrl) => {
|
||||||
|
window.open(checkoutUrl, '_blank');
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: () =>
|
||||||
|
toast({
|
||||||
|
title: _(msg`Something went wrong`),
|
||||||
|
description: _(
|
||||||
|
msg`We were unable to create a checkout session. Please try again, or contact support`,
|
||||||
|
),
|
||||||
|
variant: 'destructive',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedPrice = useMemo(() => {
|
||||||
|
if (!data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data[interval];
|
||||||
|
}, [data, interval]);
|
||||||
|
|
||||||
|
const handleOnOpenChange = (open: boolean) => {
|
||||||
|
if (pendingTeamId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (pendingTeamId === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog {...props} open={pendingTeamId !== null} onOpenChange={handleOnOpenChange}>
|
||||||
|
<DialogContent position="center">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Team checkout</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription className="mt-4">
|
||||||
|
<Trans>Payment is required to finalise the creation of your team.</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{(isLoading || !data) && (
|
||||||
|
<div className="flex h-20 items-center justify-center text-sm">
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader className="text-documenso h-6 w-6 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<p>
|
||||||
|
<Trans>Something went wrong</Trans>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data && selectedPrice && !isLoading && (
|
||||||
|
<div>
|
||||||
|
<Tabs
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
onValueChange={(value) => setInterval(value as 'monthly' | 'yearly')}
|
||||||
|
value={interval}
|
||||||
|
className="mb-4"
|
||||||
|
>
|
||||||
|
<TabsList className="w-full">
|
||||||
|
{[data.monthly, data.yearly].map((price) => (
|
||||||
|
<TabsTrigger key={price.priceId} className="w-full" value={price.interval}>
|
||||||
|
{price.friendlyInterval}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<MotionCard
|
||||||
|
key={selectedPrice.priceId}
|
||||||
|
initial={{ opacity: 0, y: 15 }}
|
||||||
|
animate={{ opacity: 1, y: 0, transition: { duration: 0.3 } }}
|
||||||
|
exit={{ opacity: 0, transition: { duration: 0.15 } }}
|
||||||
|
>
|
||||||
|
<CardContent className="flex h-full flex-col p-6">
|
||||||
|
{selectedPrice.interval === 'monthly' ? (
|
||||||
|
<div className="text-muted-foreground text-lg font-medium">
|
||||||
|
$50 USD <span className="text-xs">per month</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground flex items-center justify-between text-lg font-medium">
|
||||||
|
<span>
|
||||||
|
$480 USD <span className="text-xs">per year</span>
|
||||||
|
</span>
|
||||||
|
<div className="bg-primary text-primary-foreground ml-2 inline-flex flex-row items-center justify-center rounded px-2 py-1 text-xs">
|
||||||
|
<TagIcon className="mr-1 h-4 w-4" />
|
||||||
|
20% off
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-muted-foreground mt-1.5 text-sm">
|
||||||
|
<p>
|
||||||
|
<Trans>This price includes minimum 5 seats.</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="mt-1">
|
||||||
|
<Trans>Adding and removing seats will adjust your invoice accordingly.</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</MotionCard>
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<DialogFooter className="mt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={isCreatingCheckout}
|
||||||
|
onClick={() => onClose()}
|
||||||
|
>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
loading={isCreatingCheckout}
|
||||||
|
onClick={async () =>
|
||||||
|
createCheckout({
|
||||||
|
interval: selectedPrice.interval,
|
||||||
|
pendingTeamId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trans>Checkout</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, 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';
|
||||||
@ -7,18 +7,15 @@ import { Trans } from '@lingui/react/macro';
|
|||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { useSearchParams } from 'react-router';
|
import { useSearchParams } from 'react-router';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
|
||||||
import { IS_BILLING_ENABLED, NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
|
||||||
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 { ZCreateTeamRequestSchema } from '@documenso/trpc/server/team-router/create-team.types';
|
import { ZCreateTeamMutationSchema } from '@documenso/trpc/server/team-router/schema';
|
||||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@ -37,37 +34,29 @@ 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 { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
export type TeamCreateDialogProps = {
|
export type TeamCreateDialogProps = {
|
||||||
trigger?: React.ReactNode;
|
trigger?: React.ReactNode;
|
||||||
onCreated?: () => Promise<void>;
|
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
const ZCreateTeamFormSchema = ZCreateTeamRequestSchema.pick({
|
const ZCreateTeamFormSchema = ZCreateTeamMutationSchema.pick({
|
||||||
teamName: true,
|
teamName: true,
|
||||||
teamUrl: true,
|
teamUrl: true,
|
||||||
inheritMembers: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type TCreateTeamFormSchema = z.infer<typeof ZCreateTeamFormSchema>;
|
type TCreateTeamFormSchema = z.infer<typeof ZCreateTeamFormSchema>;
|
||||||
|
|
||||||
export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDialogProps) => {
|
export const TeamCreateDialog = ({ trigger, ...props }: TeamCreateDialogProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { refreshSession } = useSession();
|
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
const organisation = useCurrentOrganisation();
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const { data: fullOrganisation } = trpc.organisation.get.useQuery({
|
|
||||||
organisationReference: organisation.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const actionSearchParam = searchParams?.get('action');
|
const actionSearchParam = searchParams?.get('action');
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
@ -75,25 +64,24 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
teamName: '',
|
teamName: '',
|
||||||
teamUrl: '',
|
teamUrl: '',
|
||||||
inheritMembers: true,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: createTeam } = trpc.team.create.useMutation();
|
const { mutateAsync: createTeam } = trpc.team.createTeam.useMutation();
|
||||||
|
|
||||||
const onFormSubmit = async ({ teamName, teamUrl, inheritMembers }: TCreateTeamFormSchema) => {
|
const onFormSubmit = async ({ teamName, teamUrl }: TCreateTeamFormSchema) => {
|
||||||
try {
|
try {
|
||||||
await createTeam({
|
const response = await createTeam({
|
||||||
organisationId: organisation.id,
|
|
||||||
teamName,
|
teamName,
|
||||||
teamUrl,
|
teamUrl,
|
||||||
inheritMembers,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
|
||||||
await onCreated?.();
|
if (response.paymentRequired) {
|
||||||
await refreshSession();
|
await navigate(`/settings/teams?tab=pending&checkout=${response.pendingTeamId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Success`),
|
title: _(msg`Success`),
|
||||||
@ -126,26 +114,6 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
|
|||||||
return text.toLowerCase().replace(/\s+/g, '-');
|
return text.toLowerCase().replace(/\s+/g, '-');
|
||||||
};
|
};
|
||||||
|
|
||||||
const dialogState = useMemo(() => {
|
|
||||||
if (!fullOrganisation) {
|
|
||||||
return 'loading';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!IS_BILLING_ENABLED()) {
|
|
||||||
return 'form';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fullOrganisation.organisationClaim.teamCount === 0) {
|
|
||||||
return 'form';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fullOrganisation.organisationClaim.teamCount <= fullOrganisation.teams.length) {
|
|
||||||
return 'alert';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'form';
|
|
||||||
}, [fullOrganisation]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (actionSearchParam === 'add-team') {
|
if (actionSearchParam === 'add-team') {
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
@ -177,141 +145,89 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
|
|||||||
<Trans>Create team</Trans>
|
<Trans>Create team</Trans>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|
||||||
<DialogDescription>
|
<DialogDescription className="mt-4">
|
||||||
<Trans>Create a team to collaborate with your team members.</Trans>
|
<Trans>Create a team to collaborate with your team members.</Trans>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{dialogState === 'loading' && <SpinnerBox className="py-32" />}
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
{dialogState === 'alert' && (
|
<fieldset
|
||||||
<>
|
className="flex h-full flex-col space-y-4"
|
||||||
<Alert
|
disabled={form.formState.isSubmitting}
|
||||||
className="flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
|
||||||
variant="neutral"
|
|
||||||
>
|
>
|
||||||
<AlertDescription className="mt-0">
|
<FormField
|
||||||
<Trans>
|
control={form.control}
|
||||||
You have reached the maximum number of teams for your plan. Please contact sales
|
name="teamName"
|
||||||
at <a href="mailto:support@documenso.com">support@documenso.com</a> if you would
|
render={({ field }) => (
|
||||||
like to adjust your plan.
|
<FormItem>
|
||||||
</Trans>
|
<FormLabel required>
|
||||||
</AlertDescription>
|
<Trans>Team Name</Trans>
|
||||||
</Alert>
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className="bg-background"
|
||||||
|
{...field}
|
||||||
|
onChange={(event) => {
|
||||||
|
const oldGeneratedUrl = mapTextToUrl(field.value);
|
||||||
|
const newGeneratedUrl = mapTextToUrl(event.target.value);
|
||||||
|
|
||||||
<DialogFooter>
|
const urlField = form.getValues('teamUrl');
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
if (urlField === oldGeneratedUrl) {
|
||||||
<Trans>Cancel</Trans>
|
form.setValue('teamUrl', newGeneratedUrl);
|
||||||
</Button>
|
}
|
||||||
</DialogFooter>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{dialogState === 'form' && (
|
field.onChange(event);
|
||||||
<Form {...form}>
|
}}
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
/>
|
||||||
<fieldset
|
</FormControl>
|
||||||
className="flex h-full flex-col space-y-4"
|
<FormMessage />
|
||||||
disabled={form.formState.isSubmitting}
|
</FormItem>
|
||||||
>
|
)}
|
||||||
<FormField
|
/>
|
||||||
control={form.control}
|
|
||||||
name="teamName"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel required>
|
|
||||||
<Trans>Team Name</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
className="bg-background"
|
|
||||||
{...field}
|
|
||||||
onChange={(event) => {
|
|
||||||
const oldGeneratedUrl = mapTextToUrl(field.value);
|
|
||||||
const newGeneratedUrl = mapTextToUrl(event.target.value);
|
|
||||||
|
|
||||||
const urlField = form.getValues('teamUrl');
|
<FormField
|
||||||
if (urlField === oldGeneratedUrl) {
|
control={form.control}
|
||||||
form.setValue('teamUrl', newGeneratedUrl);
|
name="teamUrl"
|
||||||
}
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel required>
|
||||||
|
<Trans>Team URL</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input className="bg-background" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
{!form.formState.errors.teamUrl && (
|
||||||
|
<span className="text-foreground/50 text-xs font-normal">
|
||||||
|
{field.value ? (
|
||||||
|
`${NEXT_PUBLIC_WEBAPP_URL()}/t/${field.value}`
|
||||||
|
) : (
|
||||||
|
<Trans>A unique URL to identify your team</Trans>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
field.onChange(event);
|
<FormMessage />
|
||||||
}}
|
</FormItem>
|
||||||
/>
|
)}
|
||||||
</FormControl>
|
/>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
<DialogFooter>
|
||||||
control={form.control}
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
name="teamUrl"
|
<Trans>Cancel</Trans>
|
||||||
render={({ field }) => (
|
</Button>
|
||||||
<FormItem>
|
|
||||||
<FormLabel required>
|
|
||||||
<Trans>Team URL</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input className="bg-background" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
{!form.formState.errors.teamUrl && (
|
|
||||||
<span className="text-foreground/50 text-xs font-normal">
|
|
||||||
{field.value ? (
|
|
||||||
`${NEXT_PUBLIC_WEBAPP_URL()}/t/${field.value}`
|
|
||||||
) : (
|
|
||||||
<Trans>A unique URL to identify your team</Trans>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<FormMessage />
|
<Button
|
||||||
</FormItem>
|
type="submit"
|
||||||
)}
|
data-testid="dialog-create-team-button"
|
||||||
/>
|
loading={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
<FormField
|
<Trans>Create Team</Trans>
|
||||||
control={form.control}
|
</Button>
|
||||||
name="inheritMembers"
|
</DialogFooter>
|
||||||
render={({ field }) => (
|
</fieldset>
|
||||||
<FormItem className="flex items-center space-x-2">
|
</form>
|
||||||
<FormControl>
|
</Form>
|
||||||
<div className="flex items-center">
|
|
||||||
<Checkbox
|
|
||||||
id="inherit-members"
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<label
|
|
||||||
className="text-muted-foreground ml-2 text-sm"
|
|
||||||
htmlFor="inherit-members"
|
|
||||||
>
|
|
||||||
<Trans>Allow all organisation members to access this team</Trans>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
data-testid="dialog-create-team-button"
|
|
||||||
loading={form.formState.isSubmitting}
|
|
||||||
>
|
|
||||||
<Trans>Create Team</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import { useForm } from 'react-hook-form';
|
|||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
|
||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -36,22 +35,15 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
export type TeamDeleteDialogProps = {
|
export type TeamDeleteDialogProps = {
|
||||||
teamId: number;
|
teamId: number;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
redirectTo?: string;
|
|
||||||
trigger?: React.ReactNode;
|
trigger?: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TeamDeleteDialog = ({
|
export const TeamDeleteDialog = ({ trigger, teamId, teamName }: TeamDeleteDialogProps) => {
|
||||||
trigger,
|
|
||||||
teamId,
|
|
||||||
teamName,
|
|
||||||
redirectTo,
|
|
||||||
}: TeamDeleteDialogProps) => {
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { refreshSession } = useSession();
|
|
||||||
|
|
||||||
const deleteMessage = _(msg`delete ${teamName}`);
|
const deleteMessage = _(msg`delete ${teamName}`);
|
||||||
|
|
||||||
@ -68,23 +60,19 @@ export const TeamDeleteDialog = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: deleteTeam } = trpc.team.delete.useMutation();
|
const { mutateAsync: deleteTeam } = trpc.team.deleteTeam.useMutation();
|
||||||
|
|
||||||
const onFormSubmit = async () => {
|
const onFormSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
await deleteTeam({ teamId });
|
await deleteTeam({ teamId });
|
||||||
|
|
||||||
await refreshSession();
|
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Success`),
|
title: _(msg`Success`),
|
||||||
description: _(msg`Your team has been successfully deleted.`),
|
description: _(msg`Your team has been successfully deleted.`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (redirectTo) {
|
await navigate('/settings/teams');
|
||||||
await navigate(redirectTo);
|
|
||||||
}
|
|
||||||
|
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -125,7 +113,7 @@ export const TeamDeleteDialog = ({
|
|||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button variant="destructive">
|
<Button variant="destructive">
|
||||||
<Trans>Delete</Trans>
|
<Trans>Delete team</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|||||||
@ -61,12 +61,12 @@ export const TeamEmailAddDialog = ({ teamId, trigger, ...props }: TeamEmailAddDi
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: sendTeamEmailVerification, isPending } =
|
const { mutateAsync: createTeamEmailVerification, isPending } =
|
||||||
trpc.team.email.verification.send.useMutation();
|
trpc.team.createTeamEmailVerification.useMutation();
|
||||||
|
|
||||||
const onFormSubmit = async ({ name, email }: TCreateTeamEmailFormSchema) => {
|
const onFormSubmit = async ({ name, email }: TCreateTeamEmailFormSchema) => {
|
||||||
try {
|
try {
|
||||||
await sendTeamEmailVerification({
|
await createTeamEmailVerification({
|
||||||
teamId,
|
teamId,
|
||||||
name,
|
name,
|
||||||
email,
|
email,
|
||||||
|
|||||||
@ -48,7 +48,7 @@ export const TeamEmailDeleteDialog = ({ trigger, teamName, team }: TeamEmailDele
|
|||||||
const { revalidate } = useRevalidator();
|
const { revalidate } = useRevalidator();
|
||||||
|
|
||||||
const { mutateAsync: deleteTeamEmail, isPending: isDeletingTeamEmail } =
|
const { mutateAsync: deleteTeamEmail, isPending: isDeletingTeamEmail } =
|
||||||
trpc.team.email.delete.useMutation({
|
trpc.team.deleteTeamEmail.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Success`),
|
title: _(msg`Success`),
|
||||||
@ -67,7 +67,7 @@ export const TeamEmailDeleteDialog = ({ trigger, teamName, team }: TeamEmailDele
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: deleteTeamEmailVerification, isPending: isDeletingTeamEmailVerification } =
|
const { mutateAsync: deleteTeamEmailVerification, isPending: isDeletingTeamEmailVerification } =
|
||||||
trpc.team.email.verification.delete.useMutation({
|
trpc.team.deleteTeamEmailVerification.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Success`),
|
title: _(msg`Success`),
|
||||||
|
|||||||
@ -61,7 +61,7 @@ export const TeamEmailUpdateDialog = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: updateTeamEmail } = trpc.team.email.update.useMutation();
|
const { mutateAsync: updateTeamEmail } = trpc.team.updateTeamEmail.useMutation();
|
||||||
|
|
||||||
const onFormSubmit = async ({ name }: TUpdateTeamEmailFormSchema) => {
|
const onFormSubmit = async ({ name }: TUpdateTeamEmailFormSchema) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -1,305 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
|
||||||
import { OrganisationGroupType, TeamMemberRole } from '@prisma/client';
|
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { TEAM_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/teams';
|
|
||||||
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@documenso/ui/primitives/select';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
|
||||||
|
|
||||||
export type TeamGroupCreateDialogProps = Omit<DialogPrimitive.DialogProps, 'children'>;
|
|
||||||
|
|
||||||
const ZAddTeamMembersFormSchema = z.object({
|
|
||||||
groups: z.array(
|
|
||||||
z.object({
|
|
||||||
organisationGroupId: z.string(),
|
|
||||||
teamRole: z.nativeEnum(TeamMemberRole),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
type TAddTeamMembersFormSchema = z.infer<typeof ZAddTeamMembersFormSchema>;
|
|
||||||
|
|
||||||
export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps) => {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [step, setStep] = useState<'SELECT' | 'ROLES'>('SELECT');
|
|
||||||
|
|
||||||
const { t } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const team = useCurrentTeam();
|
|
||||||
|
|
||||||
const form = useForm<TAddTeamMembersFormSchema>({
|
|
||||||
resolver: zodResolver(ZAddTeamMembersFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
groups: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutateAsync: createTeamGroups } = trpc.team.group.createMany.useMutation();
|
|
||||||
|
|
||||||
const organisationGroupQuery = trpc.organisation.group.find.useQuery({
|
|
||||||
organisationId: team.organisationId,
|
|
||||||
perPage: 100, // Won't really work if they somehow have more than 100 groups.
|
|
||||||
types: [OrganisationGroupType.CUSTOM],
|
|
||||||
});
|
|
||||||
|
|
||||||
const teamGroupQuery = trpc.team.group.find.useQuery({
|
|
||||||
teamId: team.id,
|
|
||||||
perPage: 100, // Won't really work if they somehow have more than 100 groups.
|
|
||||||
});
|
|
||||||
|
|
||||||
const avaliableOrganisationGroups = useMemo(() => {
|
|
||||||
const organisationGroups = organisationGroupQuery.data?.data ?? [];
|
|
||||||
const teamGroups = teamGroupQuery.data?.data ?? [];
|
|
||||||
|
|
||||||
return organisationGroups.filter(
|
|
||||||
(group) => !teamGroups.some((teamGroup) => teamGroup.organisationGroupId === group.id),
|
|
||||||
);
|
|
||||||
}, [organisationGroupQuery, teamGroupQuery]);
|
|
||||||
|
|
||||||
const onFormSubmit = async ({ groups }: TAddTeamMembersFormSchema) => {
|
|
||||||
try {
|
|
||||||
await createTeamGroups({
|
|
||||||
teamId: team.id,
|
|
||||||
groups,
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: t`Success`,
|
|
||||||
description: t`Team members have been added.`,
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
} catch {
|
|
||||||
toast({
|
|
||||||
title: t`An unknown error occurred`,
|
|
||||||
description: t`We encountered an unknown error while attempting to add team members. Please try again later.`,
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
form.reset();
|
|
||||||
setStep('SELECT');
|
|
||||||
}
|
|
||||||
}, [open, form]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<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>
|
|
||||||
<Button variant="secondary" onClick={() => setOpen(true)}>
|
|
||||||
<Trans>Add groups</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent hideClose={true} position="center">
|
|
||||||
{match(step)
|
|
||||||
.with('SELECT', () => (
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Add members</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription>
|
|
||||||
<Trans>Select members or groups of members to add to the team.</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
))
|
|
||||||
.with('ROLES', () => (
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Add group roles</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription>
|
|
||||||
<Trans>Configure the team roles for each group</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
))
|
|
||||||
.exhaustive()}
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
|
||||||
<fieldset disabled={form.formState.isSubmitting}>
|
|
||||||
{step === 'SELECT' && (
|
|
||||||
<>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="groups"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Groups</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
|
|
||||||
<FormControl>
|
|
||||||
<MultiSelectCombobox
|
|
||||||
options={avaliableOrganisationGroups.map((group) => ({
|
|
||||||
label: group.name ?? group.organisationRole,
|
|
||||||
value: group.id,
|
|
||||||
}))}
|
|
||||||
loading={organisationGroupQuery.isLoading || teamGroupQuery.isLoading}
|
|
||||||
selectedValues={field.value.map(
|
|
||||||
({ organisationGroupId }) => organisationGroupId,
|
|
||||||
)}
|
|
||||||
onChange={(value) => {
|
|
||||||
field.onChange(
|
|
||||||
value.map((organisationGroupId) => ({
|
|
||||||
organisationGroupId,
|
|
||||||
teamRole:
|
|
||||||
field.value.find(
|
|
||||||
(value) => value.organisationGroupId === organisationGroupId,
|
|
||||||
)?.teamRole || TeamMemberRole.MEMBER,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
className="bg-background w-full"
|
|
||||||
emptySelectionPlaceholder={t`Select groups`}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormDescription>
|
|
||||||
<Trans>Select groups to add to this team</Trans>
|
|
||||||
</FormDescription>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
disabled={form.getValues('groups').length === 0}
|
|
||||||
onClick={() => {
|
|
||||||
setStep('ROLES');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trans>Next</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 'ROLES' && (
|
|
||||||
<>
|
|
||||||
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
|
|
||||||
{form.getValues('groups').map((group, index) => (
|
|
||||||
<div className="flex w-full flex-row space-x-4" key={index}>
|
|
||||||
<div className="w-full space-y-2">
|
|
||||||
{index === 0 && (
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Group</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
)}
|
|
||||||
<Input
|
|
||||||
readOnly
|
|
||||||
className="bg-background"
|
|
||||||
value={
|
|
||||||
avaliableOrganisationGroups.find(
|
|
||||||
({ id }) => id === group.organisationGroupId,
|
|
||||||
)?.name || t`Untitled Group`
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name={`groups.${index}.teamRole`}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="w-full">
|
|
||||||
{index === 0 && (
|
|
||||||
<FormLabel required>
|
|
||||||
<Trans>Team Role</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
)}
|
|
||||||
<FormControl>
|
|
||||||
<Select {...field} onValueChange={field.onChange}>
|
|
||||||
<SelectTrigger className="text-muted-foreground">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
|
|
||||||
<SelectContent position="popper">
|
|
||||||
{TEAM_MEMBER_ROLE_HIERARCHY[team.currentTeamRole].map(
|
|
||||||
(role) => (
|
|
||||||
<SelectItem key={role} value={role}>
|
|
||||||
{t(TEAM_MEMBER_ROLE_MAP[role]) ?? role}
|
|
||||||
</SelectItem>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="mt-4">
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setStep('SELECT')}>
|
|
||||||
<Trans>Back</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
|
||||||
<Trans>Create Groups</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,139 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import type { TeamMemberRole } from '@prisma/client';
|
|
||||||
|
|
||||||
import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams';
|
|
||||||
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';
|
|
||||||
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
|
||||||
|
|
||||||
export type TeamGroupDeleteDialogProps = {
|
|
||||||
trigger?: React.ReactNode;
|
|
||||||
teamGroupId: string;
|
|
||||||
teamGroupName: string;
|
|
||||||
teamGroupRole: TeamMemberRole;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TeamGroupDeleteDialog = ({
|
|
||||||
trigger,
|
|
||||||
teamGroupId,
|
|
||||||
teamGroupName,
|
|
||||||
teamGroupRole,
|
|
||||||
}: TeamGroupDeleteDialogProps) => {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const team = useCurrentTeam();
|
|
||||||
|
|
||||||
const { mutateAsync: deleteGroup, isPending: isDeleting } = trpc.team.group.delete.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Success`),
|
|
||||||
description: _(msg`You have successfully removed this group from the team.`),
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast({
|
|
||||||
title: _(msg`An unknown error occurred`),
|
|
||||||
description: _(
|
|
||||||
msg`We encountered an unknown error while attempting to remove this group. Please try again later.`,
|
|
||||||
),
|
|
||||||
variant: 'destructive',
|
|
||||||
duration: 10000,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={(value) => !isDeleting && setOpen(value)}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
{trigger ?? (
|
|
||||||
<Button variant="secondary">
|
|
||||||
<Trans>Delete team group</Trans>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent position="center">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Are you sure?</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription className="mt-4">
|
|
||||||
<Trans>
|
|
||||||
You are about to remove the following group from{' '}
|
|
||||||
<span className="font-semibold">{team.name}</span>.
|
|
||||||
</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{isTeamRoleWithinUserHierarchy(team.currentTeamRole, teamGroupRole) ? (
|
|
||||||
<>
|
|
||||||
<Alert variant="neutral">
|
|
||||||
<AlertDescription className="text-center font-semibold">
|
|
||||||
{teamGroupName}
|
|
||||||
</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 () =>
|
|
||||||
deleteGroup({
|
|
||||||
teamId: team.id,
|
|
||||||
teamGroupId: teamGroupId,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Trans>Delete</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</fieldset>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Alert variant="neutral">
|
|
||||||
<AlertDescription className="text-center font-semibold">
|
|
||||||
<Trans>You cannot delete a group which has a higher role than you.</Trans>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
<Trans>Close</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,209 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { TeamMemberRole } from '@prisma/client';
|
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { TEAM_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/teams';
|
|
||||||
import { EXTENDED_TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
|
|
||||||
import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams';
|
|
||||||
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 {
|
|
||||||
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';
|
|
||||||
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
|
||||||
|
|
||||||
export type TeamGroupUpdateDialogProps = {
|
|
||||||
trigger?: React.ReactNode;
|
|
||||||
teamGroupId: string;
|
|
||||||
teamGroupName: string;
|
|
||||||
teamGroupRole: TeamMemberRole;
|
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
|
||||||
|
|
||||||
const ZUpdateTeamGroupFormSchema = z.object({
|
|
||||||
role: z.nativeEnum(TeamMemberRole),
|
|
||||||
});
|
|
||||||
|
|
||||||
type ZUpdateTeamGroupSchema = z.infer<typeof ZUpdateTeamGroupFormSchema>;
|
|
||||||
|
|
||||||
export const TeamGroupUpdateDialog = ({
|
|
||||||
trigger,
|
|
||||||
teamGroupId,
|
|
||||||
teamGroupName,
|
|
||||||
teamGroupRole,
|
|
||||||
...props
|
|
||||||
}: TeamGroupUpdateDialogProps) => {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const team = useCurrentTeam();
|
|
||||||
|
|
||||||
const form = useForm<ZUpdateTeamGroupSchema>({
|
|
||||||
resolver: zodResolver(ZUpdateTeamGroupFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
role: teamGroupRole,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutateAsync: updateTeamGroup } = trpc.team.group.update.useMutation();
|
|
||||||
|
|
||||||
const onFormSubmit = async ({ role }: ZUpdateTeamGroupSchema) => {
|
|
||||||
try {
|
|
||||||
await updateTeamGroup({
|
|
||||||
id: teamGroupId,
|
|
||||||
data: {
|
|
||||||
teamRole: role,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`Success`),
|
|
||||||
description: _(msg`You have updated the team group.`),
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
} catch {
|
|
||||||
toast({
|
|
||||||
title: _(msg`An unknown error occurred`),
|
|
||||||
description: _(
|
|
||||||
msg`We encountered an unknown error while attempting to update this team member. Please try again later.`,
|
|
||||||
),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
form.reset();
|
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [open, team.currentTeamRole, teamGroupRole, form, toast]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
{...props}
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
|
||||||
>
|
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
|
||||||
{trigger ?? (
|
|
||||||
<Button variant="secondary">
|
|
||||||
<Trans>Update team group</Trans>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent position="center">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Update team group</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription>
|
|
||||||
<Trans>
|
|
||||||
You are currently updating the <span className="font-bold">{teamGroupName}</span> team
|
|
||||||
group.
|
|
||||||
</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{isTeamRoleWithinUserHierarchy(team.currentTeamRole, teamGroupRole) ? (
|
|
||||||
<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">
|
|
||||||
{TEAM_MEMBER_ROLE_HIERARCHY[team.currentTeamRole].map((role) => (
|
|
||||||
<SelectItem key={role} value={role}>
|
|
||||||
{_(EXTENDED_TEAM_MEMBER_ROLE_MAP[role]) ?? role}
|
|
||||||
</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>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Alert variant="neutral">
|
|
||||||
<AlertDescription className="text-center font-semibold">
|
|
||||||
<Trans>You cannot modify a group which has a higher role than you.</Trans>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
<Trans>Close</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,93 +0,0 @@
|
|||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
|
||||||
import type { TeamGroup } from '@prisma/client';
|
|
||||||
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
|
||||||
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 TeamMemberInheritDisableDialogProps = {
|
|
||||||
group: TeamGroup;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TeamMemberInheritDisableDialog = ({ group }: TeamMemberInheritDisableDialogProps) => {
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { t } = useLingui();
|
|
||||||
|
|
||||||
const team = useCurrentTeam();
|
|
||||||
|
|
||||||
const deleteGroupMutation = trpc.team.group.delete.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({
|
|
||||||
title: t`Access disabled`,
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast({
|
|
||||||
title: t`Something went wrong`,
|
|
||||||
description: t`We encountered an unknown error while attempting to disable access.`,
|
|
||||||
variant: 'destructive',
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button variant="outline">
|
|
||||||
<Trans>Disable access</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent position="center">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Are you sure?</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription className="mt-4">
|
|
||||||
<Trans>
|
|
||||||
You are about to remove default access to this team for all organisation members. Any
|
|
||||||
members not explicitly added to this team will no longer have access.
|
|
||||||
</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button type="button" variant="secondary">
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="destructive"
|
|
||||||
loading={deleteGroupMutation.isPending}
|
|
||||||
onClick={() =>
|
|
||||||
deleteGroupMutation.mutate({
|
|
||||||
teamId: team.id,
|
|
||||||
teamGroupId: group.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Trans>Disable</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,109 +0,0 @@
|
|||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
|
||||||
import { OrganisationGroupType, OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
|
|
||||||
|
|
||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
|
||||||
|
|
||||||
export const TeamMemberInheritEnableDialog = () => {
|
|
||||||
const organisation = useCurrentOrganisation();
|
|
||||||
const team = useCurrentTeam();
|
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { t } = useLingui();
|
|
||||||
|
|
||||||
const { mutateAsync: createTeamGroups, isPending } = trpc.team.group.createMany.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({
|
|
||||||
title: t`Access enabled`,
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast({
|
|
||||||
title: t`Something went wrong`,
|
|
||||||
description: t`We encountered an unknown error while attempting to enable access.`,
|
|
||||||
variant: 'destructive',
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const organisationGroupQuery = trpc.organisation.group.find.useQuery({
|
|
||||||
organisationId: organisation.id,
|
|
||||||
perPage: 1,
|
|
||||||
types: [OrganisationGroupType.INTERNAL_ORGANISATION],
|
|
||||||
organisationRoles: [OrganisationMemberRole.MEMBER],
|
|
||||||
});
|
|
||||||
|
|
||||||
const enableAccessGroup = async () => {
|
|
||||||
if (!organisationGroupQuery.data?.data[0]?.id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await createTeamGroups({
|
|
||||||
teamId: team.id,
|
|
||||||
groups: [
|
|
||||||
{
|
|
||||||
organisationGroupId: organisationGroupQuery.data?.data[0]?.id,
|
|
||||||
teamRole: TeamMemberRole.MEMBER,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button variant="outline">
|
|
||||||
<Trans>Enable access</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent position="center">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Are you sure?</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription className="mt-4">
|
|
||||||
<Trans>
|
|
||||||
You are about to give all organisation members access to this team under their
|
|
||||||
organisation role.
|
|
||||||
</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button type="button" variant="secondary">
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={organisationGroupQuery.isPending}
|
|
||||||
loading={isPending}
|
|
||||||
onClick={enableAccessGroup}
|
|
||||||
>
|
|
||||||
<Trans>Enable</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
117
apps/remix/app/components/dialogs/team-leave-dialog.tsx
Normal file
117
apps/remix/app/components/dialogs/team-leave-dialog.tsx
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import type { TeamMemberRole } from '@prisma/client';
|
||||||
|
|
||||||
|
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
||||||
|
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Alert } from '@documenso/ui/primitives/alert';
|
||||||
|
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||||
|
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 TeamLeaveDialogProps = {
|
||||||
|
teamId: number;
|
||||||
|
teamName: string;
|
||||||
|
teamAvatarImageId?: string | null;
|
||||||
|
role: TeamMemberRole;
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TeamLeaveDialog = ({
|
||||||
|
trigger,
|
||||||
|
teamId,
|
||||||
|
teamName,
|
||||||
|
teamAvatarImageId,
|
||||||
|
role,
|
||||||
|
}: TeamLeaveDialogProps) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { mutateAsync: leaveTeam, isPending: isLeavingTeam } = trpc.team.leaveTeam.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: _(msg`Success`),
|
||||||
|
description: _(msg`You have successfully left this team.`),
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({
|
||||||
|
title: _(msg`An unknown error occurred`),
|
||||||
|
description: _(
|
||||||
|
msg`We encountered an unknown error while attempting to leave this team. Please try again later.`,
|
||||||
|
),
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 10000,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(value) => !isLeavingTeam && setOpen(value)}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{trigger ?? (
|
||||||
|
<Button variant="destructive">
|
||||||
|
<Trans>Leave team</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent position="center">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Are you sure?</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription className="mt-4">
|
||||||
|
<Trans>You are about to leave the following team.</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Alert variant="neutral" padding="tight">
|
||||||
|
<AvatarWithText
|
||||||
|
avatarClass="h-12 w-12"
|
||||||
|
avatarSrc={formatAvatarUrl(teamAvatarImageId)}
|
||||||
|
avatarFallback={teamName.slice(0, 1).toUpperCase()}
|
||||||
|
primaryText={teamName}
|
||||||
|
secondaryText={_(TEAM_MEMBER_ROLE_MAP[role])}
|
||||||
|
/>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<fieldset disabled={isLeavingTeam}>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="destructive"
|
||||||
|
loading={isLeavingTeam}
|
||||||
|
onClick={async () => leaveTeam({ teamId })}
|
||||||
|
>
|
||||||
|
<Trans>Leave</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,305 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
|
||||||
import { TeamMemberRole } from '@prisma/client';
|
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { TEAM_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/teams';
|
|
||||||
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@documenso/ui/primitives/select';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
|
||||||
|
|
||||||
export type TeamMemberCreateDialogProps = {
|
|
||||||
trigger?: React.ReactNode;
|
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
|
||||||
|
|
||||||
const ZAddTeamMembersFormSchema = z.object({
|
|
||||||
members: z.array(
|
|
||||||
z.object({
|
|
||||||
organisationMemberId: z.string(),
|
|
||||||
teamRole: z.nativeEnum(TeamMemberRole),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
type TAddTeamMembersFormSchema = z.infer<typeof ZAddTeamMembersFormSchema>;
|
|
||||||
|
|
||||||
export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDialogProps) => {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [step, setStep] = useState<'SELECT' | 'MEMBERS'>('SELECT');
|
|
||||||
|
|
||||||
const { t } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const team = useCurrentTeam();
|
|
||||||
|
|
||||||
const form = useForm<TAddTeamMembersFormSchema>({
|
|
||||||
resolver: zodResolver(ZAddTeamMembersFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
members: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutateAsync: createTeamMembers } = trpc.team.member.createMany.useMutation();
|
|
||||||
|
|
||||||
const organisationMemberQuery = trpc.organisation.member.find.useQuery({
|
|
||||||
organisationId: team.organisationId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const teamMemberQuery = trpc.team.member.find.useQuery({
|
|
||||||
teamId: team.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const avaliableOrganisationMembers = useMemo(() => {
|
|
||||||
const organisationMembers = organisationMemberQuery.data?.data ?? [];
|
|
||||||
const teamMembers = teamMemberQuery.data?.data ?? [];
|
|
||||||
|
|
||||||
return organisationMembers.filter(
|
|
||||||
(member) => !teamMembers.some((teamMember) => teamMember.id === member.id),
|
|
||||||
);
|
|
||||||
}, [organisationMemberQuery, teamMemberQuery]);
|
|
||||||
|
|
||||||
const onFormSubmit = async ({ members }: TAddTeamMembersFormSchema) => {
|
|
||||||
try {
|
|
||||||
await createTeamMembers({
|
|
||||||
teamId: team.id,
|
|
||||||
organisationMembers: members,
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: t`Success`,
|
|
||||||
description: t`Team members have been added.`,
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
} catch {
|
|
||||||
toast({
|
|
||||||
title: t`An unknown error occurred`,
|
|
||||||
description: t`We encountered an unknown error while attempting to add team members. Please try again later.`,
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
form.reset();
|
|
||||||
setStep('SELECT');
|
|
||||||
}
|
|
||||||
}, [open, form]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<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>
|
|
||||||
<Button variant="secondary" onClick={() => setOpen(true)}>
|
|
||||||
<Trans>Add members</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent hideClose={true} position="center">
|
|
||||||
{match(step)
|
|
||||||
.with('SELECT', () => (
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Add members</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription>
|
|
||||||
<Trans>Select members or groups of members to add to the team.</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
))
|
|
||||||
.with('MEMBERS', () => (
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Add members roles</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription>
|
|
||||||
<Trans>Configure the team roles for each member</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
))
|
|
||||||
.exhaustive()}
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
|
||||||
<fieldset disabled={form.formState.isSubmitting}>
|
|
||||||
{step === 'SELECT' && (
|
|
||||||
<>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="members"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Members</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
|
|
||||||
<FormControl>
|
|
||||||
<MultiSelectCombobox
|
|
||||||
options={avaliableOrganisationMembers.map((member) => ({
|
|
||||||
label: member.name,
|
|
||||||
value: member.id,
|
|
||||||
}))}
|
|
||||||
loading={organisationMemberQuery.isLoading}
|
|
||||||
selectedValues={field.value.map(
|
|
||||||
(member) => member.organisationMemberId,
|
|
||||||
)}
|
|
||||||
onChange={(value) => {
|
|
||||||
field.onChange(
|
|
||||||
value.map((organisationMemberId) => ({
|
|
||||||
organisationMemberId,
|
|
||||||
teamRole:
|
|
||||||
field.value.find(
|
|
||||||
(member) =>
|
|
||||||
member.organisationMemberId === organisationMemberId,
|
|
||||||
)?.teamRole || TeamMemberRole.MEMBER,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
className="bg-background w-full"
|
|
||||||
emptySelectionPlaceholder={t`Select members`}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormDescription>
|
|
||||||
<Trans>Select members to add to this team</Trans>
|
|
||||||
</FormDescription>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
disabled={form.getValues('members').length === 0}
|
|
||||||
onClick={() => {
|
|
||||||
setStep('MEMBERS');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trans>Next</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 'MEMBERS' && (
|
|
||||||
<>
|
|
||||||
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
|
|
||||||
{form.getValues('members').map((member, index) => (
|
|
||||||
<div className="flex w-full flex-row space-x-4" key={index}>
|
|
||||||
<div className="w-full space-y-2">
|
|
||||||
{index === 0 && (
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Member</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
)}
|
|
||||||
<Input
|
|
||||||
readOnly
|
|
||||||
className="bg-background"
|
|
||||||
value={
|
|
||||||
organisationMemberQuery.data?.data.find(
|
|
||||||
({ id }) => id === member.organisationMemberId,
|
|
||||||
)?.name || ''
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name={`members.${index}.teamRole`}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="w-full">
|
|
||||||
{index === 0 && (
|
|
||||||
<FormLabel required>
|
|
||||||
<Trans>Team Role</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
)}
|
|
||||||
<FormControl>
|
|
||||||
<Select {...field} onValueChange={field.onChange}>
|
|
||||||
<SelectTrigger className="text-muted-foreground">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
|
|
||||||
<SelectContent position="popper">
|
|
||||||
{TEAM_MEMBER_ROLE_HIERARCHY[team.currentTeamRole].map(
|
|
||||||
(role) => (
|
|
||||||
<SelectItem key={role} value={role}>
|
|
||||||
{t(TEAM_MEMBER_ROLE_MAP[role]) ?? role}
|
|
||||||
</SelectItem>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="mt-4">
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setStep('SELECT')}>
|
|
||||||
<Trans>Back</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
|
||||||
<Trans>Add Members</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -5,7 +5,7 @@ import { useLingui } from '@lingui/react';
|
|||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
import { Alert } from '@documenso/ui/primitives/alert';
|
||||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@ -22,10 +22,9 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
export type TeamMemberDeleteDialogProps = {
|
export type TeamMemberDeleteDialogProps = {
|
||||||
teamId: number;
|
teamId: number;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
memberId: string;
|
teamMemberId: number;
|
||||||
memberName: string;
|
teamMemberName: string;
|
||||||
memberEmail: string;
|
teamMemberEmail: string;
|
||||||
isInheritMemberEnabled: boolean | null;
|
|
||||||
trigger?: React.ReactNode;
|
trigger?: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -33,18 +32,17 @@ export const TeamMemberDeleteDialog = ({
|
|||||||
trigger,
|
trigger,
|
||||||
teamId,
|
teamId,
|
||||||
teamName,
|
teamName,
|
||||||
memberId,
|
teamMemberId,
|
||||||
memberName,
|
teamMemberName,
|
||||||
memberEmail,
|
teamMemberEmail,
|
||||||
isInheritMemberEnabled,
|
|
||||||
}: TeamMemberDeleteDialogProps) => {
|
}: TeamMemberDeleteDialogProps) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { mutateAsync: deleteTeamMember, isPending: isDeletingTeamMember } =
|
const { mutateAsync: deleteTeamMembers, isPending: isDeletingTeamMember } =
|
||||||
trpc.team.member.delete.useMutation({
|
trpc.team.deleteTeamMembers.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Success`),
|
title: _(msg`Success`),
|
||||||
@ -71,7 +69,7 @@ export const TeamMemberDeleteDialog = ({
|
|||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button variant="secondary">
|
<Button variant="secondary">
|
||||||
<Trans>Remove team member</Trans>
|
<Trans>Delete team member</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
@ -90,42 +88,29 @@ export const TeamMemberDeleteDialog = ({
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{isInheritMemberEnabled ? (
|
<Alert variant="neutral" padding="tight">
|
||||||
<Alert variant="neutral">
|
<AvatarWithText
|
||||||
<AlertDescription>
|
avatarClass="h-12 w-12"
|
||||||
<Trans>
|
avatarFallback={teamMemberName.slice(0, 1).toUpperCase()}
|
||||||
You cannot remove members from this team if the inherit member feature is enabled.
|
primaryText={<span className="font-semibold">{teamMemberName}</span>}
|
||||||
</Trans>
|
secondaryText={teamMemberEmail}
|
||||||
</AlertDescription>
|
/>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : (
|
|
||||||
<Alert variant="neutral" padding="tight">
|
|
||||||
<AvatarWithText
|
|
||||||
avatarClass="h-12 w-12"
|
|
||||||
avatarFallback={memberName.slice(0, 1).toUpperCase()}
|
|
||||||
primaryText={<span className="font-semibold">{memberName}</span>}
|
|
||||||
secondaryText={memberEmail}
|
|
||||||
/>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<fieldset disabled={isDeletingTeamMember}>
|
<fieldset disabled={isDeletingTeamMember}>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
<Trans>Close</Trans>
|
<Trans>Cancel</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{!isInheritMemberEnabled && (
|
<Button
|
||||||
<Button
|
type="submit"
|
||||||
type="submit"
|
variant="destructive"
|
||||||
variant="destructive"
|
loading={isDeletingTeamMember}
|
||||||
disabled={Boolean(isInheritMemberEnabled)}
|
onClick={async () => deleteTeamMembers({ teamId, teamMemberIds: [teamMemberId] })}
|
||||||
loading={isDeletingTeamMember}
|
>
|
||||||
onClick={async () => deleteTeamMember({ teamId, memberId })}
|
<Trans>Delete</Trans>
|
||||||
>
|
</Button>
|
||||||
<Trans>Remove</Trans>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
415
apps/remix/app/components/dialogs/team-member-invite-dialog.tsx
Normal file
415
apps/remix/app/components/dialogs/team-member-invite-dialog.tsx
Normal file
@ -0,0 +1,415 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { TeamMemberRole } from '@prisma/client';
|
||||||
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { Download, Mail, MailIcon, PlusCircle, Trash, Upload, UsersIcon } from 'lucide-react';
|
||||||
|
import Papa, { type ParseResult } from 'papaparse';
|
||||||
|
import { useFieldArray, useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { downloadFile } from '@documenso/lib/client-only/download-file';
|
||||||
|
import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { ZCreateTeamMemberInvitesMutationSchema } from '@documenso/trpc/server/team-router/schema';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
|
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 { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@documenso/ui/primitives/select';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
|
export type TeamMemberInviteDialogProps = {
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
|
const ZInviteTeamMembersFormSchema = z
|
||||||
|
.object({
|
||||||
|
invitations: ZCreateTeamMemberInvitesMutationSchema.shape.invitations,
|
||||||
|
})
|
||||||
|
// Display exactly which rows are duplicates.
|
||||||
|
.superRefine((items, ctx) => {
|
||||||
|
const uniqueEmails = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const [index, invitation] of items.invitations.entries()) {
|
||||||
|
const email = invitation.email.toLowerCase();
|
||||||
|
|
||||||
|
const firstFoundIndex = uniqueEmails.get(email);
|
||||||
|
|
||||||
|
if (firstFoundIndex === undefined) {
|
||||||
|
uniqueEmails.set(email, index);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: 'Emails must be unique',
|
||||||
|
path: ['invitations', index, 'email'],
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: 'Emails must be unique',
|
||||||
|
path: ['invitations', firstFoundIndex, 'email'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
type TInviteTeamMembersFormSchema = z.infer<typeof ZInviteTeamMembersFormSchema>;
|
||||||
|
|
||||||
|
type TabTypes = 'INDIVIDUAL' | 'BULK';
|
||||||
|
|
||||||
|
const ZImportTeamMemberSchema = z.array(
|
||||||
|
z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
role: z.nativeEnum(TeamMemberRole),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const TeamMemberInviteDialog = ({ trigger, ...props }: TeamMemberInviteDialogProps) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [invitationType, setInvitationType] = useState<TabTypes>('INDIVIDUAL');
|
||||||
|
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
|
const form = useForm<TInviteTeamMembersFormSchema>({
|
||||||
|
resolver: zodResolver(ZInviteTeamMembersFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
invitations: [
|
||||||
|
{
|
||||||
|
email: '',
|
||||||
|
role: TeamMemberRole.MEMBER,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
append: appendTeamMemberInvite,
|
||||||
|
fields: teamMemberInvites,
|
||||||
|
remove: removeTeamMemberInvite,
|
||||||
|
} = useFieldArray({
|
||||||
|
control: form.control,
|
||||||
|
name: 'invitations',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: createTeamMemberInvites } = trpc.team.createTeamMemberInvites.useMutation();
|
||||||
|
|
||||||
|
const onAddTeamMemberInvite = () => {
|
||||||
|
appendTeamMemberInvite({
|
||||||
|
email: '',
|
||||||
|
role: TeamMemberRole.MEMBER,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFormSubmit = async ({ invitations }: TInviteTeamMembersFormSchema) => {
|
||||||
|
try {
|
||||||
|
await createTeamMemberInvites({
|
||||||
|
teamId: team.id,
|
||||||
|
invitations,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Success`),
|
||||||
|
description: _(msg`Team invitations have been sent.`),
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
title: _(msg`An unknown error occurred`),
|
||||||
|
description: _(
|
||||||
|
msg`We encountered an unknown error while attempting to invite team members. Please try again later.`,
|
||||||
|
),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
form.reset();
|
||||||
|
setInvitationType('INDIVIDUAL');
|
||||||
|
}
|
||||||
|
}, [open, form]);
|
||||||
|
|
||||||
|
const onFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (!e.target.files?.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const csvFile = e.target.files[0];
|
||||||
|
|
||||||
|
Papa.parse(csvFile, {
|
||||||
|
skipEmptyLines: true,
|
||||||
|
comments: 'Work email,Job title',
|
||||||
|
complete: (results: ParseResult<string[]>) => {
|
||||||
|
const members = results.data.map((row) => {
|
||||||
|
const [email, role] = row;
|
||||||
|
|
||||||
|
return {
|
||||||
|
email: email.trim(),
|
||||||
|
role: role.trim().toUpperCase(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove the first row if it contains the headers.
|
||||||
|
if (members.length > 1 && members[0].role.toUpperCase() === 'ROLE') {
|
||||||
|
members.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const importedInvitations = ZImportTeamMemberSchema.parse(members);
|
||||||
|
|
||||||
|
form.setValue('invitations', importedInvitations);
|
||||||
|
form.clearErrors('invitations');
|
||||||
|
|
||||||
|
setInvitationType('INDIVIDUAL');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Something went wrong`),
|
||||||
|
description: _(
|
||||||
|
msg`Please check the CSV file and make sure it is according to our format`,
|
||||||
|
),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadTemplate = () => {
|
||||||
|
const data = [
|
||||||
|
{ email: 'admin@documenso.com', role: 'Admin' },
|
||||||
|
{ email: 'manager@documenso.com', role: 'Manager' },
|
||||||
|
{ email: 'member@documenso.com', role: 'Member' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const csvContent =
|
||||||
|
'Email address,Role\n' + data.map((row) => `${row.email},${row.role}`).join('\n');
|
||||||
|
|
||||||
|
const blob = new Blob([csvContent], {
|
||||||
|
type: 'text/csv',
|
||||||
|
});
|
||||||
|
|
||||||
|
downloadFile({
|
||||||
|
filename: 'documenso-team-member-invites-template.csv',
|
||||||
|
data: blob,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
{...props}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||||
|
>
|
||||||
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||||
|
{trigger ?? (
|
||||||
|
<Button variant="secondary">
|
||||||
|
<Trans>Invite member</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent position="center">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Invite team members</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription className="mt-4">
|
||||||
|
<Trans>An email containing an invitation will be sent to each member.</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
defaultValue="INDIVIDUAL"
|
||||||
|
value={invitationType}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
onValueChange={(value) => setInvitationType(value as TabTypes)}
|
||||||
|
>
|
||||||
|
<TabsList className="w-full">
|
||||||
|
<TabsTrigger value="INDIVIDUAL" className="hover:text-foreground w-full">
|
||||||
|
<MailIcon size={20} className="mr-2" />
|
||||||
|
<Trans>Invite Members</Trans>
|
||||||
|
</TabsTrigger>
|
||||||
|
|
||||||
|
<TabsTrigger value="BULK" className="hover:text-foreground w-full">
|
||||||
|
<UsersIcon size={20} className="mr-2" /> <Trans>Bulk Import</Trans>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="INDIVIDUAL">
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
|
<fieldset
|
||||||
|
className="flex h-full flex-col space-y-4"
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
|
||||||
|
{teamMemberInvites.map((teamMemberInvite, index) => (
|
||||||
|
<div className="flex w-full flex-row space-x-4" key={teamMemberInvite.id}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`invitations.${index}.email`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-full">
|
||||||
|
{index === 0 && (
|
||||||
|
<FormLabel required>
|
||||||
|
<Trans>Email address</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
)}
|
||||||
|
<FormControl>
|
||||||
|
<Input className="bg-background" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`invitations.${index}.role`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-full">
|
||||||
|
{index === 0 && (
|
||||||
|
<FormLabel required>
|
||||||
|
<Trans>Role</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
)}
|
||||||
|
<FormControl>
|
||||||
|
<Select {...field} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger className="text-muted-foreground max-w-[200px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent position="popper">
|
||||||
|
{TEAM_MEMBER_ROLE_HIERARCHY[team.currentTeamMember.role].map(
|
||||||
|
(role) => (
|
||||||
|
<SelectItem key={role} value={role}>
|
||||||
|
{_(TEAM_MEMBER_ROLE_MAP[role]) ?? role}
|
||||||
|
</SelectItem>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'justify-left inline-flex h-10 w-10 items-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
index === 0 ? 'mt-8' : 'mt-0',
|
||||||
|
)}
|
||||||
|
disabled={teamMemberInvites.length === 1}
|
||||||
|
onClick={() => removeTeamMemberInvite(index)}
|
||||||
|
>
|
||||||
|
<Trash className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="w-fit"
|
||||||
|
onClick={() => onAddTeamMemberInvite()}
|
||||||
|
>
|
||||||
|
<PlusCircle className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Add more</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
|
{!form.formState.isSubmitting && <Mail className="mr-2 h-4 w-4" />}
|
||||||
|
<Trans>Invite</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="BULK">
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
<Card gradient className="h-32">
|
||||||
|
<CardContent
|
||||||
|
className="text-muted-foreground/80 hover:text-muted-foreground/90 flex h-full cursor-pointer flex-col items-center justify-center rounded-lg p-0 transition-colors"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<Upload className="h-5 w-5" />
|
||||||
|
|
||||||
|
<p className="mt-1 text-sm">
|
||||||
|
<Trans>Click here to upload</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<input
|
||||||
|
onChange={onFileInputChange}
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
accept=".csv"
|
||||||
|
hidden
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={downloadTemplate}>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Template</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -9,8 +9,7 @@ import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { TEAM_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/teams';
|
import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
||||||
import { EXTENDED_TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
|
|
||||||
import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams';
|
import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -44,9 +43,9 @@ export type TeamMemberUpdateDialogProps = {
|
|||||||
currentUserTeamRole: TeamMemberRole;
|
currentUserTeamRole: TeamMemberRole;
|
||||||
trigger?: React.ReactNode;
|
trigger?: React.ReactNode;
|
||||||
teamId: number;
|
teamId: number;
|
||||||
memberId: string;
|
teamMemberId: number;
|
||||||
memberName: string;
|
teamMemberName: string;
|
||||||
memberTeamRole: TeamMemberRole;
|
teamMemberRole: TeamMemberRole;
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
const ZUpdateTeamMemberFormSchema = z.object({
|
const ZUpdateTeamMemberFormSchema = z.object({
|
||||||
@ -59,9 +58,9 @@ export const TeamMemberUpdateDialog = ({
|
|||||||
currentUserTeamRole,
|
currentUserTeamRole,
|
||||||
trigger,
|
trigger,
|
||||||
teamId,
|
teamId,
|
||||||
memberId,
|
teamMemberId,
|
||||||
memberName,
|
teamMemberName,
|
||||||
memberTeamRole,
|
teamMemberRole,
|
||||||
...props
|
...props
|
||||||
}: TeamMemberUpdateDialogProps) => {
|
}: TeamMemberUpdateDialogProps) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@ -72,17 +71,17 @@ export const TeamMemberUpdateDialog = ({
|
|||||||
const form = useForm<ZUpdateTeamMemberSchema>({
|
const form = useForm<ZUpdateTeamMemberSchema>({
|
||||||
resolver: zodResolver(ZUpdateTeamMemberFormSchema),
|
resolver: zodResolver(ZUpdateTeamMemberFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
role: memberTeamRole,
|
role: teamMemberRole,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: updateTeamMember } = trpc.team.member.update.useMutation();
|
const { mutateAsync: updateTeamMember } = trpc.team.updateTeamMember.useMutation();
|
||||||
|
|
||||||
const onFormSubmit = async ({ role }: ZUpdateTeamMemberSchema) => {
|
const onFormSubmit = async ({ role }: ZUpdateTeamMemberSchema) => {
|
||||||
try {
|
try {
|
||||||
await updateTeamMember({
|
await updateTeamMember({
|
||||||
teamId,
|
teamId,
|
||||||
memberId,
|
teamMemberId,
|
||||||
data: {
|
data: {
|
||||||
role,
|
role,
|
||||||
},
|
},
|
||||||
@ -90,7 +89,7 @@ export const TeamMemberUpdateDialog = ({
|
|||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Success`),
|
title: _(msg`Success`),
|
||||||
description: _(msg`You have updated ${memberName}.`),
|
description: _(msg`You have updated ${teamMemberName}.`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -113,7 +112,7 @@ export const TeamMemberUpdateDialog = ({
|
|||||||
|
|
||||||
form.reset();
|
form.reset();
|
||||||
|
|
||||||
if (!isTeamRoleWithinUserHierarchy(currentUserTeamRole, memberTeamRole)) {
|
if (!isTeamRoleWithinUserHierarchy(currentUserTeamRole, teamMemberRole)) {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@ -122,7 +121,7 @@ export const TeamMemberUpdateDialog = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [open, currentUserTeamRole, memberTeamRole, form, toast]);
|
}, [open, currentUserTeamRole, teamMemberRole, form, toast]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@ -144,9 +143,9 @@ export const TeamMemberUpdateDialog = ({
|
|||||||
<Trans>Update team member</Trans>
|
<Trans>Update team member</Trans>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|
||||||
<DialogDescription>
|
<DialogDescription className="mt-4">
|
||||||
<Trans>
|
<Trans>
|
||||||
You are currently updating <span className="font-bold">{memberName}.</span>
|
You are currently updating <span className="font-bold">{teamMemberName}.</span>
|
||||||
</Trans>
|
</Trans>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@ -171,7 +170,7 @@ export const TeamMemberUpdateDialog = ({
|
|||||||
<SelectContent className="w-full" position="popper">
|
<SelectContent className="w-full" position="popper">
|
||||||
{TEAM_MEMBER_ROLE_HIERARCHY[currentUserTeamRole].map((role) => (
|
{TEAM_MEMBER_ROLE_HIERARCHY[currentUserTeamRole].map((role) => (
|
||||||
<SelectItem key={role} value={role}>
|
<SelectItem key={role} value={role}>
|
||||||
{_(EXTENDED_TEAM_MEMBER_ROLE_MAP[role]) ?? role}
|
{_(TEAM_MEMBER_ROLE_MAP[role]) ?? role}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|||||||
272
apps/remix/app/components/dialogs/team-transfer-dialog.tsx
Normal file
272
apps/remix/app/components/dialogs/team-transfer-dialog.tsx
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { Loader } from 'lucide-react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useRevalidator } from 'react-router';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
|
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 {
|
||||||
|
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 { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type TeamTransferDialogProps = {
|
||||||
|
teamId: number;
|
||||||
|
teamName: string;
|
||||||
|
ownerUserId: number;
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TeamTransferDialog = ({
|
||||||
|
trigger,
|
||||||
|
teamId,
|
||||||
|
teamName,
|
||||||
|
ownerUserId,
|
||||||
|
}: TeamTransferDialogProps) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { revalidate } = useRevalidator();
|
||||||
|
|
||||||
|
const { mutateAsync: requestTeamOwnershipTransfer } =
|
||||||
|
trpc.team.requestTeamOwnershipTransfer.useMutation();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
refetch: refetchTeamMembers,
|
||||||
|
isPending: loadingTeamMembers,
|
||||||
|
isLoadingError: loadingTeamMembersError,
|
||||||
|
} = trpc.team.getTeamMembers.useQuery({
|
||||||
|
teamId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmTransferMessage = _(msg`transfer ${teamName}`);
|
||||||
|
|
||||||
|
const ZTransferTeamFormSchema = z.object({
|
||||||
|
teamName: z.literal(confirmTransferMessage, {
|
||||||
|
errorMap: () => ({ message: `You must enter '${confirmTransferMessage}' to proceed` }),
|
||||||
|
}),
|
||||||
|
newOwnerUserId: z.string(),
|
||||||
|
clearPaymentMethods: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof ZTransferTeamFormSchema>>({
|
||||||
|
resolver: zodResolver(ZTransferTeamFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
teamName: '',
|
||||||
|
clearPaymentMethods: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onFormSubmit = async ({
|
||||||
|
newOwnerUserId,
|
||||||
|
clearPaymentMethods,
|
||||||
|
}: z.infer<typeof ZTransferTeamFormSchema>) => {
|
||||||
|
try {
|
||||||
|
await requestTeamOwnershipTransfer({
|
||||||
|
teamId,
|
||||||
|
newOwnerUserId: Number.parseInt(newOwnerUserId),
|
||||||
|
clearPaymentMethods,
|
||||||
|
});
|
||||||
|
|
||||||
|
await revalidate();
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Success`),
|
||||||
|
description: _(msg`An email requesting the transfer of this team has been sent.`),
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: _(msg`An unknown error occurred`),
|
||||||
|
description: _(
|
||||||
|
msg`We encountered an unknown error while attempting to request a transfer of this team. Please try again later.`,
|
||||||
|
),
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 10000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
form.reset();
|
||||||
|
}
|
||||||
|
}, [open, form]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && loadingTeamMembersError) {
|
||||||
|
void refetchTeamMembers();
|
||||||
|
}
|
||||||
|
}, [open, loadingTeamMembersError, refetchTeamMembers]);
|
||||||
|
|
||||||
|
const teamMembers = data
|
||||||
|
? data.filter((teamMember) => teamMember.userId !== ownerUserId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{trigger ?? (
|
||||||
|
<Button variant="outline" className="bg-background">
|
||||||
|
<Trans>Transfer team</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
{teamMembers && teamMembers.length > 0 ? (
|
||||||
|
<DialogContent position="center">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Transfer team</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription className="mt-4">
|
||||||
|
<Trans>Transfer ownership of this team to a selected team member.</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
|
<fieldset
|
||||||
|
className="flex h-full flex-col space-y-4"
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="newOwnerUserId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-full">
|
||||||
|
<FormLabel required>
|
||||||
|
<Trans>New team owner</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select {...field} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger className="text-muted-foreground">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent position="popper">
|
||||||
|
{teamMembers.map((teamMember) => (
|
||||||
|
<SelectItem
|
||||||
|
key={teamMember.userId}
|
||||||
|
value={teamMember.userId.toString()}
|
||||||
|
>
|
||||||
|
{teamMember.user.name} ({teamMember.user.email})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="teamName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>
|
||||||
|
Confirm by typing{' '}
|
||||||
|
<span className="text-destructive">{confirmTransferMessage}</span>
|
||||||
|
</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input className="bg-background" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Alert variant="neutral">
|
||||||
|
<AlertDescription>
|
||||||
|
<ul className="list-outside list-disc space-y-2 pl-4">
|
||||||
|
{IS_BILLING_ENABLED() && (
|
||||||
|
<li>
|
||||||
|
<Trans>
|
||||||
|
Any payment methods attached to this team will remain attached to this
|
||||||
|
team. Please contact us if you need to update this information.
|
||||||
|
</Trans>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
<li>
|
||||||
|
<Trans>
|
||||||
|
The selected team member will receive an email which they must accept
|
||||||
|
before the team is transferred
|
||||||
|
</Trans>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit" variant="destructive" loading={form.formState.isSubmitting}>
|
||||||
|
<Trans>Request transfer</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
) : (
|
||||||
|
<DialogContent
|
||||||
|
position="center"
|
||||||
|
className="text-muted-foreground flex items-center justify-center py-16 text-sm"
|
||||||
|
>
|
||||||
|
{loadingTeamMembers ? (
|
||||||
|
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<p className="text-center text-sm">
|
||||||
|
{loadingTeamMembersError ? (
|
||||||
|
<Trans>An error occurred while loading team members. Please try again later.</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>You must have at least one other team member to transfer ownership.</Trans>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -21,7 +21,7 @@ import {
|
|||||||
import { Form, FormControl, FormField, FormItem } from '@documenso/ui/primitives/form/form';
|
import { Form, FormControl, FormField, FormItem } from '@documenso/ui/primitives/form/form';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
const ZBulkSendFormSchema = z.object({
|
const ZBulkSendFormSchema = z.object({
|
||||||
file: z.instanceof(File),
|
file: z.instanceof(File),
|
||||||
@ -46,7 +46,7 @@ export const TemplateBulkSendDialog = ({
|
|||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const team = useCurrentTeam();
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
const form = useForm<TBulkSendFormSchema>({
|
const form = useForm<TBulkSendFormSchema>({
|
||||||
resolver: zodResolver(ZBulkSendFormSchema),
|
resolver: zodResolver(ZBulkSendFormSchema),
|
||||||
@ -114,7 +114,7 @@ export const TemplateBulkSendDialog = ({
|
|||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button variant="outline">
|
<Button>
|
||||||
<Upload className="mr-2 h-4 w-4" />
|
<Upload className="mr-2 h-4 w-4" />
|
||||||
<Trans>Bulk Send via CSV</Trans>
|
<Trans>Bulk Send via CSV</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -24,11 +24,11 @@ import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
|||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
type TemplateCreateDialogProps = {
|
type TemplateCreateDialogProps = {
|
||||||
|
teamId?: number;
|
||||||
templateRootPath: string;
|
templateRootPath: string;
|
||||||
folderId?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TemplateCreateDialog = ({ templateRootPath, folderId }: TemplateCreateDialogProps) => {
|
export const TemplateCreateDialog = ({ templateRootPath }: TemplateCreateDialogProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const { user } = useSession();
|
const { user } = useSession();
|
||||||
@ -53,7 +53,6 @@ export const TemplateCreateDialog = ({ templateRootPath, folderId }: TemplateCre
|
|||||||
const { id } = await createTemplate({
|
const { id } = await createTemplate({
|
||||||
title: file.name,
|
title: file.name,
|
||||||
templateDocumentDataId: response.id,
|
templateDocumentDataId: response.id,
|
||||||
folderId: folderId,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
|
|||||||
@ -15,7 +15,6 @@ import { P, match } from 'ts-pattern';
|
|||||||
|
|
||||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||||
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 { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
|
||||||
import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates';
|
import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates';
|
||||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||||
import { DIRECT_TEMPLATE_DOCUMENTATION } from '@documenso/lib/constants/template';
|
import { DIRECT_TEMPLATE_DOCUMENTATION } from '@documenso/lib/constants/template';
|
||||||
@ -76,8 +75,6 @@ export const TemplateDirectLinkDialog = ({
|
|||||||
token ? 'MANAGE' : 'ONBOARD',
|
token ? 'MANAGE' : 'ONBOARD',
|
||||||
);
|
);
|
||||||
|
|
||||||
const organisation = useCurrentOrganisation();
|
|
||||||
|
|
||||||
const validDirectTemplateRecipients = useMemo(
|
const validDirectTemplateRecipients = useMemo(
|
||||||
() =>
|
() =>
|
||||||
template.recipients.filter(
|
template.recipients.filter(
|
||||||
@ -240,7 +237,7 @@ export const TemplateDirectLinkDialog = ({
|
|||||||
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="/settings/billing"
|
||||||
>
|
>
|
||||||
Upgrade your account to continue!
|
Upgrade your account to continue!
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@ -1,165 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
|
||||||
import { FolderPlusIcon } from 'lucide-react';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { useNavigate, useParams } from 'react-router';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
||||||
import { FolderType } from '@documenso/lib/types/folder-type';
|
|
||||||
import { 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 {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
|
||||||
|
|
||||||
const ZCreateFolderFormSchema = z.object({
|
|
||||||
name: z.string().min(1, { message: 'Folder name is required' }),
|
|
||||||
});
|
|
||||||
|
|
||||||
type TCreateFolderFormSchema = z.infer<typeof ZCreateFolderFormSchema>;
|
|
||||||
|
|
||||||
export type TemplateFolderCreateDialogProps = {
|
|
||||||
trigger?: React.ReactNode;
|
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
|
||||||
|
|
||||||
export const TemplateFolderCreateDialog = ({
|
|
||||||
trigger,
|
|
||||||
...props
|
|
||||||
}: TemplateFolderCreateDialogProps) => {
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { folderId } = useParams();
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const team = useCurrentTeam();
|
|
||||||
|
|
||||||
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
|
|
||||||
|
|
||||||
const { mutateAsync: createFolder } = trpc.folder.createFolder.useMutation();
|
|
||||||
|
|
||||||
const form = useForm<TCreateFolderFormSchema>({
|
|
||||||
resolver: zodResolver(ZCreateFolderFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
name: '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSubmit = async (data: TCreateFolderFormSchema) => {
|
|
||||||
try {
|
|
||||||
const newFolder = await createFolder({
|
|
||||||
name: data.name,
|
|
||||||
parentId: folderId,
|
|
||||||
type: FolderType.TEMPLATE,
|
|
||||||
});
|
|
||||||
|
|
||||||
setIsCreateFolderOpen(false);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
description: _(msg`Folder created successfully`),
|
|
||||||
});
|
|
||||||
|
|
||||||
const templatesPath = formatTemplatesPath(team.url);
|
|
||||||
|
|
||||||
void navigate(`${templatesPath}/f/${newFolder.id}`);
|
|
||||||
} catch (err) {
|
|
||||||
const error = AppError.parseError(err);
|
|
||||||
|
|
||||||
if (error.code === AppErrorCode.ALREADY_EXISTS) {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Failed to create folder`),
|
|
||||||
description: _(msg`This folder name is already taken.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`Failed to create folder`),
|
|
||||||
description: _(msg`An unknown error occurred while creating the folder.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isCreateFolderOpen) {
|
|
||||||
form.reset();
|
|
||||||
}
|
|
||||||
}, [isCreateFolderOpen, form]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog {...props} open={isCreateFolderOpen} onOpenChange={setIsCreateFolderOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
{trigger ?? (
|
|
||||||
<Button variant="outline" className="flex items-center space-x-2">
|
|
||||||
<FolderPlusIcon className="h-4 w-4" />
|
|
||||||
<span>Create Folder</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Create New Folder</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Enter a name for your new folder. Folders help you organise your templates.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Folder Name</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="My Folder" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => setIsCreateFolderOpen(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit">Create</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,163 +0,0 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
export type TemplateFolderDeleteDialogProps = {
|
|
||||||
folder: TFolderWithSubfolders | null;
|
|
||||||
isOpen: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
|
||||||
|
|
||||||
export const TemplateFolderDeleteDialog = ({
|
|
||||||
folder,
|
|
||||||
isOpen,
|
|
||||||
onOpenChange,
|
|
||||||
}: TemplateFolderDeleteDialogProps) => {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { mutateAsync: deleteFolder } = trpc.folder.deleteFolder.useMutation();
|
|
||||||
|
|
||||||
const deleteMessage = _(msg`delete ${folder?.name ?? 'folder'}`);
|
|
||||||
|
|
||||||
const ZDeleteFolderFormSchema = z.object({
|
|
||||||
confirmText: z.literal(deleteMessage, {
|
|
||||||
errorMap: () => ({ message: _(msg`You must type '${deleteMessage}' to confirm`) }),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
type TDeleteFolderFormSchema = z.infer<typeof ZDeleteFolderFormSchema>;
|
|
||||||
|
|
||||||
const form = useForm<TDeleteFolderFormSchema>({
|
|
||||||
resolver: zodResolver(ZDeleteFolderFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
confirmText: '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onFormSubmit = async () => {
|
|
||||||
if (!folder) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await deleteFolder({
|
|
||||||
id: folder.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
onOpenChange(false);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Folder deleted successfully',
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
const error = AppError.parseError(err);
|
|
||||||
|
|
||||||
if (error.code === AppErrorCode.NOT_FOUND) {
|
|
||||||
toast({
|
|
||||||
title: 'Folder not found',
|
|
||||||
description: _(msg`The folder you are trying to delete does not exist.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Failed to delete folder',
|
|
||||||
description: _(msg`An unknown error occurred while deleting the folder.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) {
|
|
||||||
form.reset();
|
|
||||||
}
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Delete Folder</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Are you sure you want to delete this folder?
|
|
||||||
{folder && folder._count.documents > 0 && (
|
|
||||||
<span className="text-destructive mt-2 block">
|
|
||||||
This folder contains {folder._count.documents} document(s). Deleting it will also
|
|
||||||
delete all documents in the folder.
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{folder && folder._count.subfolders > 0 && (
|
|
||||||
<span className="text-destructive mt-2 block">
|
|
||||||
This folder contains {folder._count.subfolders} subfolder(s). Deleting it will
|
|
||||||
delete all subfolders and their contents.
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="confirmText"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>
|
|
||||||
Confirm by typing:{' '}
|
|
||||||
<span className="font-sm text-destructive font-semibold">
|
|
||||||
{deleteMessage}
|
|
||||||
</span>
|
|
||||||
</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} placeholder={deleteMessage} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="secondary" onClick={() => onOpenChange(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button variant="destructive" type="submit" disabled={!form.formState.isValid}>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,175 +0,0 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
|
||||||
import { FolderIcon, HomeIcon } from 'lucide-react';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
|
||||||
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 { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
export type TemplateFolderMoveDialogProps = {
|
|
||||||
foldersData: TFolderWithSubfolders[] | undefined;
|
|
||||||
folder: TFolderWithSubfolders | null;
|
|
||||||
isOpen: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
|
||||||
|
|
||||||
const ZMoveFolderFormSchema = z.object({
|
|
||||||
targetFolderId: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type TMoveFolderFormSchema = z.infer<typeof ZMoveFolderFormSchema>;
|
|
||||||
|
|
||||||
export const TemplateFolderMoveDialog = ({
|
|
||||||
foldersData,
|
|
||||||
folder,
|
|
||||||
isOpen,
|
|
||||||
onOpenChange,
|
|
||||||
}: TemplateFolderMoveDialogProps) => {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { mutateAsync: moveFolder } = trpc.folder.moveFolder.useMutation();
|
|
||||||
|
|
||||||
const form = useForm<TMoveFolderFormSchema>({
|
|
||||||
resolver: zodResolver(ZMoveFolderFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
targetFolderId: folder?.parentId ?? '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onFormSubmit = async ({ targetFolderId }: TMoveFolderFormSchema) => {
|
|
||||||
if (!folder) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await moveFolder({
|
|
||||||
id: folder.id,
|
|
||||||
parentId: targetFolderId ?? '',
|
|
||||||
});
|
|
||||||
|
|
||||||
onOpenChange(false);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Folder moved successfully',
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
const error = AppError.parseError(err);
|
|
||||||
|
|
||||||
if (error.code === AppErrorCode.NOT_FOUND) {
|
|
||||||
toast({
|
|
||||||
title: 'Folder not found',
|
|
||||||
description: _(msg`The folder you are trying to move does not exist.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Failed to move folder',
|
|
||||||
description: _(msg`An unknown error occurred while moving the folder.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) {
|
|
||||||
form.reset();
|
|
||||||
}
|
|
||||||
}, [isOpen, form]);
|
|
||||||
|
|
||||||
// Filter out the current folder and only show folders of the same type
|
|
||||||
const filteredFolders = foldersData?.filter(
|
|
||||||
(f) => f.id !== folder?.id && f.type === folder?.type,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Move Folder</DialogTitle>
|
|
||||||
<DialogDescription>Select a destination for this folder.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4 py-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="targetFolderId"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormControl>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant={!field.value ? 'default' : 'outline'}
|
|
||||||
className="w-full justify-start"
|
|
||||||
disabled={!folder?.parentId}
|
|
||||||
onClick={() => field.onChange(undefined)}
|
|
||||||
>
|
|
||||||
<HomeIcon className="mr-2 h-4 w-4" />
|
|
||||||
Root
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{filteredFolders &&
|
|
||||||
filteredFolders.map((f) => (
|
|
||||||
<Button
|
|
||||||
key={f.id}
|
|
||||||
type="button"
|
|
||||||
disabled={f.id === folder?.parentId}
|
|
||||||
variant={field.value === f.id ? 'default' : 'outline'}
|
|
||||||
className="w-full justify-start"
|
|
||||||
onClick={() => field.onChange(f.id)}
|
|
||||||
>
|
|
||||||
<FolderIcon className="mr-2 h-4 w-4" />
|
|
||||||
{f.name}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={
|
|
||||||
form.formState.isSubmitting ||
|
|
||||||
form.getValues('targetFolderId') === folder?.parentId
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Move Folder
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,176 +0,0 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
||||||
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
|
|
||||||
import { FolderType } from '@documenso/lib/types/folder-type';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@documenso/ui/primitives/select';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
|
||||||
|
|
||||||
export type TemplateFolderSettingsDialogProps = {
|
|
||||||
folder: TFolderWithSubfolders | null;
|
|
||||||
isOpen: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
|
||||||
|
|
||||||
export const ZUpdateFolderFormSchema = z.object({
|
|
||||||
name: z.string().min(1),
|
|
||||||
visibility: z.nativeEnum(DocumentVisibility).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type TUpdateFolderFormSchema = z.infer<typeof ZUpdateFolderFormSchema>;
|
|
||||||
|
|
||||||
export const TemplateFolderSettingsDialog = ({
|
|
||||||
folder,
|
|
||||||
isOpen,
|
|
||||||
onOpenChange,
|
|
||||||
}: TemplateFolderSettingsDialogProps) => {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const team = useOptionalCurrentTeam();
|
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation();
|
|
||||||
|
|
||||||
const isTeamContext = !!team;
|
|
||||||
const isTemplateFolder = folder?.type === FolderType.TEMPLATE;
|
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof ZUpdateFolderFormSchema>>({
|
|
||||||
resolver: zodResolver(ZUpdateFolderFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
name: folder?.name ?? '',
|
|
||||||
visibility: folder?.visibility ?? DocumentVisibility.EVERYONE,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (folder) {
|
|
||||||
form.reset({
|
|
||||||
name: folder.name,
|
|
||||||
visibility: folder.visibility ?? DocumentVisibility.EVERYONE,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [folder, form]);
|
|
||||||
|
|
||||||
const onFormSubmit = async (data: TUpdateFolderFormSchema) => {
|
|
||||||
if (!folder) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await updateFolder({
|
|
||||||
id: folder.id,
|
|
||||||
name: data.name,
|
|
||||||
visibility:
|
|
||||||
isTeamContext && !isTemplateFolder
|
|
||||||
? (data.visibility ?? DocumentVisibility.EVERYONE)
|
|
||||||
: DocumentVisibility.EVERYONE,
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`Folder updated successfully`),
|
|
||||||
});
|
|
||||||
|
|
||||||
onOpenChange(false);
|
|
||||||
} catch (err) {
|
|
||||||
const error = AppError.parseError(err);
|
|
||||||
|
|
||||||
if (error.code === AppErrorCode.NOT_FOUND) {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Folder not found`),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Folder Settings</DialogTitle>
|
|
||||||
<DialogDescription>Manage the settings for this folder.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Name</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isTeamContext && !isTemplateFolder && (
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="visibility"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Visibility</FormLabel>
|
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
|
||||||
<FormControl>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select visibility" />
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value={DocumentVisibility.EVERYONE}>Everyone</SelectItem>
|
|
||||||
<SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE}>
|
|
||||||
Managers and above
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value={DocumentVisibility.ADMIN}>Admins only</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="submit">Save Changes</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
158
apps/remix/app/components/dialogs/template-move-dialog.tsx
Normal file
158
apps/remix/app/components/dialogs/template-move-dialog.tsx
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@documenso/ui/primitives/select';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
type TemplateMoveDialogProps = {
|
||||||
|
templateId: number;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (_open: boolean) => void;
|
||||||
|
onMove?: ({
|
||||||
|
templateId,
|
||||||
|
teamUrl,
|
||||||
|
}: {
|
||||||
|
templateId: number;
|
||||||
|
teamUrl: string;
|
||||||
|
}) => Promise<void> | void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TemplateMoveDialog = ({
|
||||||
|
templateId,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onMove,
|
||||||
|
}: TemplateMoveDialogProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
const [selectedTeamId, setSelectedTeamId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const { data: teams, isLoading: isLoadingTeams } = trpc.team.getTeams.useQuery();
|
||||||
|
|
||||||
|
const { mutateAsync: moveTemplate, isPending } = trpc.template.moveTemplateToTeam.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
const team = teams?.find((team) => team.id === selectedTeamId);
|
||||||
|
|
||||||
|
if (team) {
|
||||||
|
await onMove?.({ templateId, teamUrl: team.url });
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Template moved`),
|
||||||
|
description: _(msg`The template has been successfully moved to the selected team.`),
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
const errorMessage = match(error.code)
|
||||||
|
.with(
|
||||||
|
AppErrorCode.NOT_FOUND,
|
||||||
|
() => msg`Template not found or already associated with a team.`,
|
||||||
|
)
|
||||||
|
.with(AppErrorCode.UNAUTHORIZED, () => msg`You are not a member of this team.`)
|
||||||
|
.otherwise(() => msg`An error occurred while moving the template.`);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Error`),
|
||||||
|
description: _(errorMessage),
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 7500,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleOnMove = async () => {
|
||||||
|
if (!selectedTeamId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await moveTemplate({ templateId, teamId: selectedTeamId });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Move Template to Team</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>Select a team to move this template to. This action cannot be undone.</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Select onValueChange={(value) => setSelectedTeamId(Number(value))}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={_(msg`Select a team`)} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{isLoadingTeams ? (
|
||||||
|
<SelectItem value="loading" disabled>
|
||||||
|
<Trans>Loading teams...</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
) : (
|
||||||
|
teams?.map((team) => (
|
||||||
|
<SelectItem key={team.id} value={team.id.toString()}>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Avatar className="h-8 w-8">
|
||||||
|
{team.avatarImageId && (
|
||||||
|
<AvatarImage src={formatAvatarUrl(team.avatarImageId)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AvatarFallback className="text-sm text-gray-400">
|
||||||
|
{team.name.slice(0, 1).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<span>{team.name}</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleOnMove}
|
||||||
|
loading={isPending}
|
||||||
|
disabled={!selectedTeamId || isPending}
|
||||||
|
>
|
||||||
|
{isPending ? <Trans>Moving...</Trans> : <Trans>Move</Trans>}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,213 +0,0 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
|
||||||
import { FolderIcon, HomeIcon, Loader2 } from 'lucide-react';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { useNavigate } from 'react-router';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
||||||
import { FolderType } from '@documenso/lib/types/folder-type';
|
|
||||||
import { 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,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
|
||||||
|
|
||||||
export type TemplateMoveToFolderDialogProps = {
|
|
||||||
templateId: number;
|
|
||||||
templateTitle: string;
|
|
||||||
isOpen: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
currentFolderId?: string | null;
|
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
|
||||||
|
|
||||||
const ZMoveTemplateFormSchema = z.object({
|
|
||||||
folderId: z.string().nullable().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type TMoveTemplateFormSchema = z.infer<typeof ZMoveTemplateFormSchema>;
|
|
||||||
|
|
||||||
export function TemplateMoveToFolderDialog({
|
|
||||||
templateId,
|
|
||||||
templateTitle,
|
|
||||||
isOpen,
|
|
||||||
onOpenChange,
|
|
||||||
currentFolderId,
|
|
||||||
...props
|
|
||||||
}: TemplateMoveToFolderDialogProps) {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const team = useCurrentTeam();
|
|
||||||
|
|
||||||
const form = useForm<TMoveTemplateFormSchema>({
|
|
||||||
resolver: zodResolver(ZMoveTemplateFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
folderId: currentFolderId ?? null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFolders.useQuery(
|
|
||||||
{
|
|
||||||
parentId: currentFolderId ?? null,
|
|
||||||
type: FolderType.TEMPLATE,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: isOpen,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const { mutateAsync: moveTemplateToFolder } = trpc.folder.moveTemplateToFolder.useMutation();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) {
|
|
||||||
form.reset();
|
|
||||||
} else {
|
|
||||||
form.reset({ folderId: currentFolderId ?? null });
|
|
||||||
}
|
|
||||||
}, [isOpen, currentFolderId, form]);
|
|
||||||
|
|
||||||
const onSubmit = async (data: TMoveTemplateFormSchema) => {
|
|
||||||
try {
|
|
||||||
await moveTemplateToFolder({
|
|
||||||
templateId,
|
|
||||||
folderId: data.folderId ?? null,
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`Template moved`),
|
|
||||||
description: _(msg`The template has been moved successfully.`),
|
|
||||||
variant: 'default',
|
|
||||||
});
|
|
||||||
|
|
||||||
onOpenChange(false);
|
|
||||||
|
|
||||||
const templatesPath = formatTemplatesPath(team.url);
|
|
||||||
|
|
||||||
if (data.folderId) {
|
|
||||||
void navigate(`${templatesPath}/f/${data.folderId}`);
|
|
||||||
} else {
|
|
||||||
void navigate(templatesPath);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const error = AppError.parseError(err);
|
|
||||||
|
|
||||||
if (error.code === AppErrorCode.NOT_FOUND) {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Error`),
|
|
||||||
description: _(msg`The folder you are trying to move the template to does not exist.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`Error`),
|
|
||||||
description: _(msg`An error occurred while moving the template.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog {...props} open={isOpen} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Move Template to Folder</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription>
|
|
||||||
<Trans>Move "{templateTitle}" to a folder</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-4 flex flex-col gap-y-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="folderId"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Folder</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{isFoldersLoading ? (
|
|
||||||
<div className="flex h-10 items-center justify-center">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant={field.value === null ? 'default' : 'outline'}
|
|
||||||
className="w-full justify-start"
|
|
||||||
onClick={() => field.onChange(null)}
|
|
||||||
disabled={currentFolderId === null}
|
|
||||||
>
|
|
||||||
<HomeIcon className="mr-2 h-4 w-4" />
|
|
||||||
<Trans>Root (No Folder)</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{folders?.data?.map((folder) => (
|
|
||||||
<Button
|
|
||||||
key={folder.id}
|
|
||||||
type="button"
|
|
||||||
variant={field.value === folder.id ? 'default' : 'outline'}
|
|
||||||
className="w-full justify-start"
|
|
||||||
onClick={() => field.onChange(folder.id)}
|
|
||||||
disabled={currentFolderId === folder.id}
|
|
||||||
>
|
|
||||||
<FolderIcon className="mr-2 h-4 w-4" />
|
|
||||||
{folder.name}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button type="submit" disabled={isFoldersLoading || form.formState.isSubmitting}>
|
|
||||||
<Trans>Move</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -30,7 +30,7 @@ import {
|
|||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export type TokenDeleteDialogProps = {
|
export type TokenDeleteDialogProps = {
|
||||||
token: Pick<ApiToken, 'id' | 'name'>;
|
token: Pick<ApiToken, 'id' | 'name'>;
|
||||||
@ -42,7 +42,7 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe
|
|||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const team = useCurrentTeam();
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import { useForm } from 'react-hook-form';
|
|||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { ZCreateWebhookRequestSchema } from '@documenso/trpc/server/webhook-router/schema';
|
import { ZCreateWebhookMutationSchema } from '@documenso/trpc/server/webhook-router/schema';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@ -34,11 +34,11 @@ import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
|||||||
import { Switch } from '@documenso/ui/primitives/switch';
|
import { Switch } from '@documenso/ui/primitives/switch';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
import { WebhookMultiSelectCombobox } from '../general/webhook-multiselect-combobox';
|
import { WebhookMultiSelectCombobox } from '../general/webhook-multiselect-combobox';
|
||||||
|
|
||||||
const ZCreateWebhookFormSchema = ZCreateWebhookRequestSchema.omit({ teamId: true });
|
const ZCreateWebhookFormSchema = ZCreateWebhookMutationSchema.omit({ teamId: true });
|
||||||
|
|
||||||
type TCreateWebhookFormSchema = z.infer<typeof ZCreateWebhookFormSchema>;
|
type TCreateWebhookFormSchema = z.infer<typeof ZCreateWebhookFormSchema>;
|
||||||
|
|
||||||
@ -50,7 +50,7 @@ export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogPr
|
|||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const team = useCurrentTeam();
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
@ -78,7 +78,7 @@ export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogPr
|
|||||||
eventTriggers,
|
eventTriggers,
|
||||||
secret,
|
secret,
|
||||||
webhookUrl,
|
webhookUrl,
|
||||||
teamId: team.id,
|
teamId: team?.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
|||||||
@ -30,7 +30,7 @@ import {
|
|||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export type WebhookDeleteDialogProps = {
|
export type WebhookDeleteDialogProps = {
|
||||||
webhook: Pick<Webhook, 'id' | 'webhookUrl'>;
|
webhook: Pick<Webhook, 'id' | 'webhookUrl'>;
|
||||||
@ -42,7 +42,7 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr
|
|||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const team = useCurrentTeam();
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
@ -67,7 +67,7 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr
|
|||||||
|
|
||||||
const onSubmit = async () => {
|
const onSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
await deleteWebhook({ id: webhook.id, teamId: team.id });
|
await deleteWebhook({ id: webhook.id, teamId: team?.id });
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Webhook deleted`),
|
title: _(msg`Webhook deleted`),
|
||||||
|
|||||||
@ -1,355 +0,0 @@
|
|||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { DocumentDistributionMethod } from '@prisma/client';
|
|
||||||
import { InfoIcon } from 'lucide-react';
|
|
||||||
import type { Control } from 'react-hook-form';
|
|
||||||
import { useFormContext } from 'react-hook-form';
|
|
||||||
|
|
||||||
import { DATE_FORMATS } from '@documenso/lib/constants/date-formats';
|
|
||||||
import { DOCUMENT_SIGNATURE_TYPES } from '@documenso/lib/constants/document';
|
|
||||||
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
|
|
||||||
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
|
||||||
import { DocumentEmailCheckboxes } from '@documenso/ui/components/document/document-email-checkboxes';
|
|
||||||
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
|
|
||||||
import { Combobox } from '@documenso/ui/primitives/combobox';
|
|
||||||
import {
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@documenso/ui/primitives/select';
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
|
||||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
|
||||||
|
|
||||||
import { useConfigureDocument } from './configure-document-context';
|
|
||||||
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
|
|
||||||
|
|
||||||
interface ConfigureDocumentAdvancedSettingsProps {
|
|
||||||
control: Control<TConfigureEmbedFormSchema>;
|
|
||||||
isSubmitting: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ConfigureDocumentAdvancedSettings = ({
|
|
||||||
control,
|
|
||||||
isSubmitting,
|
|
||||||
}: ConfigureDocumentAdvancedSettingsProps) => {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
|
|
||||||
const form = useFormContext<TConfigureEmbedFormSchema>();
|
|
||||||
const { features } = useConfigureDocument();
|
|
||||||
|
|
||||||
const { watch, setValue } = form;
|
|
||||||
|
|
||||||
// Lift watch() calls to reduce re-renders
|
|
||||||
const distributionMethod = watch('meta.distributionMethod');
|
|
||||||
const emailSettings = watch('meta.emailSettings');
|
|
||||||
const isEmailDistribution = distributionMethod === DocumentDistributionMethod.EMAIL;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h3 className="text-foreground mb-1 text-lg font-medium">
|
|
||||||
<Trans>Advanced Settings</Trans>
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mb-6 text-sm">
|
|
||||||
<Trans>Configure additional options and preferences</Trans>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Tabs defaultValue="general">
|
|
||||||
<TabsList className="mb-6 inline-flex">
|
|
||||||
<TabsTrigger value="general" className="px-4">
|
|
||||||
<Trans>General</Trans>
|
|
||||||
</TabsTrigger>
|
|
||||||
|
|
||||||
{features.allowConfigureCommunication && (
|
|
||||||
<TabsTrigger value="communication" className="px-4">
|
|
||||||
<Trans>Communication</Trans>
|
|
||||||
</TabsTrigger>
|
|
||||||
)}
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="general" className="mt-0">
|
|
||||||
<div className="flex flex-col space-y-6">
|
|
||||||
{/* <FormField
|
|
||||||
control={control}
|
|
||||||
name="meta.externalId"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel className="flex flex-row items-center">
|
|
||||||
<Trans>External ID</Trans>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<InfoIcon className="mx-2 h-4 w-4" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent className="text-muted-foreground max-w-xs">
|
|
||||||
<Trans>
|
|
||||||
Add an external ID to the document. This can be used to identify the
|
|
||||||
document in external systems.
|
|
||||||
</Trans>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input className="bg-background" {...field} disabled={isSubmitting} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/> */}
|
|
||||||
|
|
||||||
{features.allowConfigureSignatureTypes && (
|
|
||||||
<FormField
|
|
||||||
control={control}
|
|
||||||
name="meta.signatureTypes"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Allowed Signature Types</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<MultiSelectCombobox
|
|
||||||
options={Object.values(DOCUMENT_SIGNATURE_TYPES).map((option) => ({
|
|
||||||
label: _(option.label),
|
|
||||||
value: option.value,
|
|
||||||
}))}
|
|
||||||
selectedValues={field.value}
|
|
||||||
onChange={field.onChange}
|
|
||||||
className="bg-background w-full"
|
|
||||||
emptySelectionPlaceholder="Select signature types"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{features.allowConfigureLanguage && (
|
|
||||||
<FormField
|
|
||||||
control={control}
|
|
||||||
name="meta.language"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Language</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Select {...field} onValueChange={field.onChange} disabled={isSubmitting}>
|
|
||||||
<SelectTrigger className="bg-background">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{Object.entries(SUPPORTED_LANGUAGES).map(([code, language]) => (
|
|
||||||
<SelectItem key={code} value={code}>
|
|
||||||
{language.full}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{features.allowConfigureDateFormat && (
|
|
||||||
<FormField
|
|
||||||
control={control}
|
|
||||||
name="meta.dateFormat"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Date Format</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Select {...field} onValueChange={field.onChange} disabled={isSubmitting}>
|
|
||||||
<SelectTrigger className="bg-background">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{DATE_FORMATS.map((format) => (
|
|
||||||
<SelectItem key={format.key} value={format.value}>
|
|
||||||
{format.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{features.allowConfigureTimezone && (
|
|
||||||
<FormField
|
|
||||||
control={control}
|
|
||||||
name="meta.timezone"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Time Zone</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Combobox
|
|
||||||
className="bg-background"
|
|
||||||
options={TIME_ZONES}
|
|
||||||
{...field}
|
|
||||||
onChange={(value) => value && field.onChange(value)}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{features.allowConfigureRedirectUrl && (
|
|
||||||
<FormField
|
|
||||||
control={control}
|
|
||||||
name="meta.redirectUrl"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel className="flex flex-row items-center">
|
|
||||||
<Trans>Redirect URL</Trans>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<InfoIcon className="mx-2 h-4 w-4" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent className="text-muted-foreground max-w-xs">
|
|
||||||
<Trans>
|
|
||||||
Add a URL to redirect the user to once the document is signed
|
|
||||||
</Trans>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input className="bg-background" {...field} disabled={isSubmitting} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{features.allowConfigureCommunication && (
|
|
||||||
<TabsContent value="communication" className="mt-0">
|
|
||||||
<div className="flex flex-col space-y-6">
|
|
||||||
<FormField
|
|
||||||
control={control}
|
|
||||||
name="meta.distributionMethod"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Distribution Method</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
|
|
||||||
<FormControl>
|
|
||||||
<Select {...field} onValueChange={field.onChange} disabled={isSubmitting}>
|
|
||||||
<SelectTrigger className="bg-background">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value={DocumentDistributionMethod.EMAIL}>
|
|
||||||
<Trans>Email</Trans>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value={DocumentDistributionMethod.NONE}>
|
|
||||||
<Trans>None</Trans>
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormDescription>
|
|
||||||
<Trans>
|
|
||||||
Choose how to distribute your document to recipients. Email will send
|
|
||||||
notifications, None will generate signing links for manual distribution.
|
|
||||||
</Trans>
|
|
||||||
</FormDescription>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<fieldset
|
|
||||||
className="flex flex-col space-y-6 disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
disabled={!isEmailDistribution}
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={control}
|
|
||||||
name="meta.subject"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel htmlFor="subject">
|
|
||||||
<Trans>
|
|
||||||
Subject <span className="text-muted-foreground">(Optional)</span>
|
|
||||||
</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
id="subject"
|
|
||||||
className="bg-background mt-2"
|
|
||||||
disabled={isSubmitting || !isEmailDistribution}
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={control}
|
|
||||||
name="meta.message"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel htmlFor="message">
|
|
||||||
<Trans>
|
|
||||||
Message <span className="text-muted-foreground">(Optional)</span>
|
|
||||||
</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Textarea
|
|
||||||
id="message"
|
|
||||||
className="bg-background mt-2 h-32 resize-none"
|
|
||||||
disabled={isSubmitting || !isEmailDistribution}
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DocumentSendEmailMessageHelper />
|
|
||||||
|
|
||||||
<DocumentEmailCheckboxes
|
|
||||||
className={`mt-2 ${!isEmailDistribution ? 'pointer-events-none' : ''}`}
|
|
||||||
value={emailSettings}
|
|
||||||
onChange={(value) => setValue('meta.emailSettings', value)}
|
|
||||||
/>
|
|
||||||
</fieldset>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
)}
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,68 +0,0 @@
|
|||||||
import { createContext, useContext } from 'react';
|
|
||||||
|
|
||||||
export type ConfigureDocumentContext = {
|
|
||||||
// General
|
|
||||||
isTemplate: boolean;
|
|
||||||
isPersisted: boolean;
|
|
||||||
// Features
|
|
||||||
features: {
|
|
||||||
allowConfigureSignatureTypes?: boolean;
|
|
||||||
allowConfigureLanguage?: boolean;
|
|
||||||
allowConfigureDateFormat?: boolean;
|
|
||||||
allowConfigureTimezone?: boolean;
|
|
||||||
allowConfigureRedirectUrl?: boolean;
|
|
||||||
allowConfigureCommunication?: boolean;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const ConfigureDocumentContext = createContext<ConfigureDocumentContext | null>(null);
|
|
||||||
|
|
||||||
export type ConfigureDocumentProviderProps = {
|
|
||||||
isTemplate?: boolean;
|
|
||||||
isPersisted?: boolean;
|
|
||||||
features: {
|
|
||||||
allowConfigureSignatureTypes?: boolean;
|
|
||||||
allowConfigureLanguage?: boolean;
|
|
||||||
allowConfigureDateFormat?: boolean;
|
|
||||||
allowConfigureTimezone?: boolean;
|
|
||||||
allowConfigureRedirectUrl?: boolean;
|
|
||||||
allowConfigureCommunication?: boolean;
|
|
||||||
};
|
|
||||||
children: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ConfigureDocumentProvider = ({
|
|
||||||
isTemplate,
|
|
||||||
isPersisted,
|
|
||||||
features,
|
|
||||||
children,
|
|
||||||
}: ConfigureDocumentProviderProps) => {
|
|
||||||
return (
|
|
||||||
<ConfigureDocumentContext.Provider
|
|
||||||
value={{
|
|
||||||
isTemplate: isTemplate ?? false,
|
|
||||||
isPersisted: isPersisted ?? false,
|
|
||||||
features: {
|
|
||||||
allowConfigureSignatureTypes: features.allowConfigureSignatureTypes ?? true,
|
|
||||||
allowConfigureLanguage: features.allowConfigureLanguage ?? true,
|
|
||||||
allowConfigureDateFormat: features.allowConfigureDateFormat ?? true,
|
|
||||||
allowConfigureTimezone: features.allowConfigureTimezone ?? true,
|
|
||||||
allowConfigureRedirectUrl: features.allowConfigureRedirectUrl ?? true,
|
|
||||||
allowConfigureCommunication: features.allowConfigureCommunication ?? true,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</ConfigureDocumentContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useConfigureDocument = () => {
|
|
||||||
const context = useContext(ConfigureDocumentContext);
|
|
||||||
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useConfigureDocument must be used within a ConfigureDocumentProvider');
|
|
||||||
}
|
|
||||||
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
@ -1,413 +0,0 @@
|
|||||||
import { useCallback, useRef } from 'react';
|
|
||||||
|
|
||||||
import type { DropResult, SensorAPI } from '@hello-pangea/dnd';
|
|
||||||
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { GripVertical, HelpCircle, Plus, Trash } from 'lucide-react';
|
|
||||||
import { nanoid } from 'nanoid';
|
|
||||||
import type { Control } from 'react-hook-form';
|
|
||||||
import { useFieldArray, useFormContext, useFormState } from 'react-hook-form';
|
|
||||||
|
|
||||||
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
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 { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
|
||||||
|
|
||||||
import { useConfigureDocument } from './configure-document-context';
|
|
||||||
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
|
|
||||||
|
|
||||||
// Define a type for signer items
|
|
||||||
type SignerItem = TConfigureEmbedFormSchema['signers'][number];
|
|
||||||
|
|
||||||
export interface ConfigureDocumentRecipientsProps {
|
|
||||||
control: Control<TConfigureEmbedFormSchema>;
|
|
||||||
isSubmitting: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ConfigureDocumentRecipients = ({
|
|
||||||
control,
|
|
||||||
isSubmitting,
|
|
||||||
}: ConfigureDocumentRecipientsProps) => {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { isTemplate } = useConfigureDocument();
|
|
||||||
|
|
||||||
const $sensorApi = useRef<SensorAPI | null>(null);
|
|
||||||
|
|
||||||
const {
|
|
||||||
fields: signers,
|
|
||||||
append: appendSigner,
|
|
||||||
remove: removeSigner,
|
|
||||||
replace,
|
|
||||||
move,
|
|
||||||
} = useFieldArray({
|
|
||||||
control,
|
|
||||||
name: 'signers',
|
|
||||||
});
|
|
||||||
|
|
||||||
const { getValues, watch, setValue } = useFormContext<TConfigureEmbedFormSchema>();
|
|
||||||
|
|
||||||
const signingOrder = watch('meta.signingOrder');
|
|
||||||
|
|
||||||
const { errors } = useFormState({
|
|
||||||
control,
|
|
||||||
});
|
|
||||||
|
|
||||||
const onAddSigner = useCallback(() => {
|
|
||||||
const signerNumber = signers.length + 1;
|
|
||||||
const recipientSigningOrder =
|
|
||||||
signers.length > 0 ? (signers[signers.length - 1]?.signingOrder || 0) + 1 : 1;
|
|
||||||
|
|
||||||
appendSigner({
|
|
||||||
formId: nanoid(8),
|
|
||||||
name: isTemplate ? `Recipient ${signerNumber}` : '',
|
|
||||||
email: isTemplate ? `recipient.${signerNumber}@document.com` : '',
|
|
||||||
role: RecipientRole.SIGNER,
|
|
||||||
signingOrder:
|
|
||||||
signingOrder === DocumentSigningOrder.SEQUENTIAL ? recipientSigningOrder : undefined,
|
|
||||||
});
|
|
||||||
}, [appendSigner, signers]);
|
|
||||||
|
|
||||||
const isSigningOrderEnabled = signingOrder === DocumentSigningOrder.SEQUENTIAL;
|
|
||||||
|
|
||||||
const handleSigningOrderChange = useCallback(
|
|
||||||
(index: number, newOrderString: string) => {
|
|
||||||
const trimmedOrderString = newOrderString.trim();
|
|
||||||
if (!trimmedOrderString) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newOrder = Number(trimmedOrderString);
|
|
||||||
if (!Number.isInteger(newOrder) || newOrder < 1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get current form values to preserve unsaved input data
|
|
||||||
const currentSigners = getValues('signers') || [...signers];
|
|
||||||
const signer = currentSigners[index];
|
|
||||||
|
|
||||||
// Remove signer from current position and insert at new position
|
|
||||||
const remainingSigners = currentSigners.filter((_: unknown, idx: number) => idx !== index);
|
|
||||||
const newPosition = Math.min(Math.max(0, newOrder - 1), currentSigners.length - 1);
|
|
||||||
remainingSigners.splice(newPosition, 0, signer);
|
|
||||||
|
|
||||||
// Update signing order for each item
|
|
||||||
const updatedSigners = remainingSigners.map((s: SignerItem, idx: number) => ({
|
|
||||||
...s,
|
|
||||||
signingOrder: signingOrder === DocumentSigningOrder.SEQUENTIAL ? idx + 1 : undefined,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Update the form
|
|
||||||
replace(updatedSigners);
|
|
||||||
},
|
|
||||||
[signers, replace, getValues],
|
|
||||||
);
|
|
||||||
|
|
||||||
const onDragEnd = useCallback(
|
|
||||||
(result: DropResult) => {
|
|
||||||
if (!result.destination) return;
|
|
||||||
|
|
||||||
// Use the move function from useFieldArray which preserves input values
|
|
||||||
move(result.source.index, result.destination.index);
|
|
||||||
|
|
||||||
// Update signing orders after move
|
|
||||||
const currentSigners = getValues('signers');
|
|
||||||
const updatedSigners = currentSigners.map((signer: SignerItem, index: number) => ({
|
|
||||||
...signer,
|
|
||||||
signingOrder: signingOrder === DocumentSigningOrder.SEQUENTIAL ? index + 1 : undefined,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Update the form with new ordering
|
|
||||||
replace(updatedSigners);
|
|
||||||
},
|
|
||||||
[move, replace, getValues],
|
|
||||||
);
|
|
||||||
|
|
||||||
const onSigningOrderChange = (signingOrder: DocumentSigningOrder) => {
|
|
||||||
setValue('meta.signingOrder', signingOrder);
|
|
||||||
|
|
||||||
if (signingOrder === DocumentSigningOrder.SEQUENTIAL) {
|
|
||||||
signers.forEach((_signer, index) => {
|
|
||||||
setValue(`signers.${index}.signingOrder`, index + 1);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h3 className="text-foreground mb-1 text-lg font-medium">
|
|
||||||
<Trans>Recipients</Trans>
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mb-6 text-sm">
|
|
||||||
<Trans>Add signers and configure signing preferences</Trans>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={control}
|
|
||||||
name="meta.signingOrder"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="mb-6 flex flex-row items-center space-x-2 space-y-0">
|
|
||||||
<FormControl>
|
|
||||||
<Checkbox
|
|
||||||
{...field}
|
|
||||||
id="signingOrder"
|
|
||||||
checked={field.value === DocumentSigningOrder.SEQUENTIAL}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
onSigningOrderChange(
|
|
||||||
checked ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormLabel
|
|
||||||
htmlFor="signingOrder"
|
|
||||||
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
|
||||||
>
|
|
||||||
<Trans>Enable signing order</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={control}
|
|
||||||
name="meta.allowDictateNextSigner"
|
|
||||||
render={({ field: { value, ...field } }) => (
|
|
||||||
<FormItem className="mb-6 flex flex-row items-center space-x-2 space-y-0">
|
|
||||||
<FormControl>
|
|
||||||
<Checkbox
|
|
||||||
{...field}
|
|
||||||
id="allowDictateNextSigner"
|
|
||||||
checked={value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
disabled={isSubmitting || !isSigningOrderEnabled}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<div className="flex items-center">
|
|
||||||
<FormLabel
|
|
||||||
htmlFor="allowDictateNextSigner"
|
|
||||||
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
|
||||||
>
|
|
||||||
<Trans>Allow signers to dictate next signer</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<span className="text-muted-foreground ml-1 cursor-help">
|
|
||||||
<HelpCircle className="h-3.5 w-3.5" />
|
|
||||||
</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent className="max-w-80 p-4">
|
|
||||||
<p>
|
|
||||||
<Trans>
|
|
||||||
When enabled, signers can choose who should sign next in the sequence instead
|
|
||||||
of following the predefined order.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DragDropContext
|
|
||||||
onDragEnd={onDragEnd}
|
|
||||||
sensors={[
|
|
||||||
(api: SensorAPI) => {
|
|
||||||
$sensorApi.current = api;
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Droppable droppableId="signers">
|
|
||||||
{(provided) => (
|
|
||||||
<div {...provided.droppableProps} ref={provided.innerRef} className="space-y-2">
|
|
||||||
{signers.map((signer, index) => (
|
|
||||||
<Draggable
|
|
||||||
key={signer.id}
|
|
||||||
draggableId={signer.id}
|
|
||||||
index={index}
|
|
||||||
isDragDisabled={!isSigningOrderEnabled || isSubmitting || signer.disabled}
|
|
||||||
>
|
|
||||||
{(provided, snapshot) => (
|
|
||||||
<fieldset
|
|
||||||
ref={provided.innerRef}
|
|
||||||
{...provided.draggableProps}
|
|
||||||
{...provided.dragHandleProps}
|
|
||||||
disabled={signer.disabled}
|
|
||||||
className={cn('py-1', {
|
|
||||||
'bg-widget-foreground pointer-events-none rounded-md pt-2':
|
|
||||||
snapshot.isDragging,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
className={cn('flex items-end gap-2 pb-2', {
|
|
||||||
'border-destructive/50': errors?.signers?.[index],
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{isSigningOrderEnabled && (
|
|
||||||
<FormField
|
|
||||||
control={control}
|
|
||||||
name={`signers.${index}.signingOrder`}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem
|
|
||||||
className={cn('flex w-16 flex-none items-center gap-x-1', {
|
|
||||||
'mb-6':
|
|
||||||
errors?.signers?.[index] &&
|
|
||||||
!errors?.signers?.[index]?.signingOrder,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<GripVertical className="h-5 w-5 flex-shrink-0 opacity-40" />
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
max={signers.length}
|
|
||||||
min={1}
|
|
||||||
className="w-full text-center [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
|
||||||
{...field}
|
|
||||||
disabled={isSubmitting || snapshot.isDragging}
|
|
||||||
onChange={(e) => {
|
|
||||||
field.onChange(e);
|
|
||||||
}}
|
|
||||||
onBlur={(e) => {
|
|
||||||
field.onBlur();
|
|
||||||
handleSigningOrderChange(index, e.target.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={control}
|
|
||||||
name={`signers.${index}.name`}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem
|
|
||||||
className={cn('flex-1', {
|
|
||||||
'mb-6': errors?.signers?.[index] && !errors?.signers?.[index]?.name,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<FormLabel className="sr-only">
|
|
||||||
<Trans>Name</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder={_(msg`Name`)}
|
|
||||||
className="w-full"
|
|
||||||
{...field}
|
|
||||||
disabled={isSubmitting || snapshot.isDragging}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={control}
|
|
||||||
name={`signers.${index}.email`}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem
|
|
||||||
className={cn('flex-1', {
|
|
||||||
'mb-6':
|
|
||||||
errors?.signers?.[index] && !errors?.signers?.[index]?.email,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<FormLabel className="sr-only">
|
|
||||||
<Trans>Email</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
placeholder={_(msg`Email`)}
|
|
||||||
className="w-full"
|
|
||||||
{...field}
|
|
||||||
disabled={isSubmitting || snapshot.isDragging}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={control}
|
|
||||||
name={`signers.${index}.role`}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem
|
|
||||||
className={cn('flex-none', {
|
|
||||||
'mb-6': errors?.signers?.[index] && !errors?.signers?.[index]?.role,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<FormLabel className="sr-only">
|
|
||||||
<Trans>Role</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<RecipientRoleSelect
|
|
||||||
{...field}
|
|
||||||
isAssistantEnabled={isSigningOrderEnabled}
|
|
||||||
onValueChange={field.onChange}
|
|
||||||
disabled={isSubmitting || snapshot.isDragging || signer.disabled}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
disabled={
|
|
||||||
isSubmitting ||
|
|
||||||
signers.length === 1 ||
|
|
||||||
snapshot.isDragging ||
|
|
||||||
signer.disabled
|
|
||||||
}
|
|
||||||
onClick={() => removeSigner(index)}
|
|
||||||
>
|
|
||||||
<Trash className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</motion.div>
|
|
||||||
</fieldset>
|
|
||||||
)}
|
|
||||||
</Draggable>
|
|
||||||
))}
|
|
||||||
{provided.placeholder}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Droppable>
|
|
||||||
</DragDropContext>
|
|
||||||
|
|
||||||
<div className="mt-4 flex justify-end">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="w-auto"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
onClick={onAddSigner}
|
|
||||||
>
|
|
||||||
<Plus className="-ml-1 mr-2 h-5 w-5" />
|
|
||||||
<Trans>Add Signer</Trans>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,238 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { Cloud, FileText, Loader, X } from 'lucide-react';
|
|
||||||
import { useDropzone } from 'react-dropzone';
|
|
||||||
import { useFormContext } from 'react-hook-form';
|
|
||||||
|
|
||||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { useConfigureDocument } from './configure-document-context';
|
|
||||||
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
|
|
||||||
|
|
||||||
export interface ConfigureDocumentUploadProps {
|
|
||||||
isSubmitting?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocumentUploadProps) => {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { isPersisted } = useConfigureDocument();
|
|
||||||
|
|
||||||
const form = useFormContext<TConfigureEmbedFormSchema>();
|
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
// Watch the documentData field from the form
|
|
||||||
const documentData = form.watch('documentData');
|
|
||||||
|
|
||||||
const onFileDrop = async (acceptedFiles: File[]) => {
|
|
||||||
try {
|
|
||||||
const file = acceptedFiles[0];
|
|
||||||
|
|
||||||
if (!file) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
// Convert file to UInt8Array
|
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
|
||||||
const uint8Array = new Uint8Array(arrayBuffer);
|
|
||||||
|
|
||||||
// Store file metadata and UInt8Array in form data
|
|
||||||
form.setValue('documentData', {
|
|
||||||
name: file.name,
|
|
||||||
type: file.type,
|
|
||||||
size: file.size,
|
|
||||||
data: uint8Array, // Store as UInt8Array
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auto-populate title if it's empty
|
|
||||||
const currentTitle = form.getValues('title');
|
|
||||||
|
|
||||||
if (!currentTitle) {
|
|
||||||
// Get filename without extension
|
|
||||||
const fileNameWithoutExtension = file.name.replace(/\.[^/.]+$/, '');
|
|
||||||
form.setValue('title', fileNameWithoutExtension);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error uploading file', error);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`Error uploading file`),
|
|
||||||
description: _(msg`There was an error uploading your file. Please try again.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDropRejected = () => {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Your document failed to upload.`),
|
|
||||||
description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`),
|
|
||||||
duration: 5000,
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onRemoveFile = () => {
|
|
||||||
if (isPersisted) {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Cannot remove document`),
|
|
||||||
description: _(msg`The document is already saved and cannot be changed.`),
|
|
||||||
duration: 5000,
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
form.unregister('documentData');
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatFileSize = (bytes: number) => {
|
|
||||||
if (bytes === 0) return '0 Bytes';
|
|
||||||
|
|
||||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
||||||
return `${parseFloat((bytes / Math.pow(1024, i)).toFixed(2))} ${sizes[i]}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
|
||||||
accept: {
|
|
||||||
'application/pdf': ['.pdf'],
|
|
||||||
},
|
|
||||||
maxSize: APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024,
|
|
||||||
multiple: false,
|
|
||||||
disabled: isSubmitting || isLoading || isPersisted,
|
|
||||||
onDrop: (files) => {
|
|
||||||
void onFileDrop(files);
|
|
||||||
},
|
|
||||||
onDropRejected,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="documentData"
|
|
||||||
render={() => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel required>
|
|
||||||
<Trans>Upload Document</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
{!documentData ? (
|
|
||||||
<div className="relative">
|
|
||||||
<FormControl>
|
|
||||||
<div
|
|
||||||
{...getRootProps()}
|
|
||||||
className={cn(
|
|
||||||
'border-border bg-background relative flex min-h-[160px] cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed transition',
|
|
||||||
{
|
|
||||||
'border-primary/50 bg-primary/5': isDragActive,
|
|
||||||
'hover:bg-muted/30':
|
|
||||||
!isDragActive && !isSubmitting && !isLoading && !isPersisted,
|
|
||||||
'cursor-not-allowed opacity-60': isSubmitting || isLoading || isPersisted,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<input {...getInputProps()} />
|
|
||||||
|
|
||||||
<div className="flex flex-col items-center justify-center gap-y-2 px-4 py-4 text-center">
|
|
||||||
<Cloud
|
|
||||||
className={cn('h-10 w-10', {
|
|
||||||
'text-primary': isDragActive,
|
|
||||||
'text-muted-foreground': !isDragActive,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={cn('flex flex-col space-y-1', {
|
|
||||||
'text-primary': isDragActive,
|
|
||||||
'text-muted-foreground': !isDragActive,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<p className="text-sm font-medium">
|
|
||||||
{isDragActive ? (
|
|
||||||
<Trans>Drop your document here</Trans>
|
|
||||||
) : isPersisted ? (
|
|
||||||
<Trans>Document is already uploaded</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>Drag and drop or click to upload</Trans>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs">
|
|
||||||
{isPersisted ? (
|
|
||||||
<Trans>This document cannot be changed</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>
|
|
||||||
.PDF documents accepted (max {APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB)
|
|
||||||
</Trans>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
{isLoading && (
|
|
||||||
<div className="bg-background/50 absolute inset-0 flex items-center justify-center rounded-lg">
|
|
||||||
<Loader className="text-muted-foreground h-10 w-10 animate-spin" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="mt-2 rounded-lg border p-4">
|
|
||||||
<div className="flex items-center gap-x-4">
|
|
||||||
<div className="bg-primary/10 text-primary flex h-12 w-12 items-center justify-center rounded-md">
|
|
||||||
<FileText className="h-6 w-6" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="text-sm font-medium">{documentData.name}</div>
|
|
||||||
<div className="text-muted-foreground text-xs">
|
|
||||||
{formatFileSize(documentData.size)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!isPersisted && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={onRemoveFile}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
className="h-8 w-8 p-0"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,137 +0,0 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { DocumentDistributionMethod, DocumentSigningOrder, RecipientRole } from '@prisma/client';
|
|
||||||
import { nanoid } from 'nanoid';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
|
|
||||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
|
||||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
|
||||||
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
|
|
||||||
import { ConfigureDocumentAdvancedSettings } from './configure-document-advanced-settings';
|
|
||||||
import { useConfigureDocument } from './configure-document-context';
|
|
||||||
import { ConfigureDocumentRecipients } from './configure-document-recipients';
|
|
||||||
import { ConfigureDocumentUpload } from './configure-document-upload';
|
|
||||||
import {
|
|
||||||
type TConfigureEmbedFormSchema,
|
|
||||||
ZConfigureEmbedFormSchema,
|
|
||||||
} from './configure-document-view.types';
|
|
||||||
|
|
||||||
export interface ConfigureDocumentViewProps {
|
|
||||||
onSubmit: (data: TConfigureEmbedFormSchema) => void | Promise<void>;
|
|
||||||
defaultValues?: Partial<TConfigureEmbedFormSchema>;
|
|
||||||
disableUpload?: boolean;
|
|
||||||
isSubmitting?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ConfigureDocumentView = ({
|
|
||||||
onSubmit,
|
|
||||||
defaultValues,
|
|
||||||
disableUpload,
|
|
||||||
}: ConfigureDocumentViewProps) => {
|
|
||||||
const { isTemplate } = useConfigureDocument();
|
|
||||||
|
|
||||||
const form = useForm<TConfigureEmbedFormSchema>({
|
|
||||||
resolver: zodResolver(ZConfigureEmbedFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
title: defaultValues?.title || '',
|
|
||||||
signers: defaultValues?.signers || [
|
|
||||||
{
|
|
||||||
formId: nanoid(8),
|
|
||||||
name: isTemplate ? `Recipient ${1}` : '',
|
|
||||||
email: isTemplate ? `recipient.${1}@document.com` : '',
|
|
||||||
role: RecipientRole.SIGNER,
|
|
||||||
signingOrder: 1,
|
|
||||||
disabled: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
meta: {
|
|
||||||
subject: defaultValues?.meta?.subject || '',
|
|
||||||
message: defaultValues?.meta?.message || '',
|
|
||||||
distributionMethod:
|
|
||||||
defaultValues?.meta?.distributionMethod || DocumentDistributionMethod.EMAIL,
|
|
||||||
emailSettings: defaultValues?.meta?.emailSettings || ZDocumentEmailSettingsSchema.parse({}),
|
|
||||||
dateFormat: defaultValues?.meta?.dateFormat || DEFAULT_DOCUMENT_DATE_FORMAT,
|
|
||||||
timezone: defaultValues?.meta?.timezone || DEFAULT_DOCUMENT_TIME_ZONE,
|
|
||||||
redirectUrl: defaultValues?.meta?.redirectUrl || '',
|
|
||||||
language: defaultValues?.meta?.language || 'en',
|
|
||||||
signatureTypes: defaultValues?.meta?.signatureTypes || [],
|
|
||||||
signingOrder: defaultValues?.meta?.signingOrder || DocumentSigningOrder.PARALLEL,
|
|
||||||
allowDictateNextSigner: defaultValues?.meta?.allowDictateNextSigner || false,
|
|
||||||
externalId: defaultValues?.meta?.externalId || '',
|
|
||||||
},
|
|
||||||
documentData: defaultValues?.documentData,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { control, handleSubmit } = form;
|
|
||||||
|
|
||||||
const isSubmitting = form.formState.isSubmitting;
|
|
||||||
|
|
||||||
const onFormSubmit = handleSubmit(onSubmit);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex w-full flex-col space-y-8">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-foreground mb-1 text-xl font-semibold">
|
|
||||||
{isTemplate ? <Trans>Configure Template</Trans> : <Trans>Configure Document</Trans>}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
{isTemplate ? (
|
|
||||||
<Trans>Set up your template properties and recipient information</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>Set up your document properties and recipient information</Trans>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<div className="flex flex-col space-y-8">
|
|
||||||
<div>
|
|
||||||
<FormField
|
|
||||||
control={control}
|
|
||||||
name="title"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel required>
|
|
||||||
<Trans>Title</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} disabled={isSubmitting} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!disableUpload && <ConfigureDocumentUpload isSubmitting={isSubmitting} />}
|
|
||||||
<ConfigureDocumentRecipients control={control} isSubmitting={isSubmitting} />
|
|
||||||
<ConfigureDocumentAdvancedSettings control={control} isSubmitting={isSubmitting} />
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={onFormSubmit}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
>
|
|
||||||
<Trans>Continue</Trans>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
|
||||||
import { DocumentDistributionMethod } from '@documenso/prisma/generated/types';
|
|
||||||
import {
|
|
||||||
ZDocumentMetaDateFormatSchema,
|
|
||||||
ZDocumentMetaLanguageSchema,
|
|
||||||
} from '@documenso/trpc/server/document-router/schema';
|
|
||||||
|
|
||||||
// Define the schema for configuration
|
|
||||||
export type TConfigureEmbedFormSchema = z.infer<typeof ZConfigureEmbedFormSchema>;
|
|
||||||
|
|
||||||
export const ZConfigureEmbedFormSchema = z.object({
|
|
||||||
title: z.string().min(1, { message: 'Title is required' }),
|
|
||||||
signers: z
|
|
||||||
.array(
|
|
||||||
z.object({
|
|
||||||
nativeId: z.number().optional(),
|
|
||||||
formId: z.string(),
|
|
||||||
name: z.string().min(1, { message: 'Name is required' }),
|
|
||||||
email: z.string().email('Invalid email address'),
|
|
||||||
role: z.enum(['SIGNER', 'CC', 'APPROVER', 'VIEWER', 'ASSISTANT']),
|
|
||||||
signingOrder: z.number().optional(),
|
|
||||||
disabled: z.boolean().optional(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.min(1, { message: 'At least one signer is required' }),
|
|
||||||
meta: z.object({
|
|
||||||
subject: z.string().optional(),
|
|
||||||
message: z.string().optional(),
|
|
||||||
distributionMethod: z.nativeEnum(DocumentDistributionMethod),
|
|
||||||
emailSettings: ZDocumentEmailSettingsSchema,
|
|
||||||
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
|
|
||||||
timezone: z.string().min(1, 'Timezone is required'),
|
|
||||||
redirectUrl: z.string().optional(),
|
|
||||||
language: ZDocumentMetaLanguageSchema.optional(),
|
|
||||||
signatureTypes: z.array(z.string()).default([]),
|
|
||||||
signingOrder: z.enum(['SEQUENTIAL', 'PARALLEL']),
|
|
||||||
allowDictateNextSigner: z.boolean().default(false).optional(),
|
|
||||||
externalId: z.string().optional(),
|
|
||||||
}),
|
|
||||||
documentData: z
|
|
||||||
.object({
|
|
||||||
name: z.string(),
|
|
||||||
type: z.string(),
|
|
||||||
size: z.number(),
|
|
||||||
data: z.instanceof(Uint8Array), // UInt8Array can't be directly validated by zod
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
});
|
|
||||||
@ -1,672 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import type { DocumentData, FieldType } from '@prisma/client';
|
|
||||||
import { ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client';
|
|
||||||
import { ChevronsUpDown } from 'lucide-react';
|
|
||||||
import { useFieldArray, useForm } from 'react-hook-form';
|
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
|
||||||
|
|
||||||
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
|
|
||||||
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
|
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
|
||||||
import { type TFieldMetaSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
|
||||||
import { base64 } from '@documenso/lib/universal/base64';
|
|
||||||
import { nanoid } from '@documenso/lib/universal/id';
|
|
||||||
import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers';
|
|
||||||
import { useRecipientColors } from '@documenso/ui/lib/recipient-colors';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { FieldItem } from '@documenso/ui/primitives/document-flow/field-item';
|
|
||||||
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
|
|
||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
|
||||||
import { FieldSelector } from '@documenso/ui/primitives/field-selector';
|
|
||||||
import { Form } from '@documenso/ui/primitives/form/form';
|
|
||||||
import PDFViewer from '@documenso/ui/primitives/pdf-viewer';
|
|
||||||
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
|
|
||||||
import { Sheet, SheetContent, SheetTrigger } from '@documenso/ui/primitives/sheet';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
|
|
||||||
import type { TConfigureFieldsFormSchema } from './configure-fields-view.types';
|
|
||||||
import { FieldAdvancedSettingsDrawer } from './field-advanced-settings-drawer';
|
|
||||||
|
|
||||||
const MIN_HEIGHT_PX = 12;
|
|
||||||
const MIN_WIDTH_PX = 36;
|
|
||||||
|
|
||||||
const DEFAULT_HEIGHT_PX = MIN_HEIGHT_PX * 2.5;
|
|
||||||
const DEFAULT_WIDTH_PX = MIN_WIDTH_PX * 2.5;
|
|
||||||
|
|
||||||
export type ConfigureFieldsViewProps = {
|
|
||||||
configData: TConfigureEmbedFormSchema;
|
|
||||||
documentData?: DocumentData;
|
|
||||||
defaultValues?: Partial<TConfigureFieldsFormSchema>;
|
|
||||||
onBack: (data: TConfigureFieldsFormSchema) => void;
|
|
||||||
onSubmit: (data: TConfigureFieldsFormSchema) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ConfigureFieldsView = ({
|
|
||||||
configData,
|
|
||||||
documentData,
|
|
||||||
defaultValues,
|
|
||||||
onBack,
|
|
||||||
onSubmit,
|
|
||||||
}: ConfigureFieldsViewProps) => {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { isWithinPageBounds, getFieldPosition, getPage } = useDocumentElement();
|
|
||||||
|
|
||||||
// Track if we're on a mobile device
|
|
||||||
const [isMobile, setIsMobile] = useState(false);
|
|
||||||
|
|
||||||
// State for managing the mobile drawer
|
|
||||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
|
||||||
|
|
||||||
// Check for mobile viewport on component mount and resize
|
|
||||||
useEffect(() => {
|
|
||||||
const checkIfMobile = () => {
|
|
||||||
setIsMobile(window.innerWidth < 768);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initial check
|
|
||||||
checkIfMobile();
|
|
||||||
|
|
||||||
// Add resize listener
|
|
||||||
window.addEventListener('resize', checkIfMobile);
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('resize', checkIfMobile);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const normalizedDocumentData = useMemo(() => {
|
|
||||||
if (documentData) {
|
|
||||||
return documentData;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!configData.documentData) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = base64.encode(configData.documentData?.data);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: 'preview',
|
|
||||||
type: 'BYTES_64',
|
|
||||||
data,
|
|
||||||
initialData: data,
|
|
||||||
} satisfies DocumentData;
|
|
||||||
}, [configData.documentData]);
|
|
||||||
|
|
||||||
const recipients = useMemo(() => {
|
|
||||||
return configData.signers.map<Recipient>((signer, index) => ({
|
|
||||||
id: signer.nativeId || index,
|
|
||||||
name: signer.name || '',
|
|
||||||
email: signer.email || '',
|
|
||||||
role: signer.role,
|
|
||||||
signingOrder: signer.signingOrder || null,
|
|
||||||
documentId: null,
|
|
||||||
templateId: null,
|
|
||||||
token: '',
|
|
||||||
documentDeletedAt: null,
|
|
||||||
expired: null,
|
|
||||||
signedAt: null,
|
|
||||||
authOptions: null,
|
|
||||||
rejectionReason: null,
|
|
||||||
sendStatus: signer.disabled ? SendStatus.SENT : SendStatus.NOT_SENT,
|
|
||||||
readStatus: signer.disabled ? ReadStatus.OPENED : ReadStatus.NOT_OPENED,
|
|
||||||
signingStatus: signer.disabled ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
|
|
||||||
}));
|
|
||||||
}, [configData.signers]);
|
|
||||||
|
|
||||||
const [selectedRecipient, setSelectedRecipient] = useState<Recipient | null>(
|
|
||||||
() => recipients.find((r) => r.signingStatus === SigningStatus.NOT_SIGNED) || null,
|
|
||||||
);
|
|
||||||
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
|
|
||||||
const [isFieldWithinBounds, setIsFieldWithinBounds] = useState(false);
|
|
||||||
const [coords, setCoords] = useState({
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
});
|
|
||||||
const [activeFieldId, setActiveFieldId] = useState<string | null>(null);
|
|
||||||
const [lastActiveField, setLastActiveField] = useState<
|
|
||||||
TConfigureFieldsFormSchema['fields'][0] | null
|
|
||||||
>(null);
|
|
||||||
const [fieldClipboard, setFieldClipboard] = useState<
|
|
||||||
TConfigureFieldsFormSchema['fields'][0] | null
|
|
||||||
>(null);
|
|
||||||
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
|
|
||||||
const [currentField, setCurrentField] = useState<TConfigureFieldsFormSchema['fields'][0] | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const fieldBounds = useRef({
|
|
||||||
height: DEFAULT_HEIGHT_PX,
|
|
||||||
width: DEFAULT_WIDTH_PX,
|
|
||||||
});
|
|
||||||
|
|
||||||
const selectedRecipientIndex = recipients.findIndex((r) => r.id === selectedRecipient?.id);
|
|
||||||
const selectedRecipientStyles = useRecipientColors(
|
|
||||||
selectedRecipientIndex === -1 ? 0 : selectedRecipientIndex,
|
|
||||||
);
|
|
||||||
|
|
||||||
const form = useForm<TConfigureFieldsFormSchema>({
|
|
||||||
defaultValues: {
|
|
||||||
fields: defaultValues?.fields ?? [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { control, handleSubmit } = form;
|
|
||||||
|
|
||||||
const onFormSubmit = handleSubmit(onSubmit);
|
|
||||||
|
|
||||||
const {
|
|
||||||
append,
|
|
||||||
remove,
|
|
||||||
update,
|
|
||||||
fields: localFields,
|
|
||||||
} = useFieldArray({
|
|
||||||
control: control,
|
|
||||||
name: 'fields',
|
|
||||||
});
|
|
||||||
|
|
||||||
const onFieldCopy = useCallback(
|
|
||||||
(event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => {
|
|
||||||
const { duplicate = false, duplicateAll = false } = options ?? {};
|
|
||||||
|
|
||||||
if (lastActiveField) {
|
|
||||||
event?.preventDefault();
|
|
||||||
|
|
||||||
if (duplicate) {
|
|
||||||
const newField: TConfigureFieldsFormSchema['fields'][0] = {
|
|
||||||
...structuredClone(lastActiveField),
|
|
||||||
nativeId: undefined,
|
|
||||||
formId: nanoid(12),
|
|
||||||
signerEmail: selectedRecipient?.email ?? lastActiveField.signerEmail,
|
|
||||||
recipientId: selectedRecipient?.id ?? lastActiveField.recipientId,
|
|
||||||
pageX: lastActiveField.pageX + 3,
|
|
||||||
pageY: lastActiveField.pageY + 3,
|
|
||||||
};
|
|
||||||
|
|
||||||
append(newField);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (duplicateAll) {
|
|
||||||
const pages = Array.from(document.querySelectorAll(PDF_VIEWER_PAGE_SELECTOR));
|
|
||||||
|
|
||||||
pages.forEach((_, index) => {
|
|
||||||
const pageNumber = index + 1;
|
|
||||||
|
|
||||||
if (pageNumber === lastActiveField.pageNumber) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newField: TConfigureFieldsFormSchema['fields'][0] = {
|
|
||||||
...structuredClone(lastActiveField),
|
|
||||||
nativeId: undefined,
|
|
||||||
formId: nanoid(12),
|
|
||||||
signerEmail: selectedRecipient?.email ?? lastActiveField.signerEmail,
|
|
||||||
recipientId: selectedRecipient?.id ?? lastActiveField.recipientId,
|
|
||||||
pageNumber,
|
|
||||||
};
|
|
||||||
|
|
||||||
append(newField);
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setFieldClipboard(lastActiveField);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Copied field',
|
|
||||||
description: 'Copied field to clipboard',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[append, lastActiveField, selectedRecipient?.email, selectedRecipient?.id, toast],
|
|
||||||
);
|
|
||||||
|
|
||||||
const onFieldPaste = useCallback(
|
|
||||||
(event: KeyboardEvent) => {
|
|
||||||
if (fieldClipboard) {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
const copiedField = structuredClone(fieldClipboard);
|
|
||||||
|
|
||||||
append({
|
|
||||||
...copiedField,
|
|
||||||
nativeId: undefined,
|
|
||||||
formId: nanoid(12),
|
|
||||||
signerEmail: selectedRecipient?.email ?? copiedField.signerEmail,
|
|
||||||
recipientId: selectedRecipient?.id ?? copiedField.recipientId,
|
|
||||||
pageX: copiedField.pageX + 3,
|
|
||||||
pageY: copiedField.pageY + 3,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[append, fieldClipboard, selectedRecipient?.email, selectedRecipient?.id],
|
|
||||||
);
|
|
||||||
|
|
||||||
useHotkeys(['ctrl+c', 'meta+c'], (evt) => onFieldCopy(evt));
|
|
||||||
useHotkeys(['ctrl+v', 'meta+v'], (evt) => onFieldPaste(evt));
|
|
||||||
useHotkeys(['ctrl+d', 'meta+d'], (evt) => onFieldCopy(evt, { duplicate: true }));
|
|
||||||
|
|
||||||
const onMouseMove = useCallback(
|
|
||||||
(event: MouseEvent) => {
|
|
||||||
if (!selectedField) return;
|
|
||||||
|
|
||||||
setIsFieldWithinBounds(
|
|
||||||
isWithinPageBounds(
|
|
||||||
event,
|
|
||||||
PDF_VIEWER_PAGE_SELECTOR,
|
|
||||||
fieldBounds.current.width,
|
|
||||||
fieldBounds.current.height,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
setCoords({
|
|
||||||
x: event.clientX - fieldBounds.current.width / 2,
|
|
||||||
y: event.clientY - fieldBounds.current.height / 2,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[isWithinPageBounds, selectedField],
|
|
||||||
);
|
|
||||||
|
|
||||||
const onMouseClick = useCallback(
|
|
||||||
(event: MouseEvent) => {
|
|
||||||
if (!selectedField || !selectedRecipient) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const $page = getPage(event, PDF_VIEWER_PAGE_SELECTOR);
|
|
||||||
|
|
||||||
if (
|
|
||||||
!$page ||
|
|
||||||
!isWithinPageBounds(
|
|
||||||
event,
|
|
||||||
PDF_VIEWER_PAGE_SELECTOR,
|
|
||||||
fieldBounds.current.width,
|
|
||||||
fieldBounds.current.height,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { top, left, height, width } = getBoundingClientRect($page);
|
|
||||||
|
|
||||||
const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10);
|
|
||||||
|
|
||||||
// Calculate x and y as a percentage of the page width and height
|
|
||||||
let pageX = ((event.pageX - left) / width) * 100;
|
|
||||||
let pageY = ((event.pageY - top) / height) * 100;
|
|
||||||
|
|
||||||
// Get the bounds as a percentage of the page width and height
|
|
||||||
const fieldPageWidth = (fieldBounds.current.width / width) * 100;
|
|
||||||
const fieldPageHeight = (fieldBounds.current.height / height) * 100;
|
|
||||||
|
|
||||||
// And center it based on the bounds
|
|
||||||
pageX -= fieldPageWidth / 2;
|
|
||||||
pageY -= fieldPageHeight / 2;
|
|
||||||
|
|
||||||
const field = {
|
|
||||||
formId: nanoid(12),
|
|
||||||
type: selectedField,
|
|
||||||
pageNumber,
|
|
||||||
pageX,
|
|
||||||
pageY,
|
|
||||||
pageWidth: fieldPageWidth,
|
|
||||||
pageHeight: fieldPageHeight,
|
|
||||||
recipientId: selectedRecipient.id,
|
|
||||||
signerEmail: selectedRecipient.email,
|
|
||||||
fieldMeta: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
append(field);
|
|
||||||
|
|
||||||
// Automatically open advanced settings for field types that need configuration
|
|
||||||
if (ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING.includes(selectedField)) {
|
|
||||||
setCurrentField(field);
|
|
||||||
setShowAdvancedSettings(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelectedField(null);
|
|
||||||
},
|
|
||||||
[append, getPage, isWithinPageBounds, selectedField, selectedRecipient],
|
|
||||||
);
|
|
||||||
|
|
||||||
const onFieldResize = useCallback(
|
|
||||||
(node: HTMLElement, index: number) => {
|
|
||||||
const field = localFields[index];
|
|
||||||
|
|
||||||
const $page = window.document.querySelector<HTMLElement>(
|
|
||||||
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!$page) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
x: pageX,
|
|
||||||
y: pageY,
|
|
||||||
width: pageWidth,
|
|
||||||
height: pageHeight,
|
|
||||||
} = getFieldPosition($page, node);
|
|
||||||
|
|
||||||
update(index, {
|
|
||||||
...field,
|
|
||||||
pageX,
|
|
||||||
pageY,
|
|
||||||
pageWidth,
|
|
||||||
pageHeight,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[getFieldPosition, localFields, update],
|
|
||||||
);
|
|
||||||
|
|
||||||
const onFieldMove = useCallback(
|
|
||||||
(node: HTMLElement, index: number) => {
|
|
||||||
const field = localFields[index];
|
|
||||||
|
|
||||||
const $page = window.document.querySelector<HTMLElement>(
|
|
||||||
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!$page) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { x: pageX, y: pageY } = getFieldPosition($page, node);
|
|
||||||
|
|
||||||
update(index, {
|
|
||||||
...field,
|
|
||||||
pageX,
|
|
||||||
pageY,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[getFieldPosition, localFields, update],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleUpdateFieldMeta = useCallback(
|
|
||||||
(formId: string, fieldMeta: TFieldMetaSchema) => {
|
|
||||||
const fieldIndex = localFields.findIndex((field) => field.formId === formId);
|
|
||||||
|
|
||||||
if (fieldIndex !== -1) {
|
|
||||||
const parsedFieldMeta = ZFieldMetaSchema.parse(fieldMeta);
|
|
||||||
|
|
||||||
update(fieldIndex, {
|
|
||||||
...localFields[fieldIndex],
|
|
||||||
fieldMeta: parsedFieldMeta,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[localFields, update],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedField) {
|
|
||||||
window.addEventListener('mousemove', onMouseMove);
|
|
||||||
window.addEventListener('mouseup', onMouseClick);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('mousemove', onMouseMove);
|
|
||||||
window.removeEventListener('mouseup', onMouseClick);
|
|
||||||
};
|
|
||||||
}, [onMouseClick, onMouseMove, selectedField]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const observer = new MutationObserver((_mutations) => {
|
|
||||||
const $page = document.querySelector(PDF_VIEWER_PAGE_SELECTOR);
|
|
||||||
|
|
||||||
if (!$page) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
fieldBounds.current = {
|
|
||||||
height: Math.max(DEFAULT_HEIGHT_PX),
|
|
||||||
width: Math.max(DEFAULT_WIDTH_PX),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
observer.observe(document.body, {
|
|
||||||
childList: true,
|
|
||||||
subtree: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
observer.disconnect();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Close drawer when a field is selected on mobile
|
|
||||||
useEffect(() => {
|
|
||||||
if (isMobile && selectedField) {
|
|
||||||
setIsDrawerOpen(false);
|
|
||||||
}
|
|
||||||
}, [isMobile, selectedField]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="grid w-full grid-cols-12 gap-4">
|
|
||||||
{/* Desktop sidebar */}
|
|
||||||
{!isMobile && (
|
|
||||||
<div className="order-2 col-span-12 md:order-1 md:col-span-4">
|
|
||||||
<div className="bg-widget border-border sticky top-4 max-h-[calc(100vh-2rem)] rounded-lg border p-4 pb-6">
|
|
||||||
<h2 className="mb-1 text-lg font-medium">
|
|
||||||
<Trans>Configure Fields</Trans>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mb-6 text-sm">
|
|
||||||
<Trans>Configure the fields you want to place on the document.</Trans>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<RecipientSelector
|
|
||||||
selectedRecipient={selectedRecipient}
|
|
||||||
onSelectedRecipientChange={setSelectedRecipient}
|
|
||||||
recipients={recipients}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<hr className="my-6" />
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<FieldSelector
|
|
||||||
selectedField={selectedField}
|
|
||||||
onSelectedFieldChange={setSelectedField}
|
|
||||||
className="w-full"
|
|
||||||
disabled={!selectedRecipient}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 flex gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
className="flex-1"
|
|
||||||
loading={form.formState.isSubmitting}
|
|
||||||
onClick={() => onBack(form.getValues())}
|
|
||||||
>
|
|
||||||
<Trans>Back</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
className="flex-1"
|
|
||||||
type="button"
|
|
||||||
loading={form.formState.isSubmitting}
|
|
||||||
disabled={!form.formState.isValid}
|
|
||||||
onClick={async () => onFormSubmit()}
|
|
||||||
>
|
|
||||||
<Trans>Save</Trans>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={cn('order-1 col-span-12 md:order-2', !isMobile && 'md:col-span-8')}>
|
|
||||||
<div className="relative">
|
|
||||||
{selectedField && (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'text-muted-foreground dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center bg-white transition duration-200 [container-type:size]',
|
|
||||||
selectedRecipientStyles.base,
|
|
||||||
{
|
|
||||||
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
|
|
||||||
'dark:text-black/60': isFieldWithinBounds,
|
|
||||||
},
|
|
||||||
selectedField === 'SIGNATURE' && 'font-signature',
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
top: coords.y,
|
|
||||||
left: coords.x,
|
|
||||||
height: fieldBounds.current.height,
|
|
||||||
width: fieldBounds.current.width,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">
|
|
||||||
{_(FRIENDLY_FIELD_TYPE[selectedField])}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
{normalizedDocumentData && (
|
|
||||||
<div>
|
|
||||||
<PDFViewer documentData={normalizedDocumentData} />
|
|
||||||
|
|
||||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
|
||||||
{localFields.map((field, index) => {
|
|
||||||
const recipientIndex = recipients.findIndex(
|
|
||||||
(r) => r.id === field.recipientId,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FieldItem
|
|
||||||
key={field.formId}
|
|
||||||
field={field}
|
|
||||||
minHeight={MIN_HEIGHT_PX}
|
|
||||||
minWidth={MIN_WIDTH_PX}
|
|
||||||
defaultHeight={DEFAULT_HEIGHT_PX}
|
|
||||||
defaultWidth={DEFAULT_WIDTH_PX}
|
|
||||||
onResize={(node) => onFieldResize(node, index)}
|
|
||||||
onMove={(node) => onFieldMove(node, index)}
|
|
||||||
onRemove={() => remove(index)}
|
|
||||||
onDuplicate={() => onFieldCopy(null, { duplicate: true })}
|
|
||||||
onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })}
|
|
||||||
onFocus={() => setLastActiveField(field)}
|
|
||||||
onBlur={() => setLastActiveField(null)}
|
|
||||||
onAdvancedSettings={() => {
|
|
||||||
setCurrentField(field);
|
|
||||||
setShowAdvancedSettings(true);
|
|
||||||
}}
|
|
||||||
recipientIndex={recipientIndex}
|
|
||||||
active={activeFieldId === field.formId}
|
|
||||||
onFieldActivate={() => setActiveFieldId(field.formId)}
|
|
||||||
onFieldDeactivate={() => setActiveFieldId(null)}
|
|
||||||
disabled={selectedRecipient?.id !== field.recipientId}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ElementVisible>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile Floating Action Bar and Drawer */}
|
|
||||||
{isMobile && (
|
|
||||||
<Sheet open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
|
|
||||||
<SheetTrigger asChild>
|
|
||||||
<div className="bg-widget border-border fixed bottom-6 left-6 right-6 z-50 flex items-center justify-between gap-2 rounded-lg border p-4">
|
|
||||||
<span className="text-lg font-medium">
|
|
||||||
<Trans>Configure Fields</Trans>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="border-border text-muted-foreground inline-flex h-10 w-10 items-center justify-center rounded-lg border"
|
|
||||||
>
|
|
||||||
<ChevronsUpDown className="h-6 w-6" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</SheetTrigger>
|
|
||||||
|
|
||||||
<SheetContent
|
|
||||||
position="bottom"
|
|
||||||
size="xl"
|
|
||||||
className="bg-widget h-fit max-h-[80vh] overflow-y-auto rounded-t-xl p-4"
|
|
||||||
>
|
|
||||||
<h2 className="mb-1 text-lg font-medium">
|
|
||||||
<Trans>Configure Fields</Trans>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mb-6 text-sm">
|
|
||||||
<Trans>Configure the fields you want to place on the document.</Trans>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<RecipientSelector
|
|
||||||
selectedRecipient={selectedRecipient}
|
|
||||||
onSelectedRecipientChange={setSelectedRecipient}
|
|
||||||
recipients={recipients}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<hr className="my-6" />
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<FieldSelector
|
|
||||||
selectedField={selectedField}
|
|
||||||
onSelectedFieldChange={(field) => {
|
|
||||||
setSelectedField(field);
|
|
||||||
if (field) {
|
|
||||||
setIsDrawerOpen(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-full"
|
|
||||||
disabled={!selectedRecipient}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 flex gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
className="flex-1"
|
|
||||||
loading={form.formState.isSubmitting}
|
|
||||||
onClick={() => onBack(form.getValues())}
|
|
||||||
>
|
|
||||||
<Trans>Back</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
className="flex-1"
|
|
||||||
type="button"
|
|
||||||
loading={form.formState.isSubmitting}
|
|
||||||
disabled={!form.formState.isValid}
|
|
||||||
onClick={async () => onFormSubmit()}
|
|
||||||
>
|
|
||||||
<Trans>Save</Trans>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<FieldAdvancedSettingsDrawer
|
|
||||||
isOpen={showAdvancedSettings}
|
|
||||||
onOpenChange={setShowAdvancedSettings}
|
|
||||||
currentField={currentField}
|
|
||||||
fields={localFields}
|
|
||||||
onFieldUpdate={handleUpdateFieldMeta}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
|
||||||
import { FieldType } from '@documenso/prisma/client';
|
|
||||||
|
|
||||||
export const ZConfigureFieldsFormSchema = z.object({
|
|
||||||
fields: z.array(
|
|
||||||
z.object({
|
|
||||||
nativeId: z.number().optional(),
|
|
||||||
formId: z.string().min(1),
|
|
||||||
type: z.nativeEnum(FieldType),
|
|
||||||
signerEmail: z.string().min(1),
|
|
||||||
inserted: z.boolean().optional(),
|
|
||||||
recipientId: z.number().min(0),
|
|
||||||
pageNumber: z.number().min(1),
|
|
||||||
pageX: z.number().min(0),
|
|
||||||
pageY: z.number().min(0),
|
|
||||||
pageWidth: z.number().min(0),
|
|
||||||
pageHeight: z.number().min(0),
|
|
||||||
fieldMeta: ZFieldMetaSchema.optional(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type TConfigureFieldsFormSchema = z.infer<typeof ZConfigureFieldsFormSchema>;
|
|
||||||
|
|
||||||
export type TConfigureFieldsFormSchemaField = z.infer<
|
|
||||||
typeof ZConfigureFieldsFormSchema
|
|
||||||
>['fields'][number];
|
|
||||||
@ -1,60 +0,0 @@
|
|||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
|
|
||||||
import { type TFieldMetaSchema as FieldMeta } from '@documenso/lib/types/field-meta';
|
|
||||||
import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
|
|
||||||
import { FieldAdvancedSettings } from '@documenso/ui/primitives/document-flow/field-item-advanced-settings';
|
|
||||||
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
|
|
||||||
import { Sheet, SheetContent, SheetTitle } from '@documenso/ui/primitives/sheet';
|
|
||||||
|
|
||||||
import type { TConfigureFieldsFormSchemaField } from './configure-fields-view.types';
|
|
||||||
|
|
||||||
export type FieldAdvancedSettingsDrawerProps = {
|
|
||||||
isOpen: boolean;
|
|
||||||
onOpenChange: (isOpen: boolean) => void;
|
|
||||||
currentField: TConfigureFieldsFormSchemaField | null;
|
|
||||||
fields: TConfigureFieldsFormSchemaField[];
|
|
||||||
onFieldUpdate: (formId: string, fieldMeta: FieldMeta) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const FieldAdvancedSettingsDrawer = ({
|
|
||||||
isOpen,
|
|
||||||
onOpenChange,
|
|
||||||
currentField,
|
|
||||||
fields,
|
|
||||||
onFieldUpdate,
|
|
||||||
}: FieldAdvancedSettingsDrawerProps) => {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
|
|
||||||
if (!currentField) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Sheet open={isOpen} onOpenChange={onOpenChange}>
|
|
||||||
<SheetContent position="right" size="lg" className="w-9/12 max-w-sm overflow-y-auto">
|
|
||||||
<SheetTitle className="sr-only">
|
|
||||||
{parseMessageDescriptor(
|
|
||||||
_,
|
|
||||||
msg`Configure ${parseMessageDescriptor(_, FRIENDLY_FIELD_TYPE[currentField.type])} Field`,
|
|
||||||
)}
|
|
||||||
</SheetTitle>
|
|
||||||
|
|
||||||
<FieldAdvancedSettings
|
|
||||||
title={msg`Advanced settings`}
|
|
||||||
description={msg`Configure the ${parseMessageDescriptor(
|
|
||||||
_,
|
|
||||||
FRIENDLY_FIELD_TYPE[currentField.type],
|
|
||||||
)} field`}
|
|
||||||
field={currentField}
|
|
||||||
fields={fields}
|
|
||||||
onAdvancedSettings={() => onOpenChange(false)}
|
|
||||||
onSave={(fieldMeta) => {
|
|
||||||
onFieldUpdate(currentField.formId, fieldMeta);
|
|
||||||
onOpenChange(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,11 +1,7 @@
|
|||||||
import { Loader } from 'lucide-react';
|
|
||||||
|
|
||||||
export const EmbedClientLoading = () => {
|
export const EmbedClientLoading = () => {
|
||||||
return (
|
return (
|
||||||
<div className="bg-background fixed left-0 top-0 z-[9999] flex h-full w-full items-center justify-center">
|
<div className="bg-background fixed left-0 top-0 z-[9999] flex h-full w-full items-center justify-center">
|
||||||
<Loader className="mr-2 h-4 w-4 animate-spin" />
|
Loading...
|
||||||
|
|
||||||
<span>Loading...</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -3,8 +3,8 @@ import { useEffect, useLayoutEffect, useState } from 'react';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { DocumentMeta, Recipient, Signature, TemplateMeta } from '@prisma/client';
|
|
||||||
import { type DocumentData, type Field, FieldType } from '@prisma/client';
|
import { type DocumentData, type Field, FieldType } from '@prisma/client';
|
||||||
|
import type { DocumentMeta, Recipient, Signature, TemplateMeta } from '@prisma/client';
|
||||||
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { useSearchParams } from 'react-router';
|
import { useSearchParams } from 'react-router';
|
||||||
@ -13,10 +13,6 @@ import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn'
|
|||||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||||
import {
|
|
||||||
isFieldUnsignedAndRequired,
|
|
||||||
isRequiredField,
|
|
||||||
} from '@documenso/lib/utils/advanced-fields-helpers';
|
|
||||||
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import type {
|
import type {
|
||||||
@ -25,11 +21,12 @@ import type {
|
|||||||
} from '@documenso/trpc/server/field-router/schema';
|
} from '@documenso/trpc/server/field-router/schema';
|
||||||
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 { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||||
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { BrandingLogo } from '~/components/general/branding-logo';
|
import { BrandingLogo } from '~/components/general/branding-logo';
|
||||||
@ -68,8 +65,16 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
|
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
const { fullName, email, signature, setFullName, setEmail, setSignature } =
|
const {
|
||||||
useRequiredDocumentSigningContext();
|
fullName,
|
||||||
|
email,
|
||||||
|
signature,
|
||||||
|
signatureValid,
|
||||||
|
setFullName,
|
||||||
|
setEmail,
|
||||||
|
setSignature,
|
||||||
|
setSignatureValid,
|
||||||
|
} = useRequiredDocumentSigningContext();
|
||||||
|
|
||||||
const [hasFinishedInit, setHasFinishedInit] = useState(false);
|
const [hasFinishedInit, setHasFinishedInit] = useState(false);
|
||||||
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
|
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
|
||||||
@ -87,7 +92,7 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
const [localFields, setLocalFields] = useState<DirectTemplateLocalField[]>(() => fields);
|
const [localFields, setLocalFields] = useState<DirectTemplateLocalField[]>(() => fields);
|
||||||
|
|
||||||
const [pendingFields, _completedFields] = [
|
const [pendingFields, _completedFields] = [
|
||||||
localFields.filter((field) => isFieldUnsignedAndRequired(field)),
|
localFields.filter((field) => !field.inserted),
|
||||||
localFields.filter((field) => field.inserted),
|
localFields.filter((field) => field.inserted),
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -105,7 +110,7 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
|
|
||||||
const newField: DirectTemplateLocalField = structuredClone({
|
const newField: DirectTemplateLocalField = structuredClone({
|
||||||
...field,
|
...field,
|
||||||
customText: payload.value ?? '',
|
customText: payload.value,
|
||||||
inserted: true,
|
inserted: true,
|
||||||
signedValue: payload,
|
signedValue: payload,
|
||||||
});
|
});
|
||||||
@ -116,10 +121,8 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
created: new Date(),
|
created: new Date(),
|
||||||
recipientId: 1,
|
recipientId: 1,
|
||||||
fieldId: 1,
|
fieldId: 1,
|
||||||
signatureImageAsBase64:
|
signatureImageAsBase64: payload.value.startsWith('data:') ? payload.value : null,
|
||||||
payload.value && payload.value.startsWith('data:') ? payload.value : null,
|
typedSignature: payload.value.startsWith('data:') ? null : payload.value,
|
||||||
typedSignature:
|
|
||||||
payload.value && !payload.value.startsWith('data:') ? payload.value : null,
|
|
||||||
} satisfies Signature;
|
} satisfies Signature;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -177,7 +180,7 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onNextFieldClick = () => {
|
const onNextFieldClick = () => {
|
||||||
validateFieldsInserted(pendingFields);
|
validateFieldsInserted(localFields);
|
||||||
|
|
||||||
setShowPendingFieldTooltip(true);
|
setShowPendingFieldTooltip(true);
|
||||||
setIsExpanded(false);
|
setIsExpanded(false);
|
||||||
@ -185,7 +188,11 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
|
|
||||||
const onCompleteClick = async () => {
|
const onCompleteClick = async () => {
|
||||||
try {
|
try {
|
||||||
const valid = validateFieldsInserted(pendingFields);
|
if (hasSignatureField && !signatureValid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = validateFieldsInserted(localFields);
|
||||||
|
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
setShowPendingFieldTooltip(true);
|
setShowPendingFieldTooltip(true);
|
||||||
@ -198,6 +205,12 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
directTemplateExternalId = decodeURIComponent(directTemplateExternalId);
|
directTemplateExternalId = decodeURIComponent(directTemplateExternalId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
localFields.forEach((field) => {
|
||||||
|
if (!field.signedValue) {
|
||||||
|
throw new Error('Invalid configuration');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
documentId,
|
documentId,
|
||||||
token: documentToken,
|
token: documentToken,
|
||||||
@ -208,11 +221,13 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
directRecipientName: fullName,
|
directRecipientName: fullName,
|
||||||
directRecipientEmail: email,
|
directRecipientEmail: email,
|
||||||
templateUpdatedAt: updatedAt,
|
templateUpdatedAt: updatedAt,
|
||||||
signedFieldValues: localFields
|
signedFieldValues: localFields.map((field) => {
|
||||||
.filter((field) => {
|
if (!field.signedValue) {
|
||||||
return field.signedValue && (isRequiredField(field) || field.inserted);
|
throw new Error('Invalid configuration');
|
||||||
})
|
}
|
||||||
.map((field) => field.signedValue!),
|
|
||||||
|
return field.signedValue;
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (window.parent) {
|
if (window.parent) {
|
||||||
@ -332,7 +347,7 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
{/* Widget */}
|
{/* Widget */}
|
||||||
<div
|
<div
|
||||||
key={isExpanded ? 'expanded' : 'collapsed'}
|
key={isExpanded ? 'expanded' : 'collapsed'}
|
||||||
className="group/document-widget fixed bottom-8 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-6 md:sticky md:bottom-[unset] md:top-4 md:z-auto md:w-[350px] md:px-0"
|
className="group/document-widget fixed bottom-8 left-0 z-50 h-fit w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
|
||||||
data-expanded={isExpanded || undefined}
|
data-expanded={isExpanded || undefined}
|
||||||
>
|
>
|
||||||
<div className="border-border bg-widget flex h-fit w-full flex-col rounded-xl border px-4 py-4 md:min-h-[min(calc(100dvh-2rem),48rem)] md:py-6">
|
<div className="border-border bg-widget flex h-fit w-full flex-col rounded-xl border px-4 py-4 md:min-h-[min(calc(100dvh-2rem),48rem)] md:py-6">
|
||||||
@ -400,24 +415,40 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasSignatureField && (
|
<div>
|
||||||
<div>
|
<Label htmlFor="Signature">
|
||||||
<Label htmlFor="Signature">
|
<Trans>Signature</Trans>
|
||||||
<Trans>Signature</Trans>
|
</Label>
|
||||||
</Label>
|
|
||||||
|
|
||||||
<SignaturePadDialog
|
<Card className="mt-2" gradient degrees={-120}>
|
||||||
className="mt-2"
|
<CardContent className="p-0">
|
||||||
disabled={isThrottled || isSubmitting}
|
<SignaturePad
|
||||||
disableAnimation
|
className="h-44 w-full"
|
||||||
value={signature ?? ''}
|
disabled={isThrottled || isSubmitting}
|
||||||
onChange={(v) => setSignature(v ?? '')}
|
defaultValue={signature ?? undefined}
|
||||||
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
onChange={(value) => {
|
||||||
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
|
setSignature(value);
|
||||||
drawSignatureEnabled={metadata?.drawSignatureEnabled}
|
}}
|
||||||
/>
|
onValidityChange={(isValid) => {
|
||||||
</div>
|
setSignatureValid(isValid);
|
||||||
)}
|
}}
|
||||||
|
allowTypedSignature={Boolean(
|
||||||
|
metadata &&
|
||||||
|
'typedSignatureEnabled' in metadata &&
|
||||||
|
metadata.typedSignatureEnabled,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{hasSignatureField && !signatureValid && (
|
||||||
|
<div className="text-destructive mt-2 text-sm">
|
||||||
|
<Trans>
|
||||||
|
Signature is too small. Please provide a more complete signature.
|
||||||
|
</Trans>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,7 @@ export type EmbedDocumentCompletedPageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentCompletedPageProps) => {
|
export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentCompletedPageProps) => {
|
||||||
|
console.log({ signature });
|
||||||
return (
|
return (
|
||||||
<div className="embed--DocumentCompleted relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
<div className="embed--DocumentCompleted relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||||
<h3 className="text-foreground text-2xl font-semibold">
|
<h3 className="text-foreground text-2xl font-semibold">
|
||||||
|
|||||||
@ -54,8 +54,6 @@ export const EmbedDocumentFields = ({
|
|||||||
onSignField={onSignField}
|
onSignField={onSignField}
|
||||||
onUnsignField={onUnsignField}
|
onUnsignField={onUnsignField}
|
||||||
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
||||||
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
|
|
||||||
drawSignatureEnabled={metadata?.drawSignatureEnabled}
|
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
.with(FieldType.INITIALS, () => (
|
.with(FieldType.INITIALS, () => (
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useId, useLayoutEffect, useMemo, useState } from 'react';
|
import { useEffect, useId, 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';
|
||||||
@ -15,20 +15,18 @@ 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 { 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 { 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 { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||||
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
||||||
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { BrandingLogo } from '~/components/general/branding-logo';
|
import { BrandingLogo } from '~/components/general/branding-logo';
|
||||||
@ -49,7 +47,6 @@ export type EmbedSignDocumentClientPageProps = {
|
|||||||
documentData: DocumentData;
|
documentData: DocumentData;
|
||||||
recipient: RecipientWithFields;
|
recipient: RecipientWithFields;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
completedFields: DocumentField[];
|
|
||||||
metadata?: DocumentMeta | TemplateMeta | null;
|
metadata?: DocumentMeta | TemplateMeta | null;
|
||||||
isCompleted?: boolean;
|
isCompleted?: boolean;
|
||||||
hidePoweredBy?: boolean;
|
hidePoweredBy?: boolean;
|
||||||
@ -63,7 +60,6 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
documentData,
|
documentData,
|
||||||
recipient,
|
recipient,
|
||||||
fields,
|
fields,
|
||||||
completedFields,
|
|
||||||
metadata,
|
metadata,
|
||||||
isCompleted,
|
isCompleted,
|
||||||
hidePoweredBy = false,
|
hidePoweredBy = false,
|
||||||
@ -73,8 +69,15 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { fullName, email, signature, setFullName, setSignature } =
|
const {
|
||||||
useRequiredDocumentSigningContext();
|
fullName,
|
||||||
|
email,
|
||||||
|
signature,
|
||||||
|
signatureValid,
|
||||||
|
setFullName,
|
||||||
|
setSignature,
|
||||||
|
setSignatureValid,
|
||||||
|
} = useRequiredDocumentSigningContext();
|
||||||
|
|
||||||
const [hasFinishedInit, setHasFinishedInit] = useState(false);
|
const [hasFinishedInit, setHasFinishedInit] = useState(false);
|
||||||
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
|
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
|
||||||
@ -89,8 +92,6 @@ 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] =
|
|
||||||
useState(false);
|
|
||||||
|
|
||||||
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
|
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
|
||||||
|
|
||||||
@ -100,26 +101,19 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
const [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500);
|
const [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500);
|
||||||
|
|
||||||
const [pendingFields, _completedFields] = [
|
const [pendingFields, _completedFields] = [
|
||||||
fields.filter(
|
fields.filter((field) => field.recipientId === recipient.id && !field.inserted),
|
||||||
(field) => field.recipientId === recipient.id && isFieldUnsignedAndRequired(field),
|
|
||||||
),
|
|
||||||
fields.filter((field) => field.inserted),
|
fields.filter((field) => field.inserted),
|
||||||
];
|
];
|
||||||
|
|
||||||
const { mutateAsync: completeDocumentWithToken, isPending: isSubmitting } =
|
const { mutateAsync: completeDocumentWithToken, isPending: isSubmitting } =
|
||||||
trpc.recipient.completeDocumentWithToken.useMutation();
|
trpc.recipient.completeDocumentWithToken.useMutation();
|
||||||
|
|
||||||
const fieldsRequiringValidation = useMemo(
|
|
||||||
() => fields.filter(isFieldUnsignedAndRequired),
|
|
||||||
[fields],
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
|
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
|
||||||
|
|
||||||
const assistantSignersId = useId();
|
const assistantSignersId = useId();
|
||||||
|
|
||||||
const onNextFieldClick = () => {
|
const onNextFieldClick = () => {
|
||||||
validateFieldsInserted(fieldsRequiringValidation);
|
validateFieldsInserted(fields);
|
||||||
|
|
||||||
setShowPendingFieldTooltip(true);
|
setShowPendingFieldTooltip(true);
|
||||||
setIsExpanded(false);
|
setIsExpanded(false);
|
||||||
@ -127,7 +121,11 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
|
|
||||||
const onCompleteClick = async () => {
|
const onCompleteClick = async () => {
|
||||||
try {
|
try {
|
||||||
const valid = validateFieldsInserted(fieldsRequiringValidation);
|
if (hasSignatureField && !signatureValid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = validateFieldsInserted(fields);
|
||||||
|
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
setShowPendingFieldTooltip(true);
|
setShowPendingFieldTooltip(true);
|
||||||
@ -208,7 +206,6 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
// a to be provided by the parent application, unlike direct templates.
|
// a to be provided by the parent application, unlike direct templates.
|
||||||
setIsNameLocked(!!data.lockName);
|
setIsNameLocked(!!data.lockName);
|
||||||
setAllowDocumentRejection(!!data.allowDocumentRejection);
|
setAllowDocumentRejection(!!data.allowDocumentRejection);
|
||||||
setShowOtherRecipientsCompletedFields(!!data.showOtherRecipientsCompletedFields);
|
|
||||||
|
|
||||||
if (data.darkModeDisabled) {
|
if (data.darkModeDisabled) {
|
||||||
document.documentElement.classList.add('dark-mode-disabled');
|
document.documentElement.classList.add('dark-mode-disabled');
|
||||||
@ -290,7 +287,7 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
{/* Widget */}
|
{/* Widget */}
|
||||||
<div
|
<div
|
||||||
key={isExpanded ? 'expanded' : 'collapsed'}
|
key={isExpanded ? 'expanded' : 'collapsed'}
|
||||||
className="embed--DocumentWidgetContainer group/document-widget fixed bottom-8 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-6 md:sticky md:bottom-[unset] md:top-4 md:z-auto md:w-[350px] md:px-0"
|
className="embed--DocumentWidgetContainer group/document-widget fixed bottom-8 left-0 z-50 h-fit w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
|
||||||
data-expanded={isExpanded || undefined}
|
data-expanded={isExpanded || undefined}
|
||||||
>
|
>
|
||||||
<div className="embed--DocumentWidget border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
|
<div className="embed--DocumentWidget border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
|
||||||
@ -421,24 +418,40 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasSignatureField && (
|
<div>
|
||||||
<div>
|
<Label htmlFor="Signature">
|
||||||
<Label htmlFor="Signature">
|
<Trans>Signature</Trans>
|
||||||
<Trans>Signature</Trans>
|
</Label>
|
||||||
</Label>
|
|
||||||
|
|
||||||
<SignaturePadDialog
|
<Card className="mt-2" gradient degrees={-120}>
|
||||||
className="mt-2"
|
<CardContent className="p-0">
|
||||||
disabled={isThrottled || isSubmitting}
|
<SignaturePad
|
||||||
disableAnimation
|
className="h-44 w-full"
|
||||||
value={signature ?? ''}
|
disabled={isThrottled || isSubmitting}
|
||||||
onChange={(v) => setSignature(v ?? '')}
|
defaultValue={signature ?? undefined}
|
||||||
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
onChange={(value) => {
|
||||||
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
|
setSignature(value);
|
||||||
drawSignatureEnabled={metadata?.drawSignatureEnabled}
|
}}
|
||||||
/>
|
onValidityChange={(isValid) => {
|
||||||
</div>
|
setSignatureValid(isValid);
|
||||||
)}
|
}}
|
||||||
|
allowTypedSignature={Boolean(
|
||||||
|
metadata &&
|
||||||
|
'typedSignatureEnabled' in metadata &&
|
||||||
|
metadata.typedSignatureEnabled,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{hasSignatureField && !signatureValid && (
|
||||||
|
<div className="text-destructive mt-2 text-sm">
|
||||||
|
<Trans>
|
||||||
|
Signature is too small. Please provide a more complete signature.
|
||||||
|
</Trans>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -454,7 +467,9 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
className={allowDocumentRejection ? 'col-start-2' : 'col-span-2'}
|
className={allowDocumentRejection ? 'col-start-2' : 'col-span-2'}
|
||||||
disabled={isThrottled}
|
disabled={
|
||||||
|
isThrottled || (!isAssistantMode && hasSignatureField && !signatureValid)
|
||||||
|
}
|
||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
onClick={() => throttledOnCompleteClick()}
|
onClick={() => throttledOnCompleteClick()}
|
||||||
>
|
>
|
||||||
@ -475,9 +490,6 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
|
|
||||||
{/* Fields */}
|
{/* Fields */}
|
||||||
<EmbedDocumentFields fields={fields} metadata={metadata} />
|
<EmbedDocumentFields fields={fields} metadata={metadata} />
|
||||||
|
|
||||||
{/* Completed fields */}
|
|
||||||
<DocumentReadOnlyFields documentMeta={metadata || undefined} fields={completedFields} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!hidePoweredBy && (
|
{!hidePoweredBy && (
|
||||||
|
|||||||
@ -1,182 +0,0 @@
|
|||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { ReadStatus, RecipientRole, SigningStatus } from '@prisma/client';
|
|
||||||
import { ArrowRight, EyeIcon, XCircle } from 'lucide-react';
|
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
|
|
||||||
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
|
||||||
import type { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
|
||||||
import { Badge } from '@documenso/ui/primitives/badge';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { Progress } from '@documenso/ui/primitives/progress';
|
|
||||||
|
|
||||||
// Get the return type from getRecipientByToken
|
|
||||||
type RecipientWithFields = Awaited<ReturnType<typeof getRecipientByToken>>;
|
|
||||||
|
|
||||||
interface DocumentEnvelope {
|
|
||||||
document: DocumentAndSender;
|
|
||||||
recipient: RecipientWithFields;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MultiSignDocumentListProps {
|
|
||||||
envelopes: DocumentEnvelope[];
|
|
||||||
onDocumentSelect: (document: DocumentEnvelope['document']) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MultiSignDocumentList({ envelopes, onDocumentSelect }: MultiSignDocumentListProps) {
|
|
||||||
// Calculate progress
|
|
||||||
const completedDocuments = envelopes.filter(
|
|
||||||
(envelope) => envelope.recipient.signingStatus === SigningStatus.SIGNED,
|
|
||||||
);
|
|
||||||
const totalDocuments = envelopes.length;
|
|
||||||
const progressPercentage = (completedDocuments.length / totalDocuments) * 100;
|
|
||||||
|
|
||||||
// Find next document to sign (first one that's not signed and not rejected)
|
|
||||||
const nextDocumentToSign = envelopes.find(
|
|
||||||
(envelope) =>
|
|
||||||
envelope.recipient.signingStatus !== SigningStatus.SIGNED &&
|
|
||||||
envelope.recipient.signingStatus !== SigningStatus.REJECTED,
|
|
||||||
);
|
|
||||||
|
|
||||||
const allDocumentsCompleted = completedDocuments.length === totalDocuments;
|
|
||||||
|
|
||||||
const hasAssistantOrCcRecipient = envelopes.some(
|
|
||||||
(envelope) =>
|
|
||||||
envelope.recipient.role === RecipientRole.ASSISTANT ||
|
|
||||||
envelope.recipient.role === RecipientRole.CC,
|
|
||||||
);
|
|
||||||
|
|
||||||
function handleView(doc: DocumentEnvelope['document']) {
|
|
||||||
onDocumentSelect(doc);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleNextDocument() {
|
|
||||||
if (nextDocumentToSign) {
|
|
||||||
onDocumentSelect(nextDocumentToSign.document);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasAssistantOrCcRecipient) {
|
|
||||||
return (
|
|
||||||
<div className="mx-auto mt-16 flex w-full max-w-lg flex-col md:mt-16 md:rounded-2xl md:border md:px-8 md:py-16 md:shadow-lg">
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
<XCircle className="text-destructive h-16 w-16 md:h-24 md:w-24" strokeWidth={1.2} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 className="mt-12 text-xl font-bold md:text-2xl">
|
|
||||||
<Trans>It looks like we ran into an issue!</Trans>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-6">
|
|
||||||
<Trans>
|
|
||||||
One of the documents in the current bundle has a signing role that is not compatible
|
|
||||||
with the current signing experience.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2">
|
|
||||||
<Trans>
|
|
||||||
Assistants and Copy roles are currently not compatible with the multi-sign experience.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2">
|
|
||||||
<Trans>Please contact the site owner for further assistance.</Trans>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-background mx-auto w-full max-w-lg md:my-12 md:rounded-2xl md:border md:p-8 md:shadow-lg">
|
|
||||||
<h2 className="text-foreground mb-1 text-lg font-semibold">
|
|
||||||
<Trans>Sign Documents</Trans>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
<Trans>
|
|
||||||
You have been requested to sign the following documents. Review each document carefully
|
|
||||||
and complete the signing process.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Progress Section */}
|
|
||||||
<div className="mt-6">
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground font-medium">
|
|
||||||
<Trans>Progress</Trans>
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{completedDocuments.length} of {totalDocuments} completed
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4">
|
|
||||||
<Progress value={progressPercentage} className="h-2" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 flex flex-col gap-4">
|
|
||||||
{envelopes.map((envelope) => (
|
|
||||||
<div
|
|
||||||
key={envelope.document.id}
|
|
||||||
className="border-border flex items-center gap-4 rounded-lg border px-4 py-2"
|
|
||||||
>
|
|
||||||
<span className="text-foreground flex-1 truncate text-sm font-medium">
|
|
||||||
{envelope.document.title}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{match(envelope.recipient)
|
|
||||||
.with({ signingStatus: SigningStatus.SIGNED }, () => (
|
|
||||||
<Badge size="small" variant="default">
|
|
||||||
<Trans>Completed</Trans>
|
|
||||||
</Badge>
|
|
||||||
))
|
|
||||||
.with({ signingStatus: SigningStatus.REJECTED }, () => (
|
|
||||||
<Badge size="small" variant="destructive">
|
|
||||||
<Trans>Rejected</Trans>
|
|
||||||
</Badge>
|
|
||||||
))
|
|
||||||
.with({ readStatus: ReadStatus.OPENED }, () => (
|
|
||||||
<Badge size="small" variant="neutral">
|
|
||||||
<Trans>Viewed</Trans>
|
|
||||||
</Badge>
|
|
||||||
))
|
|
||||||
.otherwise(() => null)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
className="-mr-2"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleView(envelope.document)}
|
|
||||||
>
|
|
||||||
<EyeIcon className="mr-1 h-4 w-4" />
|
|
||||||
<Trans>View</Trans>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Next Document Button */}
|
|
||||||
{!allDocumentsCompleted && nextDocumentToSign && (
|
|
||||||
<div className="mt-6">
|
|
||||||
<Button onClick={handleNextDocument} className="w-full" size="lg">
|
|
||||||
<Trans>View next document</Trans>
|
|
||||||
<ArrowRight className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{allDocumentsCompleted && (
|
|
||||||
<Alert className="mt-6 text-center">
|
|
||||||
<AlertTitle>
|
|
||||||
<Trans>All documents have been completed!</Trans>
|
|
||||||
</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
<Trans>Thank you for completing the signing process.</Trans>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,396 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { DocumentStatus, FieldType, SigningStatus } from '@prisma/client';
|
|
||||||
import { Loader, LucideChevronDown, LucideChevronUp, X } from 'lucide-react';
|
|
||||||
import { P, match } from 'ts-pattern';
|
|
||||||
|
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import type {
|
|
||||||
TRemovedSignedFieldWithTokenMutationSchema,
|
|
||||||
TSignFieldWithTokenMutationSchema,
|
|
||||||
} from '@documenso/trpc/server/field-router/schema';
|
|
||||||
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
|
|
||||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
|
||||||
import PDFViewer from '@documenso/ui/primitives/pdf-viewer';
|
|
||||||
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { useRequiredDocumentSigningContext } from '../../general/document-signing/document-signing-provider';
|
|
||||||
import { DocumentSigningRejectDialog } from '../../general/document-signing/document-signing-reject-dialog';
|
|
||||||
import { EmbedDocumentFields } from '../embed-document-fields';
|
|
||||||
|
|
||||||
interface MultiSignDocumentSigningViewProps {
|
|
||||||
token: string;
|
|
||||||
recipientId: number;
|
|
||||||
onBack: () => void;
|
|
||||||
onDocumentCompleted?: (data: { token: string; documentId: number; recipientId: number }) => void;
|
|
||||||
onDocumentRejected?: (data: {
|
|
||||||
token: string;
|
|
||||||
documentId: number;
|
|
||||||
recipientId: number;
|
|
||||||
reason: string;
|
|
||||||
}) => void;
|
|
||||||
onDocumentError?: () => void;
|
|
||||||
onDocumentReady?: () => void;
|
|
||||||
isNameLocked?: boolean;
|
|
||||||
allowDocumentRejection?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MultiSignDocumentSigningView = ({
|
|
||||||
token,
|
|
||||||
recipientId,
|
|
||||||
onBack,
|
|
||||||
onDocumentCompleted,
|
|
||||||
onDocumentRejected,
|
|
||||||
onDocumentError,
|
|
||||||
onDocumentReady,
|
|
||||||
isNameLocked = false,
|
|
||||||
allowDocumentRejection = false,
|
|
||||||
}: MultiSignDocumentSigningViewProps) => {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const { fullName, email, signature, setFullName, setSignature } =
|
|
||||||
useRequiredDocumentSigningContext();
|
|
||||||
|
|
||||||
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
|
|
||||||
|
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
|
||||||
|
|
||||||
const { data: document, isLoading } = trpc.embeddingPresign.getMultiSignDocument.useQuery(
|
|
||||||
{ token },
|
|
||||||
{
|
|
||||||
staleTime: 0,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const { mutateAsync: signFieldWithToken } = trpc.field.signFieldWithToken.useMutation();
|
|
||||||
const { mutateAsync: removeSignedFieldWithToken } =
|
|
||||||
trpc.field.removeSignedFieldWithToken.useMutation();
|
|
||||||
|
|
||||||
const { mutateAsync: completeDocumentWithToken } =
|
|
||||||
trpc.recipient.completeDocumentWithToken.useMutation();
|
|
||||||
|
|
||||||
const hasSignatureField = document?.fields.some((field) => field.type === FieldType.SIGNATURE);
|
|
||||||
|
|
||||||
const [pendingFields, completedFields] = [
|
|
||||||
document?.fields.filter((field) => field.recipient.signingStatus !== SigningStatus.SIGNED) ??
|
|
||||||
[],
|
|
||||||
document?.fields.filter((field) => field.recipient.signingStatus === SigningStatus.SIGNED) ??
|
|
||||||
[],
|
|
||||||
];
|
|
||||||
|
|
||||||
const uninsertedFields = document?.fields.filter((field) => !field.inserted) ?? [];
|
|
||||||
|
|
||||||
const onSignField = async (payload: TSignFieldWithTokenMutationSchema) => {
|
|
||||||
try {
|
|
||||||
await signFieldWithToken(payload);
|
|
||||||
} catch (err) {
|
|
||||||
const error = AppError.parseError(err);
|
|
||||||
|
|
||||||
if (error.code === AppErrorCode.UNAUTHORIZED) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error(err);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`Error`),
|
|
||||||
description: _(msg`An error occurred while signing the document.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onUnsignField = async (payload: TRemovedSignedFieldWithTokenMutationSchema) => {
|
|
||||||
try {
|
|
||||||
await removeSignedFieldWithToken(payload);
|
|
||||||
} catch (err) {
|
|
||||||
const error = AppError.parseError(err);
|
|
||||||
|
|
||||||
if (error.code === AppErrorCode.UNAUTHORIZED) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDocumentComplete = async () => {
|
|
||||||
try {
|
|
||||||
setIsSubmitting(true);
|
|
||||||
|
|
||||||
await completeDocumentWithToken({
|
|
||||||
documentId: document!.id,
|
|
||||||
token,
|
|
||||||
});
|
|
||||||
|
|
||||||
onBack();
|
|
||||||
|
|
||||||
onDocumentCompleted?.({
|
|
||||||
token,
|
|
||||||
documentId: document!.id,
|
|
||||||
recipientId,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
onDocumentError?.();
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Error',
|
|
||||||
description: 'Failed to complete the document. Please try again.',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onNextFieldClick = () => {
|
|
||||||
setShowPendingFieldTooltip(true);
|
|
||||||
|
|
||||||
setIsExpanded(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onRejected = (reason: string) => {
|
|
||||||
if (onDocumentRejected && document) {
|
|
||||||
onDocumentRejected({
|
|
||||||
token,
|
|
||||||
documentId: document.id,
|
|
||||||
recipientId,
|
|
||||||
reason,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-background min-h-screen overflow-hidden">
|
|
||||||
<div id="document-field-portal-root" className="relative h-full w-full overflow-y-auto p-8">
|
|
||||||
{match({ isLoading, document })
|
|
||||||
.with({ isLoading: true }, () => (
|
|
||||||
<div className="flex min-h-[400px] w-full items-center justify-center">
|
|
||||||
<div className="flex flex-col items-center gap-4">
|
|
||||||
<Loader className="text-primary h-8 w-8 animate-spin" />
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
<Trans>Loading document...</Trans>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
.with({ isLoading: false, document: undefined }, () => (
|
|
||||||
<div className="flex min-h-[400px] w-full items-center justify-center">
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
<Trans>Failed to load document</Trans>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
.with({ document: P.nonNullable }, ({ document }) => (
|
|
||||||
<>
|
|
||||||
<div className="mx-auto flex w-full max-w-screen-xl items-baseline justify-between">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<h1 className="text-2xl font-semibold">{document.title}</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button variant="ghost" size="sm" onClick={onBack} className="p-2">
|
|
||||||
<X className="h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{allowDocumentRejection && (
|
|
||||||
<div className="embed--Actions mb-4 mt-8 flex w-full flex-row-reverse items-baseline justify-between">
|
|
||||||
<DocumentSigningRejectDialog
|
|
||||||
document={document}
|
|
||||||
token={token}
|
|
||||||
onRejected={onRejected}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="embed--DocumentContainer relative mx-auto mt-8 flex w-full max-w-screen-xl flex-col gap-x-6 gap-y-12 md:flex-row">
|
|
||||||
<div
|
|
||||||
className={cn('embed--DocumentViewer flex-1', {
|
|
||||||
'md:mx-auto md:max-w-2xl': document.status === DocumentStatus.COMPLETED,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<PDFViewer
|
|
||||||
documentData={document.documentData}
|
|
||||||
onDocumentLoad={() => {
|
|
||||||
setHasDocumentLoaded(true);
|
|
||||||
onDocumentReady?.();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Widget */}
|
|
||||||
{document.status !== DocumentStatus.COMPLETED && (
|
|
||||||
<div
|
|
||||||
key={isExpanded ? 'expanded' : 'collapsed'}
|
|
||||||
className="embed--DocumentWidgetContainer group/document-widget fixed bottom-8 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-6 md:sticky md:bottom-[unset] md:top-0 md:z-auto md:w-[350px] md:px-0"
|
|
||||||
data-expanded={isExpanded || undefined}
|
|
||||||
>
|
|
||||||
<div className="embed--DocumentWidget border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="embed--DocumentWidgetHeader">
|
|
||||||
<div className="flex items-center justify-between gap-x-2">
|
|
||||||
<h3 className="text-foreground text-xl font-semibold md:text-2xl">
|
|
||||||
<Trans>Sign document</Trans>
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
|
|
||||||
{isExpanded ? (
|
|
||||||
<LucideChevronDown
|
|
||||||
className="text-muted-foreground h-5 w-5"
|
|
||||||
onClick={() => setIsExpanded(false)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<LucideChevronUp
|
|
||||||
className="text-muted-foreground h-5 w-5"
|
|
||||||
onClick={() => setIsExpanded(true)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="embed--DocumentWidgetContent hidden group-data-[expanded]/document-widget:block md:block">
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
|
||||||
<Trans>Sign the document to complete the process.</Trans>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<hr className="border-border mb-8 mt-4" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Form */}
|
|
||||||
<div className="embed--DocumentWidgetForm -mx-2 hidden px-2 group-data-[expanded]/document-widget:block md:block">
|
|
||||||
<div className="flex flex-1 flex-col gap-y-4">
|
|
||||||
{
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="full-name">
|
|
||||||
<Trans>Full Name</Trans>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
id="full-name"
|
|
||||||
className="bg-background mt-2"
|
|
||||||
disabled={isNameLocked}
|
|
||||||
value={fullName}
|
|
||||||
onChange={(e) => !isNameLocked && setFullName(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="email">
|
|
||||||
<Trans>Email</Trans>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
id="email"
|
|
||||||
className="bg-background mt-2"
|
|
||||||
value={email}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasSignatureField && (
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="Signature">
|
|
||||||
<Trans>Signature</Trans>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<SignaturePadDialog
|
|
||||||
className="mt-2"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
disableAnimation
|
|
||||||
value={signature ?? ''}
|
|
||||||
onChange={(v) => setSignature(v ?? '')}
|
|
||||||
typedSignatureEnabled={
|
|
||||||
document.documentMeta?.typedSignatureEnabled
|
|
||||||
}
|
|
||||||
uploadSignatureEnabled={
|
|
||||||
document.documentMeta?.uploadSignatureEnabled
|
|
||||||
}
|
|
||||||
drawSignatureEnabled={
|
|
||||||
document.documentMeta?.drawSignatureEnabled
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="hidden flex-1 group-data-[expanded]/document-widget:block md:block" />
|
|
||||||
|
|
||||||
<div className="embed--DocumentWidgetFooter mt-4 hidden w-full grid-cols-2 items-center group-data-[expanded]/document-widget:grid md:grid">
|
|
||||||
{uninsertedFields.length > 0 ? (
|
|
||||||
<Button className="col-start-2" onClick={onNextFieldClick}>
|
|
||||||
<Trans>Next</Trans>
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
className="col-span-2"
|
|
||||||
loading={isSubmitting}
|
|
||||||
onClick={onDocumentComplete}
|
|
||||||
>
|
|
||||||
<Trans>Complete</Trans>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasDocumentLoaded && (
|
|
||||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
|
||||||
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
|
||||||
<FieldToolTip
|
|
||||||
key={pendingFields[0].id}
|
|
||||||
field={pendingFields[0]}
|
|
||||||
color="warning"
|
|
||||||
>
|
|
||||||
<Trans>Click to insert field</Trans>
|
|
||||||
</FieldToolTip>
|
|
||||||
)}
|
|
||||||
</ElementVisible>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Fields */}
|
|
||||||
{hasDocumentLoaded && (
|
|
||||||
<EmbedDocumentFields
|
|
||||||
fields={pendingFields}
|
|
||||||
metadata={document.documentMeta}
|
|
||||||
onSignField={onSignField}
|
|
||||||
onUnsignField={onUnsignField}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Completed fields */}
|
|
||||||
{document.status !== DocumentStatus.COMPLETED && (
|
|
||||||
<DocumentReadOnlyFields
|
|
||||||
documentMeta={document.documentMeta ?? undefined}
|
|
||||||
fields={completedFields}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
))
|
|
||||||
.otherwise(() => null)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -6,10 +6,10 @@ import { useLingui } from '@lingui/react';
|
|||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { flushSync } from 'react-dom';
|
import { flushSync } from 'react-dom';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useRevalidator } from 'react-router';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { authClient } from '@documenso/auth/client';
|
import { authClient } from '@documenso/auth/client';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@ -42,7 +42,7 @@ export type TDisable2FAForm = z.infer<typeof ZDisable2FAForm>;
|
|||||||
export const DisableAuthenticatorAppDialog = () => {
|
export const DisableAuthenticatorAppDialog = () => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { refreshSession } = useSession();
|
const { revalidate } = useRevalidator();
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [twoFactorDisableMethod, setTwoFactorDisableMethod] = useState<'totp' | 'backup'>('totp');
|
const [twoFactorDisableMethod, setTwoFactorDisableMethod] = useState<'totp' | 'backup'>('totp');
|
||||||
@ -92,7 +92,7 @@ export const DisableAuthenticatorAppDialog = () => {
|
|||||||
onCloseTwoFactorDisableDialog();
|
onCloseTwoFactorDisableDialog();
|
||||||
});
|
});
|
||||||
|
|
||||||
await refreshSession();
|
await revalidate();
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Unable to disable two-factor authentication`),
|
title: _(msg`Unable to disable two-factor authentication`),
|
||||||
|
|||||||
@ -5,12 +5,12 @@ 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 { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useRevalidator } from 'react-router';
|
||||||
import { renderSVG } from 'uqr';
|
import { renderSVG } from 'uqr';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { authClient } from '@documenso/auth/client';
|
import { authClient } from '@documenso/auth/client';
|
||||||
import { downloadFile } from '@documenso/lib/client-only/download-file';
|
import { downloadFile } from '@documenso/lib/client-only/download-file';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@ -48,7 +48,7 @@ export type EnableAuthenticatorAppDialogProps = {
|
|||||||
export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorAppDialogProps) => {
|
export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorAppDialogProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { refreshSession } = useSession();
|
const { revalidate } = useRevalidator();
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
|
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
|
||||||
@ -74,7 +74,6 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await authClient.twoFactor.setup();
|
const data = await authClient.twoFactor.setup();
|
||||||
await refreshSession();
|
|
||||||
|
|
||||||
setSetup2FAData(data);
|
setSetup2FAData(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -93,7 +92,6 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
|
|||||||
const onEnable2FAFormSubmit = async ({ token }: TEnable2FAForm) => {
|
const onEnable2FAFormSubmit = async ({ token }: TEnable2FAForm) => {
|
||||||
try {
|
try {
|
||||||
const data = await authClient.twoFactor.enable({ code: token });
|
const data = await authClient.twoFactor.enable({ code: token });
|
||||||
await refreshSession();
|
|
||||||
|
|
||||||
setRecoveryCodes(data.recoveryCodes);
|
setRecoveryCodes(data.recoveryCodes);
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
@ -141,6 +139,7 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
|
|||||||
|
|
||||||
if (!isOpen && recoveryCodes && recoveryCodes.length > 0) {
|
if (!isOpen && recoveryCodes && recoveryCodes.length > 0) {
|
||||||
setRecoveryCodes(null);
|
setRecoveryCodes(null);
|
||||||
|
void revalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { useLingui } from '@lingui/react';
|
|||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { ErrorCode, useDropzone } from 'react-dropzone';
|
import { ErrorCode, useDropzone } from 'react-dropzone';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useRevalidator } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@ -28,6 +29,8 @@ import {
|
|||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export const ZAvatarImageFormSchema = z.object({
|
export const ZAvatarImageFormSchema = z.object({
|
||||||
bytes: z.string().nullish(),
|
bytes: z.string().nullish(),
|
||||||
});
|
});
|
||||||
@ -36,44 +39,29 @@ export type TAvatarImageFormSchema = z.infer<typeof ZAvatarImageFormSchema>;
|
|||||||
|
|
||||||
export type AvatarImageFormProps = {
|
export type AvatarImageFormProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
team?: {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
avatarImageId: string | null;
|
|
||||||
};
|
|
||||||
organisation?: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
avatarImageId: string | null;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AvatarImageForm = ({ className, team, organisation }: AvatarImageFormProps) => {
|
export const AvatarImageForm = ({ className }: AvatarImageFormProps) => {
|
||||||
const { user, refreshSession } = useSession();
|
const { user, refreshSession } = useSession();
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { revalidate } = useRevalidator();
|
||||||
|
|
||||||
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
const { mutateAsync: setProfileImage } = trpc.profile.setProfileImage.useMutation();
|
const { mutateAsync: setProfileImage } = trpc.profile.setProfileImage.useMutation();
|
||||||
|
|
||||||
const initials = extractInitials(team?.name || organisation?.name || user.name || '');
|
const initials = extractInitials(team?.name || user.name || '');
|
||||||
|
|
||||||
const hasAvatarImage = useMemo(() => {
|
const hasAvatarImage = useMemo(() => {
|
||||||
if (team) {
|
if (team) {
|
||||||
return team.avatarImageId !== null;
|
return team.avatarImageId !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (organisation) {
|
|
||||||
return organisation.avatarImageId !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return user.avatarImageId !== null;
|
return user.avatarImageId !== null;
|
||||||
}, [team, organisation, user.avatarImageId]);
|
}, [team, user.avatarImageId]);
|
||||||
|
|
||||||
const avatarImageId = team
|
const avatarImageId = team ? team.avatarImageId : user.avatarImageId;
|
||||||
? team.avatarImageId
|
|
||||||
: organisation
|
|
||||||
? organisation.avatarImageId
|
|
||||||
: user.avatarImageId;
|
|
||||||
|
|
||||||
const form = useForm<TAvatarImageFormSchema>({
|
const form = useForm<TAvatarImageFormSchema>({
|
||||||
values: {
|
values: {
|
||||||
@ -112,8 +100,7 @@ export const AvatarImageForm = ({ className, team, organisation }: AvatarImageFo
|
|||||||
try {
|
try {
|
||||||
await setProfileImage({
|
await setProfileImage({
|
||||||
bytes: data.bytes,
|
bytes: data.bytes,
|
||||||
teamId: team?.id ?? null,
|
teamId: team?.id,
|
||||||
organisationId: organisation?.id ?? null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await refreshSession();
|
await refreshSession();
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user