mirror of
https://github.com/documenso/documenso.git
synced 2025-11-12 07:43:16 +10:00
Compare commits
152 Commits
v1.9.0-rc.
...
chore/sing
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a238d99d8 | |||
| 2f48679b0b | |||
| e40c5d9d24 | |||
| ab323f149f | |||
| bf1c1ff9dc | |||
| 516e237966 | |||
| ac7d24eb12 | |||
| 0931c472a7 | |||
| 8c9dd5e372 | |||
| e108da546d | |||
| 17370749b4 | |||
| 12ada567f5 | |||
| bdb0b0ea88 | |||
| 6a41a37bd4 | |||
| d78cfec00e | |||
| f0dcf7e9bf | |||
| 6540291055 | |||
| 193325717d | |||
| b94645a451 | |||
| 7e6704faae | |||
| cf17fc61bc | |||
| 6df8b3aac8 | |||
| fdb31772db | |||
| a3dfd81870 | |||
| 755ef697ba | |||
| 37cc41d713 | |||
| dd2ef3a657 | |||
| 435b3ca4f8 | |||
| 278cd8a9de | |||
| f1526315f5 | |||
| 353a7e8e0d | |||
| 34b2504268 | |||
| 566abda36b | |||
| 9121a062b3 | |||
| e613e0e347 | |||
| 95aae52fa4 | |||
| 5958f38719 | |||
| 419bc02171 | |||
| 5e4956f3a2 | |||
| da71613c9f | |||
| 4d6efe091e | |||
| 7e6ac4db40 | |||
| a87af910c7 | |||
| e37b005d7f | |||
| 73f8518b47 | |||
| ac3deb113e | |||
| c82388c40a | |||
| 31be548939 | |||
| 063fd32f18 | |||
| 231f51bd1f | |||
| a8de8368a2 | |||
| 7dd331addf | |||
| c6743a7cec | |||
| efbc097191 | |||
| f1525991dc | |||
| fb173e4d0e | |||
| d422ffa873 | |||
| 55b7697316 | |||
| 67bbb6c6f4 | |||
| 63a4bab0fe | |||
| 9f17c1e48e | |||
| 91ae818213 | |||
| a0ace803cf | |||
| b3db3be8e9 | |||
| 44cdbeecb4 | |||
| 7214965c0c | |||
| 8d6bf91d12 | |||
| fec078081b | |||
| c646afcd97 | |||
| 63d990ce8d | |||
| aa7d6b28a4 | |||
| b990532633 | |||
| 65be37514f | |||
| 0df29fce36 | |||
| ba5b7ce480 | |||
| 422770a8c7 | |||
| 083a706373 | |||
| db326cb4a9 | |||
| d664f571d6 | |||
| 7c38970ee8 | |||
| e08d62c844 | |||
| 25bb6ffe77 | |||
| e79d762710 | |||
| d970976299 | |||
| 3dce814ab2 | |||
| ad520bb032 | |||
| 596d30e2e5 | |||
| 6474b4a524 | |||
| 5b4db51051 | |||
| cf58c80e31 | |||
| 11dbb8873e | |||
| bc7907271b | |||
| 9b376d34cf | |||
| deea99d865 | |||
| 3328074f51 | |||
| 5e69665e00 | |||
| c1c7cfaf8b | |||
| 7e8955b89c | |||
| cedd5e87b1 | |||
| 5255e8671f | |||
| d4c1bad407 | |||
| 01dccb7916 | |||
| 483d7caef7 | |||
| 139bc265c7 | |||
| 991ce5ff46 | |||
| 7728c8641c | |||
| 50a41d0799 | |||
| 250381fec8 | |||
| d2f3d24542 | |||
| ec07092bf6 | |||
| 63e2ef0abf | |||
| 90ce52164c | |||
| ac30654913 | |||
| 24f3ecd94f | |||
| a319ea0f5e | |||
| 5ce2bae39d | |||
| 5d86e84217 | |||
| 79e26a9a46 | |||
| dd602a7e1c | |||
| fb16214dc5 | |||
| 5fc724b247 | |||
| 1ed1cb0773 | |||
| 8d5fafec27 | |||
| 0f6f236e0c | |||
| e518985833 | |||
| 595e901bc2 | |||
| df8ea09021 | |||
| 180656978b | |||
| 28f5177064 | |||
| 31de86e425 | |||
| 113ab293bb | |||
| 1c4878e526 | |||
| 92db4d68db | |||
| 7379391f92 | |||
| ebc2b00067 | |||
| 87dcdd44cd | |||
| b205f7e5f3 | |||
| c5d5355cf7 | |||
| 5fac29a07f | |||
| 1aaacab6ca | |||
| 06076c1809 | |||
| c0ae68c28b | |||
| 3e106c1a2d | |||
| 741639ee78 | |||
| 0b3638c42c | |||
| 0f50110853 | |||
| b0f8c83134 | |||
| c9e8a32471 | |||
| 84b193d99c | |||
| b03c5ab1a7 | |||
| 9db42accf3 | |||
| 383b5f78f0 |
14
.env.example
14
.env.example
@ -1,5 +1,4 @@
|
|||||||
# [[AUTH]]
|
# [[AUTH]]
|
||||||
NEXTAUTH_URL="http://localhost:3000"
|
|
||||||
NEXTAUTH_SECRET="secret"
|
NEXTAUTH_SECRET="secret"
|
||||||
|
|
||||||
# [[CRYPTO]]
|
# [[CRYPTO]]
|
||||||
@ -19,14 +18,10 @@ NEXT_PRIVATE_OIDC_WELL_KNOWN=""
|
|||||||
NEXT_PRIVATE_OIDC_CLIENT_ID=""
|
NEXT_PRIVATE_OIDC_CLIENT_ID=""
|
||||||
NEXT_PRIVATE_OIDC_CLIENT_SECRET=""
|
NEXT_PRIVATE_OIDC_CLIENT_SECRET=""
|
||||||
NEXT_PRIVATE_OIDC_PROVIDER_LABEL="OIDC"
|
NEXT_PRIVATE_OIDC_PROVIDER_LABEL="OIDC"
|
||||||
# This can be used to still allow signups for OIDC connections
|
|
||||||
# when signup is disabled via `NEXT_PUBLIC_DISABLE_SIGNUP`
|
|
||||||
NEXT_PRIVATE_OIDC_ALLOW_SIGNUP=""
|
|
||||||
NEXT_PRIVATE_OIDC_SKIP_VERIFY=""
|
NEXT_PRIVATE_OIDC_SKIP_VERIFY=""
|
||||||
|
|
||||||
# [[URLS]]
|
# [[URLS]]
|
||||||
NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000"
|
NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000"
|
||||||
NEXT_PUBLIC_MARKETING_URL="http://localhost:3001"
|
|
||||||
# URL used by the web app to request itself (e.g. local background jobs)
|
# URL used by the web app to request itself (e.g. local background jobs)
|
||||||
NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000"
|
NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000"
|
||||||
|
|
||||||
@ -113,13 +108,9 @@ NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT=5
|
|||||||
# [[STRIPE]]
|
# [[STRIPE]]
|
||||||
NEXT_PRIVATE_STRIPE_API_KEY=
|
NEXT_PRIVATE_STRIPE_API_KEY=
|
||||||
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=
|
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=
|
||||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID=
|
|
||||||
NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID=
|
|
||||||
|
|
||||||
# [[BACKGROUND JOBS]]
|
# [[BACKGROUND JOBS]]
|
||||||
NEXT_PRIVATE_JOBS_PROVIDER="local"
|
NEXT_PRIVATE_JOBS_PROVIDER="local"
|
||||||
NEXT_PRIVATE_TRIGGER_API_KEY=
|
|
||||||
NEXT_PRIVATE_TRIGGER_API_URL=
|
|
||||||
NEXT_PRIVATE_INNGEST_EVENT_KEY=
|
NEXT_PRIVATE_INNGEST_EVENT_KEY=
|
||||||
|
|
||||||
# [[FEATURES]]
|
# [[FEATURES]]
|
||||||
@ -135,10 +126,5 @@ E2E_TEST_AUTHENTICATE_USERNAME="Test User"
|
|||||||
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
|
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
|
||||||
E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
|
E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
|
||||||
|
|
||||||
# This is only required for the marketing site
|
|
||||||
# [[REDIS]]
|
|
||||||
NEXT_PRIVATE_REDIS_URL=
|
|
||||||
NEXT_PRIVATE_REDIS_TOKEN=
|
|
||||||
|
|
||||||
# [[LOGGER]]
|
# [[LOGGER]]
|
||||||
NEXT_PRIVATE_LOGGER_HONEY_BADGER_API_KEY=
|
NEXT_PRIVATE_LOGGER_HONEY_BADGER_API_KEY=
|
||||||
|
|||||||
@ -5,6 +5,7 @@ module.exports = {
|
|||||||
rules: {
|
rules: {
|
||||||
'@next/next/no-img-element': 'off',
|
'@next/next/no-img-element': 'off',
|
||||||
'no-unreachable': 'error',
|
'no-unreachable': 'error',
|
||||||
|
'react-hooks/exhaustive-deps': 'off',
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
next: {
|
next: {
|
||||||
|
|||||||
23
.github/actions/cache-build/action.yml
vendored
23
.github/actions/cache-build/action.yml
vendored
@ -1,23 +0,0 @@
|
|||||||
name: Cache production build binaries
|
|
||||||
description: 'Cache or restore if necessary'
|
|
||||||
inputs:
|
|
||||||
node_version:
|
|
||||||
required: false
|
|
||||||
default: v20.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
|
|
||||||
2
.github/actions/node-install/action.yml
vendored
2
.github/actions/node-install/action.yml
vendored
@ -2,7 +2,7 @@ name: 'Setup node and cache node_modules'
|
|||||||
inputs:
|
inputs:
|
||||||
node_version:
|
node_version:
|
||||||
required: false
|
required: false
|
||||||
default: v20.x
|
default: v22.x
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: 'composite'
|
using: 'composite'
|
||||||
|
|||||||
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@ -26,7 +26,8 @@ jobs:
|
|||||||
- name: Copy env
|
- name: Copy env
|
||||||
run: cp .env.example .env
|
run: cp .env.example .env
|
||||||
|
|
||||||
- uses: ./.github/actions/cache-build
|
- name: Build app
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
build_docker:
|
build_docker:
|
||||||
name: Build Docker Image
|
name: Build Docker Image
|
||||||
|
|||||||
29
.github/workflows/clean-cache.yml
vendored
29
.github/workflows/clean-cache.yml
vendored
@ -1,29 +0,0 @@
|
|||||||
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-22.04
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
actions: read
|
actions: read
|
||||||
contents: read
|
contents: read
|
||||||
@ -30,7 +30,8 @@ jobs:
|
|||||||
|
|
||||||
- uses: ./.github/actions/node-install
|
- uses: ./.github/actions/node-install
|
||||||
|
|
||||||
- uses: ./.github/actions/cache-build
|
- name: Build app
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v3
|
uses: github/codeql-action/init@v3
|
||||||
|
|||||||
8
.github/workflows/e2e-tests.yml
vendored
8
.github/workflows/e2e-tests.yml
vendored
@ -1,7 +1,7 @@
|
|||||||
name: Playwright Tests
|
name: Playwright Tests
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: ['main']
|
branches: ['main', 'feat/rr7']
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: ['main']
|
branches: ['main']
|
||||||
jobs:
|
jobs:
|
||||||
@ -28,7 +28,11 @@ jobs:
|
|||||||
- name: Seed the database
|
- name: Seed the database
|
||||||
run: npm run prisma:seed
|
run: npm run prisma:seed
|
||||||
|
|
||||||
- uses: ./.github/actions/cache-build
|
- name: Build app
|
||||||
|
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
|
||||||
|
|||||||
@ -4,9 +4,7 @@ tasks:
|
|||||||
npm run dx:up &&
|
npm run dx:up &&
|
||||||
cp .env.example .env &&
|
cp .env.example .env &&
|
||||||
set -a; source .env &&
|
set -a; source .env &&
|
||||||
export NEXTAUTH_URL="$(gp url 3000)" &&
|
|
||||||
export NEXT_PUBLIC_WEBAPP_URL="$(gp url 3000)" &&
|
export NEXT_PUBLIC_WEBAPP_URL="$(gp url 3000)" &&
|
||||||
export NEXT_PUBLIC_MARKETING_URL="$(gp url 3001)"
|
|
||||||
command: npm run d
|
command: npm run d
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@ -1,4 +1 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
. "$(dirname -- "$0")/_/husky.sh"
|
|
||||||
|
|
||||||
npm run commitlint -- $1
|
npm run commitlint -- $1
|
||||||
|
|||||||
@ -1,15 +1,9 @@
|
|||||||
#!/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/../")"
|
||||||
|
|
||||||
echo "Copying pdf.js"
|
|
||||||
npm run copy:pdfjs --workspace apps/**
|
|
||||||
|
|
||||||
echo "Copying .well-known/ contents"
|
echo "Copying .well-known/ contents"
|
||||||
node "$MONOREPO_ROOT/scripts/copy-wellknown.cjs"
|
node "$MONOREPO_ROOT/scripts/copy-wellknown.cjs"
|
||||||
|
|
||||||
git add "$MONOREPO_ROOT/apps/web/public/"
|
git add "$MONOREPO_ROOT/apps/remix/public/"
|
||||||
|
|
||||||
npx lint-staged
|
npx lint-staged
|
||||||
|
|||||||
21
README.md
21
README.md
@ -1,7 +1,3 @@
|
|||||||
> 🚨 We are live on Product Hunt 🎉 Check out our latest launch: <a href="documen.so/sign-everywhere">The Platform Plan</a>!
|
|
||||||
|
|
||||||
<a href="https://www.producthunt.com/posts/documenso-platform-plan?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-documenso-platform-plan" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=670576&theme=light" alt="Documenso Platform Plan - Whitelabeled signing flows in your product | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
|
||||||
|
|
||||||
<img src="https://github.com/documenso/documenso/assets/13398220/a643571f-0239-46a6-a73e-6bef38d1228b" alt="Documenso Logo">
|
<img src="https://github.com/documenso/documenso/assets/13398220/a643571f-0239-46a6-a73e-6bef38d1228b" alt="Documenso Logo">
|
||||||
|
|
||||||
<p align="center" style="margin-top: 20px">
|
<p align="center" style="margin-top: 20px">
|
||||||
@ -73,9 +69,9 @@ Contact us if you are interested in our Enterprise plan for large organizations
|
|||||||
<a href="https://cal.com/timurercan/enterprise-customers?utm_source=banner&utm_campaign=oss"><img alt="Book us with Cal.com" src="https://cal.com/book-with-cal-dark.svg" /></a>
|
<a href="https://cal.com/timurercan/enterprise-customers?utm_source=banner&utm_campaign=oss"><img alt="Book us with Cal.com" src="https://cal.com/book-with-cal-dark.svg" /></a>
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
<p align="left">
|
<p align="left">
|
||||||
<a href="https://www.typescriptlang.org"><img src="https://shields.io/badge/TypeScript-3178C6?logo=TypeScript&logoColor=FFF&style=flat-square" alt="TypeScript"></a>
|
<a href="https://www.typescriptlang.org"><img src="https://shields.io/badge/TypeScript-3178C6?logo=TypeScript&logoColor=FFF&style=flat-square" alt="TypeScript"></a>
|
||||||
<a href="https://nextjs.org/"><img src="https://img.shields.io/badge/next.js-000000?style=flat-square&logo=nextdotjs&logoColor=white" alt="NextJS"></a>
|
|
||||||
<a href="https://prisma.io"><img width="122" height="20" src="http://made-with.prisma.io/indigo.svg" alt="Made with Prisma" /></a>
|
<a href="https://prisma.io"><img width="122" height="20" src="http://made-with.prisma.io/indigo.svg" alt="Made with Prisma" /></a>
|
||||||
<a href="https://tailwindcss.com/"><img src="https://img.shields.io/badge/tailwindcss-0F172A?&logo=tailwindcss" alt="Tailwind CSS"></a>
|
<a href="https://tailwindcss.com/"><img src="https://img.shields.io/badge/tailwindcss-0F172A?&logo=tailwindcss" alt="Tailwind CSS"></a>
|
||||||
<a href=""><img src="" alt=""></a>
|
<a href=""><img src="" alt=""></a>
|
||||||
@ -85,20 +81,17 @@ Contact us if you are interested in our Enterprise plan for large organizations
|
|||||||
<a href=""><img src="" alt=""></a>
|
<a href=""><img src="" alt=""></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
||||||
- [Typescript](https://www.typescriptlang.org/) - Language
|
- [Typescript](https://www.typescriptlang.org/) - Language
|
||||||
- [Next.js](https://nextjs.org/) - Framework
|
- [ReactRouter](https://reactrouter.com/) - Framework
|
||||||
- [Prisma](https://www.prisma.io/) - ORM
|
- [Prisma](https://www.prisma.io/) - ORM
|
||||||
- [Tailwind](https://tailwindcss.com/) - CSS
|
- [Tailwind](https://tailwindcss.com/) - CSS
|
||||||
- [shadcn/ui](https://ui.shadcn.com/) - Component Library
|
- [shadcn/ui](https://ui.shadcn.com/) - Component Library
|
||||||
- [NextAuth.js](https://next-auth.js.org/) - Authentication
|
|
||||||
- [react-email](https://react.email/) - Email Templates
|
- [react-email](https://react.email/) - Email Templates
|
||||||
- [tRPC](https://trpc.io/) - API
|
- [tRPC](https://trpc.io/) - API
|
||||||
- [@documenso/pdf-sign](https://www.npmjs.com/package/@documenso/pdf-sign) - PDF Signatures (launching soon)
|
- [@documenso/pdf-sign](https://www.npmjs.com/package/@documenso/pdf-sign) - PDF Signatures (launching soon)
|
||||||
- [React-PDF](https://github.com/wojtekmaj/react-pdf) - Viewing PDFs
|
- [React-PDF](https://github.com/wojtekmaj/react-pdf) - Viewing PDFs
|
||||||
- [PDF-Lib](https://github.com/Hopding/pdf-lib) - PDF manipulation
|
- [PDF-Lib](https://github.com/Hopding/pdf-lib) - PDF manipulation
|
||||||
- [Stripe](https://stripe.com/) - Payments
|
- [Stripe](https://stripe.com/) - Payments
|
||||||
- [Vercel](https://vercel.com) - Hosting
|
|
||||||
|
|
||||||
<!-- - Support for [opensignpdf (requires Java on server)](https://github.com/open-pdf-sign) is currently planned. -->
|
<!-- - Support for [opensignpdf (requires Java on server)](https://github.com/open-pdf-sign) is currently planned. -->
|
||||||
|
|
||||||
@ -108,7 +101,7 @@ Contact us if you are interested in our Enterprise plan for large organizations
|
|||||||
|
|
||||||
To run Documenso locally, you will need
|
To run Documenso locally, you will need
|
||||||
|
|
||||||
- Node.js (v18 or above)
|
- Node.js (v22 or above)
|
||||||
- Postgres SQL Database
|
- Postgres SQL Database
|
||||||
- Docker (optional)
|
- Docker (optional)
|
||||||
|
|
||||||
@ -171,10 +164,8 @@ git clone https://github.com/<your-username>/documenso
|
|||||||
|
|
||||||
4. Set the following environment variables:
|
4. Set the following environment variables:
|
||||||
|
|
||||||
- NEXTAUTH_URL
|
|
||||||
- NEXTAUTH_SECRET
|
- NEXTAUTH_SECRET
|
||||||
- NEXT_PUBLIC_WEBAPP_URL
|
- NEXT_PUBLIC_WEBAPP_URL
|
||||||
- NEXT_PUBLIC_MARKETING_URL
|
|
||||||
- NEXT_PRIVATE_DATABASE_URL
|
- NEXT_PRIVATE_DATABASE_URL
|
||||||
- NEXT_PRIVATE_DIRECT_DATABASE_URL
|
- NEXT_PRIVATE_DIRECT_DATABASE_URL
|
||||||
- NEXT_PRIVATE_SMTP_FROM_NAME
|
- NEXT_PRIVATE_SMTP_FROM_NAME
|
||||||
@ -243,16 +234,14 @@ cp .env.example .env
|
|||||||
|
|
||||||
The following environment variables must be set:
|
The following environment variables must be set:
|
||||||
|
|
||||||
- `NEXTAUTH_URL`
|
|
||||||
- `NEXTAUTH_SECRET`
|
- `NEXTAUTH_SECRET`
|
||||||
- `NEXT_PUBLIC_WEBAPP_URL`
|
- `NEXT_PUBLIC_WEBAPP_URL`
|
||||||
- `NEXT_PUBLIC_MARKETING_URL`
|
|
||||||
- `NEXT_PRIVATE_DATABASE_URL`
|
- `NEXT_PRIVATE_DATABASE_URL`
|
||||||
- `NEXT_PRIVATE_DIRECT_DATABASE_URL`
|
- `NEXT_PRIVATE_DIRECT_DATABASE_URL`
|
||||||
- `NEXT_PRIVATE_SMTP_FROM_NAME`
|
- `NEXT_PRIVATE_SMTP_FROM_NAME`
|
||||||
- `NEXT_PRIVATE_SMTP_FROM_ADDRESS`
|
- `NEXT_PRIVATE_SMTP_FROM_ADDRESS`
|
||||||
|
|
||||||
> If you are using a reverse proxy in front of Documenso, don't forget to provide the public URL for both `NEXTAUTH_URL` and `NEXT_PUBLIC_WEBAPP_URL` variables!
|
> If you are using a reverse proxy in front of Documenso, don't forget to provide the public URL for the `NEXT_PUBLIC_WEBAPP_URL` variable!
|
||||||
|
|
||||||
Now you can install the dependencies and build it:
|
Now you can install the dependencies and build it:
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { Button } from '../primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Card, CardContent } from '../primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
|
|
||||||
type CallToActionProps = {
|
type CallToActionProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -7,8 +7,7 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start -p 3002",
|
"start": "next start -p 3002",
|
||||||
"lint:fix": "next lint --fix",
|
"lint:fix": "next lint --fix",
|
||||||
"clean": "rimraf .next && rimraf node_modules",
|
"clean": "rimraf .next && rimraf node_modules"
|
||||||
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@documenso/assets": "*",
|
"@documenso/assets": "*",
|
||||||
|
|||||||
@ -14,4 +14,4 @@
|
|||||||
"public-api": "Public API",
|
"public-api": "Public API",
|
||||||
"embedding": "Embedding",
|
"embedding": "Embedding",
|
||||||
"webhooks": "Webhooks"
|
"webhooks": "Webhooks"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,5 +6,6 @@
|
|||||||
"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"
|
||||||
|
}
|
||||||
167
apps/documentation/pages/developers/embedding/authoring.mdx
Normal file
167
apps/documentation/pages/developers/embedding/authoring.mdx
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
@ -111,6 +111,83 @@ The colors will be automatically converted to the appropriate format internally.
|
|||||||
|
|
||||||
4. **Consistent Radius**: Use a consistent border radius value that matches your application's design system.
|
4. **Consistent Radius**: Use a consistent border radius value that matches your application's design system.
|
||||||
|
|
||||||
|
## CSS Class Targets
|
||||||
|
|
||||||
|
In addition to CSS variables, specific components in the embedded experience can be targeted using CSS classes for more granular styling:
|
||||||
|
|
||||||
|
### Component Classes
|
||||||
|
|
||||||
|
| Class Name | Description |
|
||||||
|
| --------------------------------- | ----------------------------------------------------------------------- |
|
||||||
|
| `.embed--Root` | Main container for the embedded signing experience |
|
||||||
|
| `.embed--DocumentContainer` | Container for the document and signing widget |
|
||||||
|
| `.embed--DocumentViewer` | Container for the document viewer |
|
||||||
|
| `.embed--DocumentWidget` | The signing widget container |
|
||||||
|
| `.embed--DocumentWidgetContainer` | Outer container for the signing widget, handles positioning |
|
||||||
|
| `.embed--DocumentWidgetHeader` | Header section of the signing widget |
|
||||||
|
| `.embed--DocumentWidgetContent` | Main content area of the signing widget |
|
||||||
|
| `.embed--DocumentWidgetForm` | Form section within the signing widget |
|
||||||
|
| `.embed--DocumentWidgetFooter` | Footer section of the signing widget |
|
||||||
|
| `.embed--WaitingForTurn` | Container for the waiting screen when it's not the user's turn to sign |
|
||||||
|
| `.embed--DocumentCompleted` | Container for the completion screen after signing |
|
||||||
|
| `.field--FieldRootContainer` | Base container for document fields (signatures, text, checkboxes, etc.) |
|
||||||
|
|
||||||
|
Field components also expose several data attributes that can be used for styling different states:
|
||||||
|
|
||||||
|
| Data Attribute | Values | Description |
|
||||||
|
| ------------------- | ---------------------------------------------- | ------------------------------------ |
|
||||||
|
| `[data-field-type]` | `SIGNATURE`, `TEXT`, `CHECKBOX`, `RADIO`, etc. | The type of field |
|
||||||
|
| `[data-inserted]` | `true`, `false` | Whether the field has been filled |
|
||||||
|
| `[data-validate]` | `true`, `false` | Whether the field is being validated |
|
||||||
|
|
||||||
|
### Field Styling Example
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Style all field containers */
|
||||||
|
.field--FieldRootContainer {
|
||||||
|
transition: all 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Style specific field types */
|
||||||
|
.field--FieldRootContainer[data-field-type='SIGNATURE'] {
|
||||||
|
background-color: rgba(0, 0, 0, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Style inserted fields */
|
||||||
|
.field--FieldRootContainer[data-inserted='true'] {
|
||||||
|
background-color: var(--primary);
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Style fields being validated */
|
||||||
|
.field--FieldRootContainer[data-validate='true'] {
|
||||||
|
border-color: orange;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example Usage
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Custom styles for the document widget */
|
||||||
|
.embed--DocumentWidget {
|
||||||
|
background-color: #ffffff;
|
||||||
|
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom styles for the waiting screen */
|
||||||
|
.embed--WaitingForTurn {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments for the document container */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.embed--DocumentContainer {
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
- [React Integration](/developers/embedding/react)
|
- [React Integration](/developers/embedding/react)
|
||||||
|
|||||||
@ -52,9 +52,9 @@ Platform customers have access to advanced styling options to customize the embe
|
|||||||
<EmbedDirectTemplate
|
<EmbedDirectTemplate
|
||||||
token={token}
|
token={token}
|
||||||
cssVars={{
|
cssVars={{
|
||||||
colorPrimary: '#0000FF',
|
primary: '#0000FF',
|
||||||
colorBackground: '#F5F5F5',
|
background: '#F5F5F5',
|
||||||
borderRadius: '8px',
|
radius: '8px',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
```
|
```
|
||||||
@ -169,6 +169,19 @@ 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)
|
||||||
@ -178,3 +191,4 @@ If you're using **web components**, the integration process is slightly differen
|
|||||||
- [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 = {
|
||||||
colorPrimary: '#0000FF',
|
primary: '#0000FF',
|
||||||
colorBackground: '#F5F5F5',
|
background: '#F5F5F5',
|
||||||
borderRadius: '8px',
|
radius: '8px',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -99,9 +99,9 @@ const MyEmbeddingComponent = () => {
|
|||||||
`}
|
`}
|
||||||
// CSS Variables
|
// CSS Variables
|
||||||
cssVars={{
|
cssVars={{
|
||||||
colorPrimary: '#0000FF',
|
primary: '#0000FF',
|
||||||
colorBackground: '#F5F5F5',
|
background: '#F5F5F5',
|
||||||
borderRadius: '8px',
|
radius: '8px',
|
||||||
}}
|
}}
|
||||||
// Dark Mode Control
|
// Dark Mode Control
|
||||||
darkModeDisabled={true}
|
darkModeDisabled={true}
|
||||||
|
|||||||
@ -95,9 +95,9 @@ const MyEmbeddingComponent = () => {
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const cssVars = {
|
const cssVars = {
|
||||||
colorPrimary: '#0000FF',
|
primary: '#0000FF',
|
||||||
colorBackground: '#F5F5F5',
|
background: '#F5F5F5',
|
||||||
borderRadius: '8px',
|
radius: '8px',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -97,9 +97,9 @@ Platform customers have access to advanced styling options:
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const cssVars = {
|
const cssVars = {
|
||||||
colorPrimary: '#0000FF',
|
primary: '#0000FF',
|
||||||
colorBackground: '#F5F5F5',
|
background: '#F5F5F5',
|
||||||
borderRadius: '8px',
|
radius: '8px',
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -97,9 +97,9 @@ Platform customers have access to advanced styling options:
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const cssVars = {
|
const cssVars = {
|
||||||
colorPrimary: '#0000FF',
|
primary: '#0000FF',
|
||||||
colorBackground: '#F5F5F5',
|
background: '#F5F5F5',
|
||||||
borderRadius: '8px',
|
radius: '8px',
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -16,18 +16,16 @@ Pick the one that fits your needs the best.
|
|||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- [Typescript](https://www.typescriptlang.org/) - Language
|
- [Typescript](https://www.typescriptlang.org/) - Language
|
||||||
- [Next.js](https://nextjs.org/) - Framework
|
- [React Router](https://reactrouter.com/) - Framework
|
||||||
- [Prisma](https://www.prisma.io/) - ORM
|
- [Prisma](https://www.prisma.io/) - ORM
|
||||||
- [Tailwind](https://tailwindcss.com/) - CSS
|
- [Tailwind](https://tailwindcss.com/) - CSS
|
||||||
- [shadcn/ui](https://ui.shadcn.com/) - Component Library
|
- [shadcn/ui](https://ui.shadcn.com/) - Component Library
|
||||||
- [NextAuth.js](https://next-auth.js.org/) - Authentication
|
|
||||||
- [react-email](https://react.email/) - Email Templates
|
- [react-email](https://react.email/) - Email Templates
|
||||||
- [tRPC](https://trpc.io/) - API
|
- [tRPC](https://trpc.io/) - API
|
||||||
- [@documenso/pdf-sign](https://www.npmjs.com/package/@documenso/pdf-sign) - PDF Signatures
|
- [@documenso/pdf-sign](https://www.npmjs.com/package/@documenso/pdf-sign) - PDF Signatures
|
||||||
- [React-PDF](https://github.com/wojtekmaj/react-pdf) - Viewing PDFs
|
- [React-PDF](https://github.com/wojtekmaj/react-pdf) - Viewing PDFs
|
||||||
- [PDF-Lib](https://github.com/Hopding/pdf-lib) - PDF manipulation
|
- [PDF-Lib](https://github.com/Hopding/pdf-lib) - PDF manipulation
|
||||||
- [Stripe](https://stripe.com/) - Payments
|
- [Stripe](https://stripe.com/) - Payments
|
||||||
- [Vercel](https://vercel.com) - Hosting
|
|
||||||
|
|
||||||
<div className="mt-16 flex items-center justify-center gap-4">
|
<div className="mt-16 flex items-center justify-center gap-4">
|
||||||
<a href="https://documen.so/discord">
|
<a href="https://documen.so/discord">
|
||||||
|
|||||||
@ -32,10 +32,8 @@ Run `npm i` in the root directory to install the dependencies required for the p
|
|||||||
Set up the following environment variables in the `.env` file:
|
Set up the following environment variables in the `.env` file:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
NEXTAUTH_URL
|
|
||||||
NEXTAUTH_SECRET
|
NEXTAUTH_SECRET
|
||||||
NEXT_PUBLIC_WEBAPP_URL
|
NEXT_PUBLIC_WEBAPP_URL
|
||||||
NEXT_PUBLIC_MARKETING_URL
|
|
||||||
NEXT_PRIVATE_DATABASE_URL
|
NEXT_PRIVATE_DATABASE_URL
|
||||||
NEXT_PRIVATE_DIRECT_DATABASE_URL
|
NEXT_PRIVATE_DIRECT_DATABASE_URL
|
||||||
NEXT_PRIVATE_SMTP_FROM_NAME
|
NEXT_PRIVATE_SMTP_FROM_NAME
|
||||||
|
|||||||
@ -13,35 +13,13 @@ Documenso uses the following stack to handle translations:
|
|||||||
|
|
||||||
Additional reading can be found in the [Lingui documentation](https://lingui.dev/introduction).
|
Additional reading can be found in the [Lingui documentation](https://lingui.dev/introduction).
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
You **must** insert **`setupI18nSSR()`** when creating any of the following files:
|
|
||||||
|
|
||||||
- Server layout.tsx
|
|
||||||
- Server page.tsx
|
|
||||||
- Server loading.tsx
|
|
||||||
|
|
||||||
Server meaning it does not have `'use client'` in it.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
|
||||||
|
|
||||||
export default function SomePage() {
|
|
||||||
setupI18nSSR(); // Required if there are translations within the page, or nested in components.
|
|
||||||
|
|
||||||
// Rest of code...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Additional information can be found [here.](https://lingui.dev/tutorials/react-rsc#pages-layouts-and-lingui)
|
|
||||||
|
|
||||||
## Quick guide
|
## Quick guide
|
||||||
|
|
||||||
If you require more in-depth information, please see the [Lingui documentation](https://lingui.dev/introduction).
|
If you require more in-depth information, please see the [Lingui documentation](https://lingui.dev/introduction).
|
||||||
|
|
||||||
### HTML
|
### HTML
|
||||||
|
|
||||||
Wrap all text to translate in **`<Trans></Trans>`** tags exported from **@lingui/macro** (not @lingui/react).
|
Wrap all text to translate in **`<Trans></Trans>`** tags exported from **@lingui/react/macro**.
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<h1>
|
<h1>
|
||||||
@ -64,8 +42,9 @@ For text that is broken into elements, but represent a whole sentence, you must
|
|||||||
### Constants outside of react components
|
### Constants outside of react components
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Trans, msg } from '@lingui/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
|
||||||
// Wrap text in msg`text to translate` when it's in a constant here, or another file/package.
|
// Wrap text in msg`text to translate` when it's in a constant here, or another file/package.
|
||||||
export const CONSTANT_WITH_MSG = {
|
export const CONSTANT_WITH_MSG = {
|
||||||
@ -98,31 +77,13 @@ Lingui provides a Plural component to make it easy. See full documentation [here
|
|||||||
|
|
||||||
Lingui provides a [DateTime instance](https://lingui.dev/ref/core#i18n.date) with the configured locale.
|
Lingui provides a [DateTime instance](https://lingui.dev/ref/core#i18n.date) with the configured locale.
|
||||||
|
|
||||||
#### Server components
|
|
||||||
|
|
||||||
Note that the i18n instance is coming from **setupI18nSSR**.
|
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Trans } from '@lingui/macro';
|
import { Trans } from '@lingui/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
export const SomeComponent = () => {
|
export const SomeComponent = () => {
|
||||||
const { i18n } = setupI18nSSR();
|
const { i18n } = useLingui();
|
||||||
|
|
||||||
return <Trans>The current date is {i18n.date(new Date(), { dateStyle: 'short' })}</Trans>;
|
return <Trans>The current date is {i18n.date(new Date(), { dateStyle: 'short' })}</Trans>;
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Client components
|
|
||||||
|
|
||||||
Note that the i18n instance is coming from the **import**.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { i18n } from '@lingui/core';
|
|
||||||
import { Trans } from '@lingui/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
|
|
||||||
export const SomeComponent = () => {
|
|
||||||
return <Trans>The current date is {i18n.date(new Date(), { dateStyle: 'short' })}</Trans>;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|||||||
@ -21,14 +21,25 @@ Check out the [API V1 documentation](https://app.documenso.com/api/v1/openapi) f
|
|||||||
|
|
||||||
## API V2 - Beta
|
## API V2 - Beta
|
||||||
|
|
||||||
Our new API V2 is currently in Beta. The new API features typed SDKs for TypeScript, Python and Go and example code for many more.
|
<Callout type="warning">API V2 is currently beta, and will be subject to breaking changes</Callout>
|
||||||
|
|
||||||
<Callout type="warning">
|
Check out the [API V2 documentation](https://documen.so/api-v2-docs) for details about the API endpoints, request parameters, response formats, and authentication methods.
|
||||||
NOW IN BETA: [API V2 Documentation](https://documen.so/api-v2-docs)
|
|
||||||
|
Our new API V2 supports the following typed SDKs:
|
||||||
|
|
||||||
|
- [TypeScript](https://github.com/documenso/sdk-typescript)
|
||||||
|
- [Python](https://github.com/documenso/sdk-python)
|
||||||
|
- [Go](https://github.com/documenso/sdk-go)
|
||||||
|
|
||||||
|
<Callout type="info">
|
||||||
|
For the staging API, please use the following base URL:
|
||||||
|
`https://stg-app.documenso.dev/api/v2-beta/`
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
🚀 [V2 Announcement](https://documen.so/sdk-blog)
|
🚀 [V2 Announcement](https://documen.so/sdk-blog)
|
||||||
|
|
||||||
|
📖 [Documentation](https://documen.so/api-v2-docs)
|
||||||
|
|
||||||
💬 [Leave Feedback](https://documen.so/sdk-feedback)
|
💬 [Leave Feedback](https://documen.so/sdk-feedback)
|
||||||
|
|
||||||
🔔 [Breaking Changes](https://documen.so/sdk-breaking)
|
🔔 [Breaking Changes](https://documen.so/sdk-breaking)
|
||||||
|
|||||||
@ -532,3 +532,93 @@ 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).
|
||||||
|
|||||||
@ -5,7 +5,7 @@ description: Learn how to self-host Documenso on your server or cloud infrastruc
|
|||||||
|
|
||||||
import { Callout, Steps } from 'nextra/components';
|
import { Callout, Steps } from 'nextra/components';
|
||||||
|
|
||||||
import { CallToAction } from '@documenso/ui/components/call-to-action';
|
import { CallToAction } from '../../../components/call-to-action';
|
||||||
|
|
||||||
# Self Hosting
|
# Self Hosting
|
||||||
|
|
||||||
@ -35,10 +35,8 @@ cp .env.example .env
|
|||||||
Open the `.env` file and fill in the following variables:
|
Open the `.env` file and fill in the following variables:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
- NEXTAUTH_URL
|
|
||||||
- NEXTAUTH_SECRET
|
- NEXTAUTH_SECRET
|
||||||
- NEXT_PUBLIC_WEBAPP_URL
|
- NEXT_PUBLIC_WEBAPP_URL
|
||||||
- NEXT_PUBLIC_MARKETING_URL
|
|
||||||
- NEXT_PRIVATE_DATABASE_URL
|
- NEXT_PRIVATE_DATABASE_URL
|
||||||
- NEXT_PRIVATE_DIRECT_DATABASE_URL
|
- NEXT_PRIVATE_DIRECT_DATABASE_URL
|
||||||
- NEXT_PRIVATE_SMTP_FROM_NAME
|
- NEXT_PRIVATE_SMTP_FROM_NAME
|
||||||
@ -46,8 +44,8 @@ Open the `.env` file and fill in the following variables:
|
|||||||
```
|
```
|
||||||
|
|
||||||
<Callout type="info">
|
<Callout type="info">
|
||||||
If you use a reverse proxy in front of Documenso, don't forget to provide the public URL for both
|
If you use a reverse proxy in front of Documenso, don't forget to provide the public URL for the
|
||||||
the `NEXTAUTH_URL` and `NEXT_PUBLIC_WEBAPP_URL` variables!
|
`NEXT_PUBLIC_WEBAPP_URL` variable!
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
### Install the Dependencies
|
### Install the Dependencies
|
||||||
@ -171,7 +169,6 @@ Run the Docker container with the required environment variables:
|
|||||||
```bash
|
```bash
|
||||||
docker run -d \
|
docker run -d \
|
||||||
-p 3000:3000 \
|
-p 3000:3000 \
|
||||||
-e NEXTAUTH_URL="<your-nextauth-url>"
|
|
||||||
-e NEXTAUTH_SECRET="<your-nextauth-secret>"
|
-e NEXTAUTH_SECRET="<your-nextauth-secret>"
|
||||||
-e NEXT_PRIVATE_ENCRYPTION_KEY="<your-next-private-encryption-key>"
|
-e NEXT_PRIVATE_ENCRYPTION_KEY="<your-next-private-encryption-key>"
|
||||||
-e NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="<your-next-private-encryption-secondary-key>"
|
-e NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="<your-next-private-encryption-secondary-key>"
|
||||||
@ -200,7 +197,6 @@ The environment variables listed above are a subset of those available for confi
|
|||||||
| Variable | Description |
|
| Variable | Description |
|
||||||
| -------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
| -------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||||
| `PORT` | The port on which the Documenso application runs. It defaults to `3000`. |
|
| `PORT` | The port on which the Documenso application runs. It defaults to `3000`. |
|
||||||
| `NEXTAUTH_URL` | The URL for the NextAuth.js authentication service. |
|
|
||||||
| `NEXTAUTH_SECRET` | The secret key used by NextAuth.js for encryption and signing. |
|
| `NEXTAUTH_SECRET` | The secret key used by NextAuth.js for encryption and signing. |
|
||||||
| `NEXT_PRIVATE_ENCRYPTION_KEY` | The primary encryption key for symmetric encryption and decryption (at least 32 characters). |
|
| `NEXT_PRIVATE_ENCRYPTION_KEY` | The primary encryption key for symmetric encryption and decryption (at least 32 characters). |
|
||||||
| `NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY` | The secondary encryption key for symmetric encryption and decryption (at least 32 characters). |
|
| `NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY` | The secondary encryption key for symmetric encryption and decryption (at least 32 characters). |
|
||||||
|
|||||||
@ -3,7 +3,7 @@ title: Getting Started with Self-Hosting
|
|||||||
description: A step-by-step guide to setting up and hosting your own Documenso instance.
|
description: A step-by-step guide to setting up and hosting your own Documenso instance.
|
||||||
---
|
---
|
||||||
|
|
||||||
import { CallToAction } from '@documenso/ui/components/call-to-action';
|
import { CallToAction } from '../../../components/call-to-action';
|
||||||
|
|
||||||
# Getting Started with Self-Hosting
|
# Getting Started with Self-Hosting
|
||||||
|
|
||||||
|
|||||||
@ -85,12 +85,13 @@ You can also set the recipient's role, which determines their actions and permis
|
|||||||
|
|
||||||
Documenso has 4 roles for recipients with different permissions and actions.
|
Documenso has 4 roles for recipients with different permissions and actions.
|
||||||
|
|
||||||
| Role | Function | Action required | Signature |
|
| Role | Function | Action required | Signature |
|
||||||
| :------: | :-----------------------------------------------------------------------------: | :-------------: | :-------: |
|
| :-------: | :-----------------------------------------------------------------------------: | :-------------: | :-------: |
|
||||||
| Signer | Needs to sign signatures fields assigned to them. | Yes | Yes |
|
| Signer | Needs to sign signatures fields assigned to them. | Yes | Yes |
|
||||||
| Approver | Needs to approve the document as a whole. Signature optional. | Yes | Optional |
|
| Approver | Needs to approve the document as a whole. Signature optional. | Yes | Optional |
|
||||||
| Viewer | Needs to confirm they viewed the document. | Yes | No |
|
| Viewer | Needs to confirm they viewed the document. | Yes | No |
|
||||||
| BCC | Receives a copy of the signed document after completion. No action is required. | No | No |
|
| Assistant | Can help prepare the document by filling in fields on behalf of other signers. | Yes | No |
|
||||||
|
| CC | Receives a copy of the signed document after completion. No action is required. | No | No |
|
||||||
|
|
||||||
### Fields
|
### Fields
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import NextPlausibleProvider from 'next-plausible';
|
import NextPlausibleProvider from 'next-plausible';
|
||||||
|
|||||||
54
apps/openpage-api/lib/add-zero-month.ts
Normal file
54
apps/openpage-api/lib/add-zero-month.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
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,7 +1,9 @@
|
|||||||
|
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
|
||||||
@ -35,7 +37,7 @@ export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative'
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
return transformedData;
|
return addZeroMonth(transformedData);
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetCompletedDocumentsMonthlyResult = Awaited<
|
export type GetCompletedDocumentsMonthlyResult = Awaited<
|
||||||
|
|||||||
@ -2,6 +2,8 @@ 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')
|
||||||
@ -34,7 +36,7 @@ export const getSignerConversionMonthly = async (type: 'count' | 'cumulative' =
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
return transformedData;
|
return addZeroMonth(transformedData);
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetSignerConversionMonthlyResult = Awaited<
|
export type GetSignerConversionMonthlyResult = Awaited<
|
||||||
|
|||||||
@ -2,6 +2,8 @@ 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')
|
||||||
@ -32,7 +34,7 @@ export const getUserMonthlyGrowth = async (type: 'count' | 'cumulative' = 'count
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
return transformedData;
|
return addZeroMonth(transformedData);
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetUserMonthlyGrowthResult = Awaited<ReturnType<typeof getUserMonthlyGrowth>>;
|
export type GetUserMonthlyGrowthResult = Awaited<ReturnType<typeof getUserMonthlyGrowth>>;
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
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;
|
||||||
@ -37,31 +39,77 @@ export function transformData({
|
|||||||
data: DataEntry;
|
data: DataEntry;
|
||||||
metric: MetricKey;
|
metric: MetricKey;
|
||||||
}): TransformData {
|
}): TransformData {
|
||||||
const sortedEntries = Object.entries(data).sort(([dateA], [dateB]) => {
|
try {
|
||||||
const [yearA, monthA] = dateA.split('-').map(Number);
|
if (!data || Object.keys(data).length === 0) {
|
||||||
const [yearB, monthB] = dateB.split('-').map(Number);
|
return {
|
||||||
|
labels: [],
|
||||||
|
datasets: [{ label: `Total ${FRIENDLY_METRIC_NAMES[metric]}`, data: [] }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return DateTime.local(yearA, monthA).toMillis() - DateTime.local(yearB, monthB).toMillis();
|
const sortedEntries = Object.entries(data).sort(([dateA], [dateB]) => {
|
||||||
});
|
try {
|
||||||
|
const [yearA, monthA] = dateA.split('-').map(Number);
|
||||||
|
const [yearB, monthB] = dateB.split('-').map(Number);
|
||||||
|
|
||||||
const labels = sortedEntries.map(([date]) => {
|
if (isNaN(yearA) || isNaN(monthA) || isNaN(yearB) || isNaN(monthB)) {
|
||||||
const [year, month] = date.split('-');
|
console.warn(`Invalid date format: ${dateA} or ${dateB}`);
|
||||||
const dateTime = DateTime.fromObject({
|
return 0;
|
||||||
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');
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
const labels = sortedEntries.map(([date]) => {
|
||||||
labels,
|
try {
|
||||||
datasets: [
|
const [year, month] = date.split('-');
|
||||||
{
|
|
||||||
label: `Total ${FRIENDLY_METRIC_NAMES[metric]}`,
|
if (!year || !month || isNaN(Number(year)) || isNaN(Number(month))) {
|
||||||
data: sortedEntries.map(([_, stats]) => stats[metric]),
|
console.warn(`Invalid date format: ${date}`);
|
||||||
},
|
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
|
||||||
|
|||||||
@ -7,8 +7,7 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint:fix": "next lint --fix",
|
"lint:fix": "next lint --fix",
|
||||||
"clean": "rimraf .next && rimraf node_modules",
|
"clean": "rimraf .next && rimraf node_modules"
|
||||||
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@documenso/prisma": "*",
|
"@documenso/prisma": "*",
|
||||||
|
|||||||
37
apps/remix/.bin/build.sh
Executable file
37
apps/remix/.bin/build.sh
Executable file
@ -0,0 +1,37 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Exit on error.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
|
||||||
|
WEB_APP_DIR="$SCRIPT_DIR/.."
|
||||||
|
|
||||||
|
# Store the original directory
|
||||||
|
ORIGINAL_DIR=$(pwd)
|
||||||
|
|
||||||
|
# Set up trap to ensure we return to original directory
|
||||||
|
trap 'cd "$ORIGINAL_DIR"' EXIT
|
||||||
|
|
||||||
|
cd "$WEB_APP_DIR"
|
||||||
|
|
||||||
|
start_time=$(date +%s)
|
||||||
|
|
||||||
|
echo "[Build]: Extracting and compiling translations"
|
||||||
|
npm run translate --prefix ../../
|
||||||
|
|
||||||
|
echo "[Build]: Building app"
|
||||||
|
npm run build:app
|
||||||
|
|
||||||
|
echo "[Build]: Building server"
|
||||||
|
npm run build:server
|
||||||
|
|
||||||
|
# Copy over the entry point for the server.
|
||||||
|
cp server/main.js build/server/main.js
|
||||||
|
|
||||||
|
# Copy over all web.js translations
|
||||||
|
cp -r ../../packages/lib/translations build/server/hono/packages/lib/translations
|
||||||
|
|
||||||
|
# Time taken
|
||||||
|
end_time=$(date +%s)
|
||||||
|
|
||||||
|
echo "[Build]: Done in $((end_time - start_time)) seconds"
|
||||||
77
apps/remix/.bin/stripe-dev.sh
Executable file
77
apps/remix/.bin/stripe-dev.sh
Executable file
@ -0,0 +1,77 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Set Error handling
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
|
||||||
|
WEB_APP_DIR="$SCRIPT_DIR/.."
|
||||||
|
|
||||||
|
# Store the original directory
|
||||||
|
ORIGINAL_DIR=$(pwd)
|
||||||
|
|
||||||
|
# Set up trap to ensure we return to original directory
|
||||||
|
trap 'cd "$ORIGINAL_DIR"' EXIT
|
||||||
|
|
||||||
|
cd "$WEB_APP_DIR"
|
||||||
|
|
||||||
|
# Define env file paths
|
||||||
|
ENV_LOCAL_FILE="../../.env.local"
|
||||||
|
|
||||||
|
# Function to load environment variable from env files
|
||||||
|
load_env_var() {
|
||||||
|
local var_name=$1
|
||||||
|
local var_value=""
|
||||||
|
|
||||||
|
if [ -f "$ENV_LOCAL_FILE" ]; then
|
||||||
|
var_value=$(grep "^$var_name=" "$ENV_LOCAL_FILE" | cut -d '=' -f2)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Remove quotes if present
|
||||||
|
var_value=$(echo "$var_value" | sed 's/^"\(.*\)"$/\1/' | sed "s/^'\(.*\)'$/\1/")
|
||||||
|
|
||||||
|
echo "$var_value"
|
||||||
|
}
|
||||||
|
|
||||||
|
NEXT_PUBLIC_FEATURE_BILLING_ENABLED=$(load_env_var "NEXT_PUBLIC_FEATURE_BILLING_ENABLED")
|
||||||
|
|
||||||
|
# Check if NEXT_PUBLIC_FEATURE_BILLING_ENABLED is equal to true
|
||||||
|
if [ "$NEXT_PUBLIC_FEATURE_BILLING_ENABLED" != "true" ]; then
|
||||||
|
echo "[ERROR]: NEXT_PUBLIC_FEATURE_BILLING_ENABLED must be enabled."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 1. Load NEXT_PRIVATE_STRIPE_API_KEY from env files
|
||||||
|
NEXT_PRIVATE_STRIPE_API_KEY=$(load_env_var "NEXT_PRIVATE_STRIPE_API_KEY")
|
||||||
|
|
||||||
|
# Check if NEXT_PRIVATE_STRIPE_API_KEY exists
|
||||||
|
if [ -z "$NEXT_PRIVATE_STRIPE_API_KEY" ]; then
|
||||||
|
echo "[ERROR]: NEXT_PRIVATE_STRIPE_API_KEY not found in environment files."
|
||||||
|
echo "[ERROR]: Please make sure it's set in $ENV_LOCAL_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Check if stripe CLI is installed
|
||||||
|
if ! command -v stripe &> /dev/null; then
|
||||||
|
echo "[ERROR]: Stripe CLI is not installed or not in PATH."
|
||||||
|
echo "[ERROR]: Please install the Stripe CLI: https://stripe.com/docs/stripe-cli"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. Check if NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET env key exists
|
||||||
|
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=$(load_env_var "NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET")
|
||||||
|
|
||||||
|
if [ -z "$NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET" ]; then
|
||||||
|
echo "╔═════════════════════════════════════════════════════════════════════╗"
|
||||||
|
echo "║ ║"
|
||||||
|
echo "║ ! WARNING: NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET MISSING ! ║"
|
||||||
|
echo "║ ║"
|
||||||
|
echo "║ Copy the webhook signing secret which will appear in the terminal ║"
|
||||||
|
echo "║ soon into the env file. ║"
|
||||||
|
echo "║ ║"
|
||||||
|
echo "║ The webhook secret will start with whsec_... ║"
|
||||||
|
echo "║ ║"
|
||||||
|
echo "╚═════════════════════════════════════════════════════════════════════╝"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[INFO]: Starting Stripe webhook listener..."
|
||||||
|
stripe listen --api-key "$NEXT_PRIVATE_STRIPE_API_KEY" --forward-to http://localhost:3000/api/stripe/webhook
|
||||||
4
apps/remix/.dockerignore
Normal file
4
apps/remix/.dockerignore
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
.react-router
|
||||||
|
build
|
||||||
|
node_modules
|
||||||
|
README.md
|
||||||
9
apps/remix/.gitignore
vendored
Normal file
9
apps/remix/.gitignore
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
.DS_Store
|
||||||
|
/node_modules/
|
||||||
|
|
||||||
|
# React Router
|
||||||
|
/.react-router/
|
||||||
|
/build/
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
vite.config.*.timestamp*
|
||||||
22
apps/remix/Dockerfile
Normal file
22
apps/remix/Dockerfile
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
FROM node:20-alpine AS development-dependencies-env
|
||||||
|
COPY . /app
|
||||||
|
WORKDIR /app
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
FROM node:20-alpine AS production-dependencies-env
|
||||||
|
COPY ./package.json package-lock.json /app/
|
||||||
|
WORKDIR /app
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
|
FROM node:20-alpine AS build-env
|
||||||
|
COPY . /app/
|
||||||
|
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
|
||||||
|
WORKDIR /app
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:20-alpine
|
||||||
|
COPY ./package.json package-lock.json /app/
|
||||||
|
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
|
||||||
|
COPY --from=build-env /app/build /app/build
|
||||||
|
WORKDIR /app
|
||||||
|
CMD ["npm", "run", "start"]
|
||||||
25
apps/remix/Dockerfile.bun
Normal file
25
apps/remix/Dockerfile.bun
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
FROM oven/bun:1 AS dependencies-env
|
||||||
|
COPY . /app
|
||||||
|
|
||||||
|
FROM dependencies-env AS development-dependencies-env
|
||||||
|
COPY ./package.json bun.lockb /app/
|
||||||
|
WORKDIR /app
|
||||||
|
RUN bun i --frozen-lockfile
|
||||||
|
|
||||||
|
FROM dependencies-env AS production-dependencies-env
|
||||||
|
COPY ./package.json bun.lockb /app/
|
||||||
|
WORKDIR /app
|
||||||
|
RUN bun i --production
|
||||||
|
|
||||||
|
FROM dependencies-env AS build-env
|
||||||
|
COPY ./package.json bun.lockb /app/
|
||||||
|
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
|
||||||
|
WORKDIR /app
|
||||||
|
RUN bun run build
|
||||||
|
|
||||||
|
FROM dependencies-env
|
||||||
|
COPY ./package.json bun.lockb /app/
|
||||||
|
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
|
||||||
|
COPY --from=build-env /app/build /app/build
|
||||||
|
WORKDIR /app
|
||||||
|
CMD ["bun", "run", "start"]
|
||||||
26
apps/remix/Dockerfile.pnpm
Normal file
26
apps/remix/Dockerfile.pnpm
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
FROM node:20-alpine AS dependencies-env
|
||||||
|
RUN npm i -g pnpm
|
||||||
|
COPY . /app
|
||||||
|
|
||||||
|
FROM dependencies-env AS development-dependencies-env
|
||||||
|
COPY ./package.json pnpm-lock.yaml /app/
|
||||||
|
WORKDIR /app
|
||||||
|
RUN pnpm i --frozen-lockfile
|
||||||
|
|
||||||
|
FROM dependencies-env AS production-dependencies-env
|
||||||
|
COPY ./package.json pnpm-lock.yaml /app/
|
||||||
|
WORKDIR /app
|
||||||
|
RUN pnpm i --prod --frozen-lockfile
|
||||||
|
|
||||||
|
FROM dependencies-env AS build-env
|
||||||
|
COPY ./package.json pnpm-lock.yaml /app/
|
||||||
|
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
|
||||||
|
WORKDIR /app
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
FROM dependencies-env
|
||||||
|
COPY ./package.json pnpm-lock.yaml /app/
|
||||||
|
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
|
||||||
|
COPY --from=build-env /app/build /app/build
|
||||||
|
WORKDIR /app
|
||||||
|
CMD ["pnpm", "start"]
|
||||||
100
apps/remix/README.md
Normal file
100
apps/remix/README.md
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
# Welcome to React Router!
|
||||||
|
|
||||||
|
A modern, production-ready template for building full-stack React applications using React Router.
|
||||||
|
|
||||||
|
[](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default)
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 🚀 Server-side rendering
|
||||||
|
- ⚡️ Hot Module Replacement (HMR)
|
||||||
|
- 📦 Asset bundling and optimization
|
||||||
|
- 🔄 Data loading and mutations
|
||||||
|
- 🔒 TypeScript by default
|
||||||
|
- 🎉 TailwindCSS for styling
|
||||||
|
- 📖 [React Router docs](https://reactrouter.com/)
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
Install the dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
Start the development server with HMR:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Your application will be available at `http://localhost:5173`.
|
||||||
|
|
||||||
|
## Building for Production
|
||||||
|
|
||||||
|
Create a production build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Docker Deployment
|
||||||
|
|
||||||
|
This template includes three Dockerfiles optimized for different package managers:
|
||||||
|
|
||||||
|
- `Dockerfile` - for npm
|
||||||
|
- `Dockerfile.pnpm` - for pnpm
|
||||||
|
- `Dockerfile.bun` - for bun
|
||||||
|
|
||||||
|
To build and run using Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# For npm
|
||||||
|
docker build -t my-app .
|
||||||
|
|
||||||
|
# For pnpm
|
||||||
|
docker build -f Dockerfile.pnpm -t my-app .
|
||||||
|
|
||||||
|
# For bun
|
||||||
|
docker build -f Dockerfile.bun -t my-app .
|
||||||
|
|
||||||
|
# Run the container
|
||||||
|
docker run -p 3000:3000 my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
The containerized application can be deployed to any platform that supports Docker, including:
|
||||||
|
|
||||||
|
- AWS ECS
|
||||||
|
- Google Cloud Run
|
||||||
|
- Azure Container Apps
|
||||||
|
- Digital Ocean App Platform
|
||||||
|
- Fly.io
|
||||||
|
- Railway
|
||||||
|
|
||||||
|
### DIY Deployment
|
||||||
|
|
||||||
|
If you're familiar with deploying Node applications, the built-in app server is production-ready.
|
||||||
|
|
||||||
|
Make sure to deploy the output of `npm run build`
|
||||||
|
|
||||||
|
```
|
||||||
|
├── package.json
|
||||||
|
├── package-lock.json (or pnpm-lock.yaml, or bun.lockb)
|
||||||
|
├── build/
|
||||||
|
│ ├── client/ # Static assets
|
||||||
|
│ └── server/ # Server-side code
|
||||||
|
```
|
||||||
|
|
||||||
|
## Styling
|
||||||
|
|
||||||
|
This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Built with ❤️ using React Router.
|
||||||
24
apps/remix/app/app.css
Normal file
24
apps/remix/app/app.css
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
@import '@documenso/ui/styles/theme.css';
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
src: url('/public/fonts/inter-regular.ttf') format('ttf');
|
||||||
|
/* font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap; */
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Caveat';
|
||||||
|
src: url('/public/fonts/caveat.ttf') format('ttf');
|
||||||
|
/* font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap; */
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--font-sans: 'Inter';
|
||||||
|
--font-signature: 'Caveat';
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,12 +1,11 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { Trans, msg } from '@lingui/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { signOut } from 'next-auth/react';
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
|
||||||
import type { User } from '@documenso/prisma/client';
|
import { authClient } from '@documenso/auth/client';
|
||||||
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -23,12 +22,13 @@ import { Input } from '@documenso/ui/primitives/input';
|
|||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
export type DeleteAccountDialogProps = {
|
export type AccountDeleteDialogProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
user: User;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProps) => {
|
export const AccountDeleteDialog = ({ className }: AccountDeleteDialogProps) => {
|
||||||
|
const { user } = useSession();
|
||||||
|
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@ -49,7 +49,7 @@ export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProp
|
|||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
return await signOut({ callbackUrl: '/' });
|
return await authClient.signOut();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`An unknown error occurred`),
|
title: _(msg`An unknown error occurred`),
|
||||||
@ -118,7 +118,7 @@ export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProp
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{!hasTwoFactorAuthentication && (
|
{!hasTwoFactorAuthentication && (
|
||||||
<div className="mt-4">
|
<div>
|
||||||
<Label>
|
<Label>
|
||||||
<Trans>
|
<Trans>
|
||||||
Please type{' '}
|
Please type{' '}
|
||||||
@ -1,13 +1,11 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { msg } from '@lingui/core/macro';
|
||||||
|
|
||||||
import { Trans, msg } from '@lingui/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import type { Document } from '@prisma/client';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
import type { Document } from '@documenso/prisma/client';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -23,15 +21,15 @@ 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';
|
||||||
|
|
||||||
export type SuperDeleteDocumentDialogProps = {
|
export type AdminDocumentDeleteDialogProps = {
|
||||||
document: Document;
|
document: Document;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SuperDeleteDocumentDialog = ({ document }: SuperDeleteDocumentDialogProps) => {
|
export const AdminDocumentDeleteDialog = ({ document }: AdminDocumentDeleteDialogProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const router = useRouter();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [reason, setReason] = useState('');
|
const [reason, setReason] = useState('');
|
||||||
|
|
||||||
@ -52,7 +50,7 @@ export const SuperDeleteDocumentDialog = ({ document }: SuperDeleteDocumentDialo
|
|||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
router.push('/admin/documents');
|
await navigate('/admin/documents');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`An unknown error occurred`),
|
title: _(msg`An unknown error occurred`),
|
||||||
@ -1,15 +1,13 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { msg } from '@lingui/core/macro';
|
||||||
|
|
||||||
import { Trans, msg } from '@lingui/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import type { User } from '@prisma/client';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import type { User } from '@documenso/prisma/client';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -25,17 +23,15 @@ 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';
|
||||||
|
|
||||||
export type DeleteUserDialogProps = {
|
export type AdminUserDeleteDialogProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
user: User;
|
user: User;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DeleteUserDialog = ({ className, user }: DeleteUserDialogProps) => {
|
export const AdminUserDeleteDialog = ({ className, user }: AdminUserDeleteDialogProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const navigate = useNavigate();
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
|
|
||||||
const { mutateAsync: deleteUser, isPending: isDeletingUser } =
|
const { mutateAsync: deleteUser, isPending: isDeletingUser } =
|
||||||
@ -47,13 +43,13 @@ export const DeleteUserDialog = ({ className, user }: DeleteUserDialogProps) =>
|
|||||||
id: user.id,
|
id: user.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await navigate('/admin/users');
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Account deleted`),
|
title: _(msg`Account deleted`),
|
||||||
description: _(msg`The account has been deleted successfully.`),
|
description: _(msg`The account has been deleted successfully.`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
router.push('/admin/users');
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = AppError.parseError(err);
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
@ -1,13 +1,12 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { Trans, msg } from '@lingui/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import type { User } from '@prisma/client';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import type { User } from '@documenso/prisma/client';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -23,12 +22,15 @@ 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';
|
||||||
|
|
||||||
export type DisableUserDialogProps = {
|
export type AdminUserDisableDialogProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
userToDisable: User;
|
userToDisable: User;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DisableUserDialog = ({ className, userToDisable }: DisableUserDialogProps) => {
|
export const AdminUserDisableDialog = ({
|
||||||
|
className,
|
||||||
|
userToDisable,
|
||||||
|
}: AdminUserDisableDialogProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@ -1,13 +1,12 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { Trans, msg } from '@lingui/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import type { User } from '@prisma/client';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import type { User } from '@documenso/prisma/client';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -23,12 +22,12 @@ 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';
|
||||||
|
|
||||||
export type EnableUserDialogProps = {
|
export type AdminUserEnableDialogProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
userToEnable: User;
|
userToEnable: User;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EnableUserDialog = ({ className, userToEnable }: EnableUserDialogProps) => {
|
export const AdminUserEnableDialog = ({ className, userToEnable }: AdminUserEnableDialogProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
|
||||||
@ -0,0 +1,206 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
|
||||||
|
import { DocumentSigningDisclosure } from '../general/document-signing/document-signing-disclosure';
|
||||||
|
|
||||||
|
export type NextSigner = {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ConfirmationDialogProps = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: (nextSigner?: NextSigner) => void;
|
||||||
|
hasUninsertedFields: 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({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
hasUninsertedFields,
|
||||||
|
isSubmitting,
|
||||||
|
allowDictateNextSigner = false,
|
||||||
|
defaultNextSigner,
|
||||||
|
}: ConfirmationDialogProps) {
|
||||||
|
const [isEditingNextSigner, setIsEditingNextSigner] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm<TNextSignerFormSchema>({
|
||||||
|
resolver: zodResolver(ZNextSignerFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: defaultNextSigner?.name ?? '',
|
||||||
|
email: defaultNextSigner?.email ?? '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onOpenChange = () => {
|
||||||
|
if (isSubmitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.reset({
|
||||||
|
name: defaultNextSigner?.name ?? '',
|
||||||
|
email: defaultNextSigner?.email ?? '',
|
||||||
|
});
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
// Validate the form and submit it if dictate signer is enabled.
|
||||||
|
if (allowDictateNextSigner) {
|
||||||
|
void form.handleSubmit(onConfirm)();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onConfirm();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<Form {...form}>
|
||||||
|
<form>
|
||||||
|
<fieldset disabled={isSubmitting} className="border-none p-0">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Complete Document</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>
|
||||||
|
Are you sure you want to complete the document? This action cannot be undone.
|
||||||
|
Please ensure that you have completed prefilling all relevant fields before
|
||||||
|
proceeding.
|
||||||
|
</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-col gap-4">
|
||||||
|
{allowDictateNextSigner && (
|
||||||
|
<div className="mt-4 flex flex-col gap-4">
|
||||||
|
{!isEditingNextSigner && (
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
The next recipient to sign this document will be{' '}
|
||||||
|
<span className="font-semibold">{form.watch('name')}</span> (
|
||||||
|
<span className="font-semibold">{form.watch('email')}</span>).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="mt-2"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsEditingNextSigner((prev) => !prev)}
|
||||||
|
>
|
||||||
|
<Trans>Update Recipient</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isEditingNextSigner && (
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row">
|
||||||
|
<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>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,13 +1,12 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { msg } from '@lingui/core/macro';
|
||||||
|
|
||||||
import { Trans, msg } from '@lingui/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { match } from 'ts-pattern';
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { DocumentStatus } from '@prisma/client';
|
||||||
|
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 { DocumentStatus } from '@documenso/prisma/client';
|
|
||||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -22,26 +21,26 @@ 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';
|
||||||
|
|
||||||
type DeleteDocumentDialogProps = {
|
type DocumentDeleteDialogProps = {
|
||||||
id: number;
|
id: number;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (_open: boolean) => void;
|
onOpenChange: (_open: boolean) => void;
|
||||||
|
onDelete?: () => Promise<void> | void;
|
||||||
status: DocumentStatus;
|
status: DocumentStatus;
|
||||||
documentTitle: string;
|
documentTitle: string;
|
||||||
teamId?: number;
|
teamId?: number;
|
||||||
canManageDocument: boolean;
|
canManageDocument: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DeleteDocumentDialog = ({
|
export const DocumentDeleteDialog = ({
|
||||||
id,
|
id,
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
onDelete,
|
||||||
status,
|
status,
|
||||||
documentTitle,
|
documentTitle,
|
||||||
canManageDocument,
|
canManageDocument,
|
||||||
}: DeleteDocumentDialogProps) => {
|
}: DocumentDeleteDialogProps) => {
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { refreshLimits } = useLimits();
|
const { refreshLimits } = useLimits();
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
@ -52,8 +51,7 @@ export const DeleteDocumentDialog = ({
|
|||||||
const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT);
|
const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT);
|
||||||
|
|
||||||
const { mutateAsync: deleteDocument, isPending } = trpcReact.document.deleteDocument.useMutation({
|
const { mutateAsync: deleteDocument, isPending } = trpcReact.document.deleteDocument.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: async () => {
|
||||||
router.refresh();
|
|
||||||
void refreshLimits();
|
void refreshLimits();
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@ -62,8 +60,18 @@ export const DeleteDocumentDialog = ({
|
|||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await onDelete?.();
|
||||||
|
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
},
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({
|
||||||
|
title: _(msg`Something went wrong`),
|
||||||
|
description: _(msg`This document could not be deleted at this time. Please try again.`),
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 7500,
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -73,19 +81,6 @@ export const DeleteDocumentDialog = ({
|
|||||||
}
|
}
|
||||||
}, [open, status]);
|
}, [open, status]);
|
||||||
|
|
||||||
const onDelete = async () => {
|
|
||||||
try {
|
|
||||||
await deleteDocument({ documentId: id });
|
|
||||||
} catch {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Something went wrong`),
|
|
||||||
description: _(msg`This document could not be deleted at this time. Please try again.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
duration: 7500,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setInputValue(event.target.value);
|
setInputValue(event.target.value);
|
||||||
setIsDeleteEnabled(event.target.value === _(deleteMessage));
|
setIsDeleteEnabled(event.target.value === _(deleteMessage));
|
||||||
@ -151,7 +146,7 @@ export const DeleteDocumentDialog = ({
|
|||||||
</ul>
|
</ul>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
))
|
))
|
||||||
.with(DocumentStatus.COMPLETED, () => (
|
.with(P.union(DocumentStatus.COMPLETED, DocumentStatus.REJECTED), () => (
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<p>
|
<p>
|
||||||
<Trans>By deleting this document, the following will occur:</Trans>
|
<Trans>By deleting this document, the following will occur:</Trans>
|
||||||
@ -194,7 +189,7 @@ export const DeleteDocumentDialog = ({
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
loading={isPending}
|
loading={isPending}
|
||||||
onClick={onDelete}
|
onClick={() => void deleteDocument({ documentId: id })}
|
||||||
disabled={!isDeleteEnabled && canManageDocument}
|
disabled={!isDeleteEnabled && canManageDocument}
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
>
|
>
|
||||||
@ -1,10 +1,9 @@
|
|||||||
import { useRouter } from 'next/navigation';
|
import { msg } from '@lingui/core/macro';
|
||||||
|
|
||||||
import { Trans, msg } from '@lingui/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import type { Team } from '@documenso/prisma/client';
|
|
||||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@ -14,30 +13,37 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-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';
|
||||||
|
|
||||||
type DuplicateDocumentDialogProps = {
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
|
type DocumentDuplicateDialogProps = {
|
||||||
id: number;
|
id: number;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (_open: boolean) => void;
|
onOpenChange: (_open: boolean) => void;
|
||||||
team?: Pick<Team, 'id' | 'url'>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DuplicateDocumentDialog = ({
|
export const DocumentDuplicateDialog = ({
|
||||||
id,
|
id,
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
team,
|
}: DocumentDuplicateDialogProps) => {
|
||||||
}: DuplicateDocumentDialogProps) => {
|
const navigate = useNavigate();
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery({
|
const team = useOptionalCurrentTeam();
|
||||||
documentId: id,
|
|
||||||
});
|
const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery(
|
||||||
|
{
|
||||||
|
documentId: id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: open === true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const documentData = document?.documentData
|
const documentData = document?.documentData
|
||||||
? {
|
? {
|
||||||
@ -50,15 +56,14 @@ export const DuplicateDocumentDialog = ({
|
|||||||
|
|
||||||
const { mutateAsync: duplicateDocument, isPending: isDuplicateLoading } =
|
const { mutateAsync: duplicateDocument, isPending: isDuplicateLoading } =
|
||||||
trpcReact.document.duplicateDocument.useMutation({
|
trpcReact.document.duplicateDocument.useMutation({
|
||||||
onSuccess: ({ documentId }) => {
|
onSuccess: async ({ documentId }) => {
|
||||||
router.push(`${documentsPath}/${documentId}/edit`);
|
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Document Duplicated`),
|
title: _(msg`Document Duplicated`),
|
||||||
description: _(msg`Your document has been successfully duplicated.`),
|
description: _(msg`Your document has been successfully duplicated.`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await navigate(`${documentsPath}/${documentId}/edit`);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -92,7 +97,7 @@ export const DuplicateDocumentDialog = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll">
|
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll">
|
||||||
<LazyPDFViewer key={document?.id} documentData={documentData} />
|
<PDFViewer key={document?.id} documentData={documentData} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -1,11 +1,10 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { msg } from '@lingui/core/macro';
|
||||||
|
|
||||||
import { Trans, msg } from '@lingui/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -26,30 +25,28 @@ import {
|
|||||||
} from '@documenso/ui/primitives/select';
|
} from '@documenso/ui/primitives/select';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
type MoveDocumentDialogProps = {
|
type DocumentMoveDialogProps = {
|
||||||
documentId: number;
|
documentId: number;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (_open: boolean) => void;
|
onOpenChange: (_open: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MoveDocumentDialog = ({ documentId, open, onOpenChange }: MoveDocumentDialogProps) => {
|
export const DocumentMoveDialog = ({ documentId, open, onOpenChange }: DocumentMoveDialogProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const [selectedTeamId, setSelectedTeamId] = useState<number | null>(null);
|
const [selectedTeamId, setSelectedTeamId] = useState<number | null>(null);
|
||||||
|
|
||||||
const { data: teams, isPending: isLoadingTeams } = trpc.team.getTeams.useQuery();
|
const { data: teams, isPending: isLoadingTeams } = trpc.team.getTeams.useQuery();
|
||||||
|
|
||||||
const { mutateAsync: moveDocument, isPending } = trpc.document.moveDocumentToTeam.useMutation({
|
const { mutateAsync: moveDocument, isPending } = trpc.document.moveDocumentToTeam.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
router.refresh();
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Document moved`),
|
title: _(msg`Document moved`),
|
||||||
description: _(msg`The document has been successfully moved to the selected team.`),
|
description: _(msg`The document has been successfully moved to the selected team.`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@ -97,9 +94,7 @@ export const MoveDocumentDialog = ({ documentId, open, onOpenChange }: MoveDocum
|
|||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Avatar className="h-8 w-8">
|
<Avatar className="h-8 w-8">
|
||||||
{team.avatarImageId && (
|
{team.avatarImageId && (
|
||||||
<AvatarImage
|
<AvatarImage src={formatAvatarUrl(team.avatarImageId)} />
|
||||||
src={`${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${team.avatarImageId}`}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<AvatarFallback className="text-sm text-gray-400">
|
<AvatarFallback className="text-sm text-gray-400">
|
||||||
@ -0,0 +1,216 @@
|
|||||||
|
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 { useOptionalCurrentTeam } 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 = useOptionalCurrentTeam();
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Document moved`),
|
||||||
|
description: _(msg`The document has been moved successfully.`),
|
||||||
|
variant: 'default',
|
||||||
|
});
|
||||||
|
|
||||||
|
onOpenChange(false);
|
||||||
|
|
||||||
|
const documentsPath = formatDocumentsPath(team?.url);
|
||||||
|
|
||||||
|
if (data.folderId) {
|
||||||
|
void navigate(`${documentsPath}/f/${data.folderId}`);
|
||||||
|
} else {
|
||||||
|
void navigate(documentsPath);
|
||||||
|
}
|
||||||
|
} 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,19 +1,18 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Trans, msg } from '@lingui/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { type Recipient, SigningStatus } from '@prisma/client';
|
||||||
import { History } from 'lucide-react';
|
import { History } from 'lucide-react';
|
||||||
import { useSession } from 'next-auth/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 { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||||
|
import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/document';
|
||||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import type { Team } from '@documenso/prisma/client';
|
|
||||||
import { type Document, type Recipient, SigningStatus } from '@documenso/prisma/client';
|
|
||||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -37,16 +36,15 @@ 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 { StackAvatar } from '~/components/(dashboard)/avatar/stack-avatar';
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
|
import { StackAvatar } from '../general/stack-avatar';
|
||||||
|
|
||||||
const FORM_ID = 'resend-email';
|
const FORM_ID = 'resend-email';
|
||||||
|
|
||||||
export type ResendDocumentActionItemProps = {
|
export type DocumentResendDialogProps = {
|
||||||
document: Document & {
|
document: TDocumentRow;
|
||||||
team: Pick<Team, 'id' | 'url'> | null;
|
|
||||||
};
|
|
||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
team?: Pick<Team, 'id' | 'url'>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ZResendDocumentFormSchema = z.object({
|
export const ZResendDocumentFormSchema = z.object({
|
||||||
@ -57,17 +55,15 @@ export const ZResendDocumentFormSchema = z.object({
|
|||||||
|
|
||||||
export type TResendDocumentFormSchema = z.infer<typeof ZResendDocumentFormSchema>;
|
export type TResendDocumentFormSchema = z.infer<typeof ZResendDocumentFormSchema>;
|
||||||
|
|
||||||
export const ResendDocumentActionItem = ({
|
export const DocumentResendDialog = ({ document, recipients }: DocumentResendDialogProps) => {
|
||||||
document,
|
const { user } = useSession();
|
||||||
recipients,
|
const team = useOptionalCurrentTeam();
|
||||||
team,
|
|
||||||
}: ResendDocumentActionItemProps) => {
|
|
||||||
const { data: session } = useSession();
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const isOwner = document.userId === session?.user?.id;
|
const isOwner = document.userId === user.id;
|
||||||
const isCurrentTeamDocument = team && document.team?.url === team.url;
|
const isCurrentTeamDocument = team && document.team?.url === team.url;
|
||||||
|
|
||||||
const isDisabled =
|
const isDisabled =
|
||||||
163
apps/remix/app/components/dialogs/folder-create-dialog.tsx
Normal file
163
apps/remix/app/components/dialogs/folder-create-dialog.tsx
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
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 { useOptionalCurrentTeam } 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 = useOptionalCurrentTeam();
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
description: 'Folder created successfully',
|
||||||
|
});
|
||||||
|
|
||||||
|
const documentsPath = formatDocumentsPath(team?.url);
|
||||||
|
|
||||||
|
void navigate(`${documentsPath}/f/${newFolder.id}`);
|
||||||
|
} 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 organize 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
159
apps/remix/app/components/dialogs/folder-delete-dialog.tsx
Normal file
159
apps/remix/app/components/dialogs/folder-delete-dialog.tsx
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
169
apps/remix/app/components/dialogs/folder-move-dialog.tsx
Normal file
169
apps/remix/app/components/dialogs/folder-move-dialog.tsx
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
173
apps/remix/app/components/dialogs/folder-settings-dialog.tsx
Normal file
173
apps/remix/app/components/dialogs/folder-settings-dialog.tsx
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
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,10 +1,9 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Trans, msg } from '@lingui/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
import { startRegistration } from '@simplewebauthn/browser';
|
import { startRegistration } from '@simplewebauthn/browser';
|
||||||
import { KeyRoundIcon } from 'lucide-react';
|
import { KeyRoundIcon } from 'lucide-react';
|
||||||
@ -38,7 +37,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';
|
||||||
|
|
||||||
export type CreatePasskeyDialogProps = {
|
export type PasskeyCreateDialogProps = {
|
||||||
trigger?: React.ReactNode;
|
trigger?: React.ReactNode;
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
@ -51,7 +50,7 @@ type TCreatePasskeyFormSchema = z.infer<typeof ZCreatePasskeyFormSchema>;
|
|||||||
|
|
||||||
const parser = new UAParser();
|
const parser = new UAParser();
|
||||||
|
|
||||||
export const CreatePasskeyDialog = ({ trigger, onSuccess, ...props }: CreatePasskeyDialogProps) => {
|
export const PasskeyCreateDialog = ({ trigger, onSuccess, ...props }: PasskeyCreateDialogProps) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
|
||||||
@ -1,18 +1,17 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Plural, Trans, msg } from '@lingui/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Plural, Trans } from '@lingui/react/macro';
|
||||||
|
import type { Template, TemplateDirectLink } from '@prisma/client';
|
||||||
|
import { TemplateType } from '@prisma/client';
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
import { CheckCircle2Icon, CircleIcon } from 'lucide-react';
|
import { CheckCircle2Icon, CircleIcon } from 'lucide-react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { P, match } from 'ts-pattern';
|
import { P, match } from 'ts-pattern';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import type { Template, TemplateDirectLink } from '@documenso/prisma/client';
|
|
||||||
import { TemplateType } from '@documenso/prisma/client';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import {
|
import {
|
||||||
MAX_TEMPLATE_PUBLIC_DESCRIPTION_LENGTH,
|
MAX_TEMPLATE_PUBLIC_DESCRIPTION_LENGTH,
|
||||||
@ -1,7 +1,8 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { Trans, msg } from '@lingui/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
|
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 { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { Loader, TagIcon } from 'lucide-react';
|
import { Loader, TagIcon } from 'lucide-react';
|
||||||
@ -20,18 +21,18 @@ import {
|
|||||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
export type CreateTeamCheckoutDialogProps = {
|
export type TeamCheckoutCreateDialogProps = {
|
||||||
pendingTeamId: number | null;
|
pendingTeamId: number | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
const MotionCard = motion(Card);
|
const MotionCard = motion(Card);
|
||||||
|
|
||||||
export const CreateTeamCheckoutDialog = ({
|
export const TeamCheckoutCreateDialog = ({
|
||||||
pendingTeamId,
|
pendingTeamId,
|
||||||
onClose,
|
onClose,
|
||||||
...props
|
...props
|
||||||
}: CreateTeamCheckoutDialogProps) => {
|
}: TeamCheckoutCreateDialogProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@ -1,18 +1,17 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Trans, msg } from '@lingui/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
|
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 { 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 { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
import { 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 { ZCreateTeamMutationSchema } from '@documenso/trpc/server/team-router/schema';
|
import { ZCreateTeamMutationSchema } from '@documenso/trpc/server/team-router/schema';
|
||||||
@ -37,7 +36,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';
|
||||||
|
|
||||||
export type CreateTeamDialogProps = {
|
export type TeamCreateDialogProps = {
|
||||||
trigger?: React.ReactNode;
|
trigger?: React.ReactNode;
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
@ -48,12 +47,12 @@ const ZCreateTeamFormSchema = ZCreateTeamMutationSchema.pick({
|
|||||||
|
|
||||||
type TCreateTeamFormSchema = z.infer<typeof ZCreateTeamFormSchema>;
|
type TCreateTeamFormSchema = z.infer<typeof ZCreateTeamFormSchema>;
|
||||||
|
|
||||||
export const CreateTeamDialog = ({ trigger, ...props }: CreateTeamDialogProps) => {
|
export const TeamCreateDialog = ({ trigger, ...props }: TeamCreateDialogProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const router = useRouter();
|
const navigate = useNavigate();
|
||||||
const searchParams = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@ -80,7 +79,7 @@ export const CreateTeamDialog = ({ trigger, ...props }: CreateTeamDialogProps) =
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
|
||||||
if (response.paymentRequired) {
|
if (response.paymentRequired) {
|
||||||
router.push(`/settings/teams?tab=pending&checkout=${response.pendingTeamId}`);
|
await navigate(`/settings/teams?tab=pending&checkout=${response.pendingTeamId}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -120,7 +119,7 @@ export const CreateTeamDialog = ({ trigger, ...props }: CreateTeamDialogProps) =
|
|||||||
setOpen(true);
|
setOpen(true);
|
||||||
updateSearchParams({ action: null });
|
updateSearchParams({ action: null });
|
||||||
}
|
}
|
||||||
}, [actionSearchParam, open, setOpen, updateSearchParams]);
|
}, [actionSearchParam, open]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
form.reset();
|
form.reset();
|
||||||
@ -201,7 +200,7 @@ export const CreateTeamDialog = ({ trigger, ...props }: CreateTeamDialogProps) =
|
|||||||
{!form.formState.errors.teamUrl && (
|
{!form.formState.errors.teamUrl && (
|
||||||
<span className="text-foreground/50 text-xs font-normal">
|
<span className="text-foreground/50 text-xs font-normal">
|
||||||
{field.value ? (
|
{field.value ? (
|
||||||
`${WEBAPP_BASE_URL}/t/${field.value}`
|
`${NEXT_PUBLIC_WEBAPP_URL()}/t/${field.value}`
|
||||||
) : (
|
) : (
|
||||||
<Trans>A unique URL to identify your team</Trans>
|
<Trans>A unique URL to identify your team</Trans>
|
||||||
)}
|
)}
|
||||||
@ -1,13 +1,11 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Trans, msg } from '@lingui/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
@ -34,14 +32,14 @@ import { Input } from '@documenso/ui/primitives/input';
|
|||||||
import type { Toast } from '@documenso/ui/primitives/use-toast';
|
import type { Toast } from '@documenso/ui/primitives/use-toast';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
export type DeleteTeamDialogProps = {
|
export type TeamDeleteDialogProps = {
|
||||||
teamId: number;
|
teamId: number;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
trigger?: React.ReactNode;
|
trigger?: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DeleteTeamDialog = ({ trigger, teamId, teamName }: DeleteTeamDialogProps) => {
|
export const TeamDeleteDialog = ({ trigger, teamId, teamName }: TeamDeleteDialogProps) => {
|
||||||
const router = useRouter();
|
const navigate = useNavigate();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
@ -74,9 +72,9 @@ export const DeleteTeamDialog = ({ trigger, teamId, teamName }: DeleteTeamDialog
|
|||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
setOpen(false);
|
await navigate('/settings/teams');
|
||||||
|
|
||||||
router.push('/settings/teams');
|
setOpen(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = AppError.parseError(err);
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
@ -1,15 +1,13 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Trans, msg } from '@lingui/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
|
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 { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useRevalidator } from 'react-router';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
@ -36,7 +34,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';
|
||||||
|
|
||||||
export type AddTeamEmailDialogProps = {
|
export type TeamEmailAddDialogProps = {
|
||||||
teamId: number;
|
teamId: number;
|
||||||
trigger?: React.ReactNode;
|
trigger?: React.ReactNode;
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
@ -48,13 +46,12 @@ const ZCreateTeamEmailFormSchema = ZCreateTeamEmailVerificationMutationSchema.pi
|
|||||||
|
|
||||||
type TCreateTeamEmailFormSchema = z.infer<typeof ZCreateTeamEmailFormSchema>;
|
type TCreateTeamEmailFormSchema = z.infer<typeof ZCreateTeamEmailFormSchema>;
|
||||||
|
|
||||||
export const AddTeamEmailDialog = ({ teamId, trigger, ...props }: AddTeamEmailDialogProps) => {
|
export const TeamEmailAddDialog = ({ teamId, trigger, ...props }: TeamEmailAddDialogProps) => {
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { revalidate } = useRevalidator();
|
||||||
|
|
||||||
const form = useForm<TCreateTeamEmailFormSchema>({
|
const form = useForm<TCreateTeamEmailFormSchema>({
|
||||||
resolver: zodResolver(ZCreateTeamEmailFormSchema),
|
resolver: zodResolver(ZCreateTeamEmailFormSchema),
|
||||||
@ -81,7 +78,7 @@ export const AddTeamEmailDialog = ({ teamId, trigger, ...props }: AddTeamEmailDi
|
|||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
router.refresh();
|
await revalidate();
|
||||||
|
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -1,15 +1,13 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { msg } from '@lingui/core/macro';
|
||||||
|
|
||||||
import { Trans, msg } from '@lingui/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import type { Prisma } from '@prisma/client';
|
||||||
|
import { useRevalidator } from 'react-router';
|
||||||
|
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import type { Prisma } from '@documenso/prisma/client';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Alert } 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';
|
||||||
@ -25,7 +23,7 @@ import {
|
|||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
export type RemoveTeamEmailDialogProps = {
|
export type TeamEmailDeleteDialogProps = {
|
||||||
trigger?: React.ReactNode;
|
trigger?: React.ReactNode;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
team: Prisma.TeamGetPayload<{
|
team: Prisma.TeamGetPayload<{
|
||||||
@ -42,13 +40,12 @@ export type RemoveTeamEmailDialogProps = {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const RemoveTeamEmailDialog = ({ trigger, teamName, team }: RemoveTeamEmailDialogProps) => {
|
export const TeamEmailDeleteDialog = ({ trigger, teamName, team }: TeamEmailDeleteDialogProps) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { revalidate } = useRevalidator();
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { mutateAsync: deleteTeamEmail, isPending: isDeletingTeamEmail } =
|
const { mutateAsync: deleteTeamEmail, isPending: isDeletingTeamEmail } =
|
||||||
trpc.team.deleteTeamEmail.useMutation({
|
trpc.team.deleteTeamEmail.useMutation({
|
||||||
@ -97,7 +94,7 @@ export const RemoveTeamEmailDialog = ({ trigger, teamName, team }: RemoveTeamEma
|
|||||||
await deleteTeamEmailVerification({ teamId: team.id });
|
await deleteTeamEmailVerification({ teamId: team.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
router.refresh();
|
await revalidate();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -127,7 +124,7 @@ export const RemoveTeamEmailDialog = ({ trigger, teamName, team }: RemoveTeamEma
|
|||||||
<Alert variant="neutral" padding="tight">
|
<Alert variant="neutral" padding="tight">
|
||||||
<AvatarWithText
|
<AvatarWithText
|
||||||
avatarClass="h-12 w-12"
|
avatarClass="h-12 w-12"
|
||||||
avatarSrc={`${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${team.avatarImageId}`}
|
avatarSrc={formatAvatarUrl(team.avatarImageId)}
|
||||||
avatarFallback={extractInitials(
|
avatarFallback={extractInitials(
|
||||||
(team.teamEmail?.name || team.emailVerification?.name) ?? '',
|
(team.teamEmail?.name || team.emailVerification?.name) ?? '',
|
||||||
)}
|
)}
|
||||||
@ -1,17 +1,15 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Trans, msg } from '@lingui/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import type { TeamEmail } from '@prisma/client';
|
||||||
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 { useRevalidator } from 'react-router';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import type { TeamEmail } from '@documenso/prisma/client';
|
|
||||||
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';
|
||||||
import {
|
import {
|
||||||
@ -34,7 +32,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';
|
||||||
|
|
||||||
export type UpdateTeamEmailDialogProps = {
|
export type TeamEmailUpdateDialogProps = {
|
||||||
teamEmail: TeamEmail;
|
teamEmail: TeamEmail;
|
||||||
trigger?: React.ReactNode;
|
trigger?: React.ReactNode;
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
@ -45,17 +43,16 @@ const ZUpdateTeamEmailFormSchema = z.object({
|
|||||||
|
|
||||||
type TUpdateTeamEmailFormSchema = z.infer<typeof ZUpdateTeamEmailFormSchema>;
|
type TUpdateTeamEmailFormSchema = z.infer<typeof ZUpdateTeamEmailFormSchema>;
|
||||||
|
|
||||||
export const UpdateTeamEmailDialog = ({
|
export const TeamEmailUpdateDialog = ({
|
||||||
teamEmail,
|
teamEmail,
|
||||||
trigger,
|
trigger,
|
||||||
...props
|
...props
|
||||||
}: UpdateTeamEmailDialogProps) => {
|
}: TeamEmailUpdateDialogProps) => {
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { revalidate } = useRevalidator();
|
||||||
|
|
||||||
const form = useForm<TUpdateTeamEmailFormSchema>({
|
const form = useForm<TUpdateTeamEmailFormSchema>({
|
||||||
resolver: zodResolver(ZUpdateTeamEmailFormSchema),
|
resolver: zodResolver(ZUpdateTeamEmailFormSchema),
|
||||||
@ -81,7 +78,7 @@ export const UpdateTeamEmailDialog = ({
|
|||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
router.refresh();
|
await revalidate();
|
||||||
|
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -1,13 +1,12 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { Trans, msg } from '@lingui/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import type { TeamMemberRole } from '@prisma/client';
|
||||||
|
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
|
||||||
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
||||||
import type { TeamMemberRole } from '@documenso/prisma/client';
|
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Alert } 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';
|
||||||
@ -23,7 +22,7 @@ import {
|
|||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
export type LeaveTeamDialogProps = {
|
export type TeamLeaveDialogProps = {
|
||||||
teamId: number;
|
teamId: number;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
teamAvatarImageId?: string | null;
|
teamAvatarImageId?: string | null;
|
||||||
@ -31,13 +30,13 @@ export type LeaveTeamDialogProps = {
|
|||||||
trigger?: React.ReactNode;
|
trigger?: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LeaveTeamDialog = ({
|
export const TeamLeaveDialog = ({
|
||||||
trigger,
|
trigger,
|
||||||
teamId,
|
teamId,
|
||||||
teamName,
|
teamName,
|
||||||
teamAvatarImageId,
|
teamAvatarImageId,
|
||||||
role,
|
role,
|
||||||
}: LeaveTeamDialogProps) => {
|
}: TeamLeaveDialogProps) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
@ -89,7 +88,7 @@ export const LeaveTeamDialog = ({
|
|||||||
<Alert variant="neutral" padding="tight">
|
<Alert variant="neutral" padding="tight">
|
||||||
<AvatarWithText
|
<AvatarWithText
|
||||||
avatarClass="h-12 w-12"
|
avatarClass="h-12 w-12"
|
||||||
avatarSrc={`${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${teamAvatarImageId}`}
|
avatarSrc={formatAvatarUrl(teamAvatarImageId)}
|
||||||
avatarFallback={teamName.slice(0, 1).toUpperCase()}
|
avatarFallback={teamName.slice(0, 1).toUpperCase()}
|
||||||
primaryText={teamName}
|
primaryText={teamName}
|
||||||
secondaryText={_(TEAM_MEMBER_ROLE_MAP[role])}
|
secondaryText={_(TEAM_MEMBER_ROLE_MAP[role])}
|
||||||
@ -1,9 +1,8 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { Trans, msg } from '@lingui/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Alert } from '@documenso/ui/primitives/alert';
|
import { Alert } from '@documenso/ui/primitives/alert';
|
||||||
@ -20,7 +19,7 @@ import {
|
|||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
export type DeleteTeamMemberDialogProps = {
|
export type TeamMemberDeleteDialogProps = {
|
||||||
teamId: number;
|
teamId: number;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
teamMemberId: number;
|
teamMemberId: number;
|
||||||
@ -29,14 +28,14 @@ export type DeleteTeamMemberDialogProps = {
|
|||||||
trigger?: React.ReactNode;
|
trigger?: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DeleteTeamMemberDialog = ({
|
export const TeamMemberDeleteDialog = ({
|
||||||
trigger,
|
trigger,
|
||||||
teamId,
|
teamId,
|
||||||
teamName,
|
teamName,
|
||||||
teamMemberId,
|
teamMemberId,
|
||||||
teamMemberName,
|
teamMemberName,
|
||||||
teamMemberEmail,
|
teamMemberEmail,
|
||||||
}: DeleteTeamMemberDialogProps) => {
|
}: TeamMemberDeleteDialogProps) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
@ -1,10 +1,10 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Trans, msg } from '@lingui/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
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 type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
import { Download, Mail, MailIcon, PlusCircle, Trash, Upload, UsersIcon } from 'lucide-react';
|
import { Download, Mail, MailIcon, PlusCircle, Trash, Upload, UsersIcon } from 'lucide-react';
|
||||||
import Papa, { type ParseResult } from 'papaparse';
|
import Papa, { type ParseResult } from 'papaparse';
|
||||||
@ -13,7 +13,6 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
import { downloadFile } from '@documenso/lib/client-only/download-file';
|
import { downloadFile } from '@documenso/lib/client-only/download-file';
|
||||||
import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
||||||
import { TeamMemberRole } from '@documenso/prisma/client';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { ZCreateTeamMemberInvitesMutationSchema } from '@documenso/trpc/server/team-router/schema';
|
import { ZCreateTeamMemberInvitesMutationSchema } from '@documenso/trpc/server/team-router/schema';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -47,9 +46,9 @@ import {
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
export type InviteTeamMembersDialogProps = {
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
currentUserTeamRole: TeamMemberRole;
|
|
||||||
teamId: number;
|
export type TeamMemberInviteDialogProps = {
|
||||||
trigger?: React.ReactNode;
|
trigger?: React.ReactNode;
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
@ -96,12 +95,7 @@ const ZImportTeamMemberSchema = z.array(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const InviteTeamMembersDialog = ({
|
export const TeamMemberInviteDialog = ({ trigger, ...props }: TeamMemberInviteDialogProps) => {
|
||||||
currentUserTeamRole,
|
|
||||||
teamId,
|
|
||||||
trigger,
|
|
||||||
...props
|
|
||||||
}: InviteTeamMembersDialogProps) => {
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [invitationType, setInvitationType] = useState<TabTypes>('INDIVIDUAL');
|
const [invitationType, setInvitationType] = useState<TabTypes>('INDIVIDUAL');
|
||||||
@ -109,6 +103,8 @@ export const InviteTeamMembersDialog = ({
|
|||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
const form = useForm<TInviteTeamMembersFormSchema>({
|
const form = useForm<TInviteTeamMembersFormSchema>({
|
||||||
resolver: zodResolver(ZInviteTeamMembersFormSchema),
|
resolver: zodResolver(ZInviteTeamMembersFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@ -142,7 +138,7 @@ export const InviteTeamMembersDialog = ({
|
|||||||
const onFormSubmit = async ({ invitations }: TInviteTeamMembersFormSchema) => {
|
const onFormSubmit = async ({ invitations }: TInviteTeamMembersFormSchema) => {
|
||||||
try {
|
try {
|
||||||
await createTeamMemberInvites({
|
await createTeamMemberInvites({
|
||||||
teamId,
|
teamId: team.id,
|
||||||
invitations,
|
invitations,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -204,7 +200,7 @@ export const InviteTeamMembersDialog = ({
|
|||||||
|
|
||||||
setInvitationType('INDIVIDUAL');
|
setInvitationType('INDIVIDUAL');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err.message);
|
console.error(err);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Something went wrong`),
|
title: _(msg`Something went wrong`),
|
||||||
@ -325,11 +321,13 @@ export const InviteTeamMembersDialog = ({
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|
||||||
<SelectContent position="popper">
|
<SelectContent position="popper">
|
||||||
{TEAM_MEMBER_ROLE_HIERARCHY[currentUserTeamRole].map((role) => (
|
{TEAM_MEMBER_ROLE_HIERARCHY[team.currentTeamMember.role].map(
|
||||||
<SelectItem key={role} value={role}>
|
(role) => (
|
||||||
{_(TEAM_MEMBER_ROLE_MAP[role]) ?? role}
|
<SelectItem key={role} value={role}>
|
||||||
</SelectItem>
|
{_(TEAM_MEMBER_ROLE_MAP[role]) ?? role}
|
||||||
))}
|
</SelectItem>
|
||||||
|
),
|
||||||
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@ -1,17 +1,16 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Trans, msg } from '@lingui/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
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 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, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
||||||
import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams';
|
import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams';
|
||||||
import { TeamMemberRole } from '@documenso/prisma/client';
|
|
||||||
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';
|
||||||
import {
|
import {
|
||||||
@ -40,7 +39,7 @@ import {
|
|||||||
} from '@documenso/ui/primitives/select';
|
} from '@documenso/ui/primitives/select';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
export type UpdateTeamMemberDialogProps = {
|
export type TeamMemberUpdateDialogProps = {
|
||||||
currentUserTeamRole: TeamMemberRole;
|
currentUserTeamRole: TeamMemberRole;
|
||||||
trigger?: React.ReactNode;
|
trigger?: React.ReactNode;
|
||||||
teamId: number;
|
teamId: number;
|
||||||
@ -55,7 +54,7 @@ const ZUpdateTeamMemberFormSchema = z.object({
|
|||||||
|
|
||||||
type ZUpdateTeamMemberSchema = z.infer<typeof ZUpdateTeamMemberFormSchema>;
|
type ZUpdateTeamMemberSchema = z.infer<typeof ZUpdateTeamMemberFormSchema>;
|
||||||
|
|
||||||
export const UpdateTeamMemberDialog = ({
|
export const TeamMemberUpdateDialog = ({
|
||||||
currentUserTeamRole,
|
currentUserTeamRole,
|
||||||
trigger,
|
trigger,
|
||||||
teamId,
|
teamId,
|
||||||
@ -63,7 +62,7 @@ export const UpdateTeamMemberDialog = ({
|
|||||||
teamMemberName,
|
teamMemberName,
|
||||||
teamMemberRole,
|
teamMemberRole,
|
||||||
...props
|
...props
|
||||||
}: UpdateTeamMemberDialogProps) => {
|
}: TeamMemberUpdateDialogProps) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
@ -1,14 +1,12 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Trans, msg } from '@lingui/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
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 { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
@ -42,24 +40,24 @@ import {
|
|||||||
} from '@documenso/ui/primitives/select';
|
} from '@documenso/ui/primitives/select';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
export type TransferTeamDialogProps = {
|
export type TeamTransferDialogProps = {
|
||||||
teamId: number;
|
teamId: number;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
ownerUserId: number;
|
ownerUserId: number;
|
||||||
trigger?: React.ReactNode;
|
trigger?: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TransferTeamDialog = ({
|
export const TeamTransferDialog = ({
|
||||||
trigger,
|
trigger,
|
||||||
teamId,
|
teamId,
|
||||||
teamName,
|
teamName,
|
||||||
ownerUserId,
|
ownerUserId,
|
||||||
}: TransferTeamDialogProps) => {
|
}: TeamTransferDialogProps) => {
|
||||||
const router = useRouter();
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { revalidate } = useRevalidator();
|
||||||
|
|
||||||
const { mutateAsync: requestTeamOwnershipTransfer } =
|
const { mutateAsync: requestTeamOwnershipTransfer } =
|
||||||
trpc.team.requestTeamOwnershipTransfer.useMutation();
|
trpc.team.requestTeamOwnershipTransfer.useMutation();
|
||||||
@ -102,7 +100,7 @@ export const TransferTeamDialog = ({
|
|||||||
clearPaymentMethods,
|
clearPaymentMethods,
|
||||||
});
|
});
|
||||||
|
|
||||||
router.refresh();
|
await revalidate();
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Success`),
|
title: _(msg`Success`),
|
||||||
@ -1,8 +1,7 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Trans, msg } from '@lingui/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { File as FileIcon, Upload, X } from 'lucide-react';
|
import { File as FileIcon, Upload, X } from 'lucide-react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@ -115,7 +114,7 @@ export const TemplateBulkSendDialog = ({
|
|||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button>
|
<Button variant="outline">
|
||||||
<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>
|
||||||
@ -1,15 +1,12 @@
|
|||||||
'use client';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import { msg } from '@lingui/core/macro';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
import { Trans, msg } from '@lingui/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { FilePlus, Loader } from 'lucide-react';
|
import { FilePlus, Loader } from 'lucide-react';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -26,21 +23,21 @@ import {
|
|||||||
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
type NewTemplateDialogProps = {
|
type TemplateCreateDialogProps = {
|
||||||
teamId?: number;
|
|
||||||
templateRootPath: string;
|
templateRootPath: string;
|
||||||
|
folderId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const NewTemplateDialog = ({ templateRootPath }: NewTemplateDialogProps) => {
|
export const TemplateCreateDialog = ({ templateRootPath, folderId }: TemplateCreateDialogProps) => {
|
||||||
const router = useRouter();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const { data: session } = useSession();
|
const { user } = useSession();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation();
|
const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation();
|
||||||
|
|
||||||
const [showNewTemplateDialog, setShowNewTemplateDialog] = useState(false);
|
const [showTemplateCreateDialog, setShowTemplateCreateDialog] = useState(false);
|
||||||
const [isUploadingFile, setIsUploadingFile] = useState(false);
|
const [isUploadingFile, setIsUploadingFile] = useState(false);
|
||||||
|
|
||||||
const onFileDrop = async (file: File) => {
|
const onFileDrop = async (file: File) => {
|
||||||
@ -51,15 +48,12 @@ export const NewTemplateDialog = ({ templateRootPath }: NewTemplateDialogProps)
|
|||||||
setIsUploadingFile(true);
|
setIsUploadingFile(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { type, data } = await putPdfFile(file);
|
const response = await putPdfFile(file);
|
||||||
const { id: templateDocumentDataId } = await createDocumentData({
|
|
||||||
type,
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { id } = await createTemplate({
|
const { id } = await createTemplate({
|
||||||
title: file.name,
|
title: file.name,
|
||||||
templateDocumentDataId,
|
templateDocumentDataId: response.id,
|
||||||
|
folderId: folderId,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@ -70,9 +64,9 @@ export const NewTemplateDialog = ({ templateRootPath }: NewTemplateDialogProps)
|
|||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
setShowNewTemplateDialog(false);
|
setShowTemplateCreateDialog(false);
|
||||||
|
|
||||||
router.push(`${templateRootPath}/${id}/edit`);
|
await navigate(`${templateRootPath}/${id}/edit`);
|
||||||
} catch {
|
} catch {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Something went wrong`),
|
title: _(msg`Something went wrong`),
|
||||||
@ -86,11 +80,11 @@ export const NewTemplateDialog = ({ templateRootPath }: NewTemplateDialogProps)
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={showNewTemplateDialog}
|
open={showTemplateCreateDialog}
|
||||||
onOpenChange={(value) => !isUploadingFile && setShowNewTemplateDialog(value)}
|
onOpenChange={(value) => !isUploadingFile && setShowTemplateCreateDialog(value)}
|
||||||
>
|
>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button className="cursor-pointer" disabled={!session?.user.emailVerified}>
|
<Button className="cursor-pointer" disabled={!user.emailVerified}>
|
||||||
<FilePlus className="-ml-1 mr-2 h-4 w-4" />
|
<FilePlus className="-ml-1 mr-2 h-4 w-4" />
|
||||||
<Trans>New Template</Trans>
|
<Trans>New Template</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
@ -1,7 +1,6 @@
|
|||||||
import { useRouter } from 'next/navigation';
|
import { msg } from '@lingui/core/macro';
|
||||||
|
|
||||||
import { Trans, msg } from '@lingui/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
|
||||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -15,22 +14,25 @@ import {
|
|||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
type DeleteTemplateDialogProps = {
|
type TemplateDeleteDialogProps = {
|
||||||
id: number;
|
id: number;
|
||||||
teamId?: number;
|
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (_open: boolean) => void;
|
onOpenChange: (_open: boolean) => void;
|
||||||
|
onDelete?: () => Promise<void> | void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateDialogProps) => {
|
export const TemplateDeleteDialog = ({
|
||||||
const router = useRouter();
|
id,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onDelete,
|
||||||
|
}: TemplateDeleteDialogProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { mutateAsync: deleteTemplate, isPending } = trpcReact.template.deleteTemplate.useMutation({
|
const { mutateAsync: deleteTemplate, isPending } = trpcReact.template.deleteTemplate.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: async () => {
|
||||||
router.refresh();
|
await onDelete?.();
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Template deleted`),
|
title: _(msg`Template deleted`),
|
||||||
@ -1,14 +1,12 @@
|
|||||||
'use client';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import type { Recipient, Template, TemplateDirectLink } from '@prisma/client';
|
||||||
import { Trans } from '@lingui/macro';
|
|
||||||
import { LinkIcon } from 'lucide-react';
|
import { LinkIcon } from 'lucide-react';
|
||||||
|
|
||||||
import type { Recipient, Template, TemplateDirectLink } from '@documenso/prisma/client';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
import { TemplateDirectLinkDialog } from '../template-direct-link-dialog';
|
import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog';
|
||||||
|
|
||||||
export type TemplateDirectLinkDialogWrapperProps = {
|
export type TemplateDirectLinkDialogWrapperProps = {
|
||||||
template: Template & { directLink?: TemplateDirectLink | null; recipients: Recipient[] };
|
template: Template & { directLink?: TemplateDirectLink | null; recipients: Recipient[] };
|
||||||
@ -1,11 +1,16 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
import { Trans, msg } from '@lingui/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import {
|
||||||
|
type Recipient,
|
||||||
|
RecipientRole,
|
||||||
|
type Template,
|
||||||
|
type TemplateDirectLink,
|
||||||
|
} from '@prisma/client';
|
||||||
import { CircleDotIcon, CircleIcon, ClipboardCopyIcon, InfoIcon, LoaderIcon } from 'lucide-react';
|
import { CircleDotIcon, CircleIcon, ClipboardCopyIcon, InfoIcon, LoaderIcon } from 'lucide-react';
|
||||||
|
import { Link, useRevalidator } from 'react-router';
|
||||||
import { P, match } from 'ts-pattern';
|
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';
|
||||||
@ -14,12 +19,6 @@ import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct
|
|||||||
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';
|
||||||
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
|
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
|
||||||
import {
|
|
||||||
type Recipient,
|
|
||||||
RecipientRole,
|
|
||||||
type Template,
|
|
||||||
type TemplateDirectLink,
|
|
||||||
} from '@documenso/prisma/client';
|
|
||||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
@ -65,9 +64,9 @@ export const TemplateDirectLinkDialog = ({
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { quota, remaining } = useLimits();
|
const { quota, remaining } = useLimits();
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
const { revalidate } = useRevalidator();
|
||||||
|
|
||||||
const [, copy] = useCopyToClipboard();
|
const [, copy] = useCopyToClipboard();
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const [isEnabled, setIsEnabled] = useState(template.directLink?.enabled ?? false);
|
const [isEnabled, setIsEnabled] = useState(template.directLink?.enabled ?? false);
|
||||||
const [token, setToken] = useState(template.directLink?.token ?? null);
|
const [token, setToken] = useState(template.directLink?.token ?? null);
|
||||||
@ -90,12 +89,12 @@ export const TemplateDirectLinkDialog = ({
|
|||||||
isPending: isCreatingTemplateDirectLink,
|
isPending: isCreatingTemplateDirectLink,
|
||||||
reset: resetCreateTemplateDirectLink,
|
reset: resetCreateTemplateDirectLink,
|
||||||
} = trpcReact.template.createTemplateDirectLink.useMutation({
|
} = trpcReact.template.createTemplateDirectLink.useMutation({
|
||||||
onSuccess: (data) => {
|
onSuccess: async (data) => {
|
||||||
|
await revalidate();
|
||||||
|
|
||||||
setToken(data.token);
|
setToken(data.token);
|
||||||
setIsEnabled(data.enabled);
|
setIsEnabled(data.enabled);
|
||||||
setCurrentStep('MANAGE');
|
setCurrentStep('MANAGE');
|
||||||
|
|
||||||
router.refresh();
|
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
setSelectedRecipientId(null);
|
setSelectedRecipientId(null);
|
||||||
@ -110,7 +109,9 @@ export const TemplateDirectLinkDialog = ({
|
|||||||
|
|
||||||
const { mutateAsync: toggleTemplateDirectLink, isPending: isTogglingTemplateAccess } =
|
const { mutateAsync: toggleTemplateDirectLink, isPending: isTogglingTemplateAccess } =
|
||||||
trpcReact.template.toggleTemplateDirectLink.useMutation({
|
trpcReact.template.toggleTemplateDirectLink.useMutation({
|
||||||
onSuccess: (data) => {
|
onSuccess: async (data) => {
|
||||||
|
await revalidate();
|
||||||
|
|
||||||
const enabledDescription = msg`Direct link signing has been enabled`;
|
const enabledDescription = msg`Direct link signing has been enabled`;
|
||||||
const disabledDescription = msg`Direct link signing has been disabled`;
|
const disabledDescription = msg`Direct link signing has been disabled`;
|
||||||
|
|
||||||
@ -133,7 +134,9 @@ export const TemplateDirectLinkDialog = ({
|
|||||||
|
|
||||||
const { mutateAsync: deleteTemplateDirectLink, isPending: isDeletingTemplateDirectLink } =
|
const { mutateAsync: deleteTemplateDirectLink, isPending: isDeletingTemplateDirectLink } =
|
||||||
trpcReact.template.deleteTemplateDirectLink.useMutation({
|
trpcReact.template.deleteTemplateDirectLink.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: async () => {
|
||||||
|
await revalidate();
|
||||||
|
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
setToken(null);
|
setToken(null);
|
||||||
|
|
||||||
@ -143,7 +146,6 @@ export const TemplateDirectLinkDialog = ({
|
|||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
router.refresh();
|
|
||||||
setToken(null);
|
setToken(null);
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
@ -235,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"
|
||||||
href="/settings/billing"
|
to="/settings/billing"
|
||||||
>
|
>
|
||||||
Upgrade your account to continue!
|
Upgrade your account to continue!
|
||||||
</Link>
|
</Link>
|
||||||
@ -436,7 +438,7 @@ export const TemplateDirectLinkDialog = ({
|
|||||||
await toggleTemplateDirectLink({
|
await toggleTemplateDirectLink({
|
||||||
templateId: template.id,
|
templateId: template.id,
|
||||||
enabled: isEnabled,
|
enabled: isEnabled,
|
||||||
}).catch((e) => null);
|
}).catch(() => null);
|
||||||
|
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
}}
|
}}
|
||||||
@ -1,7 +1,6 @@
|
|||||||
import { useRouter } from 'next/navigation';
|
import { msg } from '@lingui/core/macro';
|
||||||
|
|
||||||
import { Trans, msg } from '@lingui/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
|
||||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -15,28 +14,23 @@ import {
|
|||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
type DuplicateTemplateDialogProps = {
|
type TemplateDuplicateDialogProps = {
|
||||||
id: number;
|
id: number;
|
||||||
teamId?: number;
|
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (_open: boolean) => void;
|
onOpenChange: (_open: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DuplicateTemplateDialog = ({
|
export const TemplateDuplicateDialog = ({
|
||||||
id,
|
id,
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: DuplicateTemplateDialogProps) => {
|
}: TemplateDuplicateDialogProps) => {
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { mutateAsync: duplicateTemplate, isPending } =
|
const { mutateAsync: duplicateTemplate, isPending } =
|
||||||
trpcReact.template.duplicateTemplate.useMutation({
|
trpcReact.template.duplicateTemplate.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
router.refresh();
|
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Template duplicated`),
|
title: _(msg`Template duplicated`),
|
||||||
description: _(msg`Your template has been duplicated successfully.`),
|
description: _(msg`Your template has been duplicated successfully.`),
|
||||||
@ -0,0 +1,164 @@
|
|||||||
|
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 { useOptionalCurrentTeam } 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 navigate = useNavigate();
|
||||||
|
const team = useOptionalCurrentTeam();
|
||||||
|
const { folderId } = useParams();
|
||||||
|
|
||||||
|
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 organize 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,163 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,175 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,176 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,13 +1,12 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { msg } from '@lingui/core/macro';
|
||||||
|
|
||||||
import { Trans, msg } from '@lingui/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { 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 { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -28,29 +27,46 @@ import {
|
|||||||
} from '@documenso/ui/primitives/select';
|
} from '@documenso/ui/primitives/select';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
type MoveTemplateDialogProps = {
|
type TemplateMoveDialogProps = {
|
||||||
templateId: number;
|
templateId: number;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (_open: boolean) => void;
|
onOpenChange: (_open: boolean) => void;
|
||||||
|
onMove?: ({
|
||||||
|
templateId,
|
||||||
|
teamUrl,
|
||||||
|
}: {
|
||||||
|
templateId: number;
|
||||||
|
teamUrl: string;
|
||||||
|
}) => Promise<void> | void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MoveTemplateDialog = ({ templateId, open, onOpenChange }: MoveTemplateDialogProps) => {
|
export const TemplateMoveDialog = ({
|
||||||
const router = useRouter();
|
templateId,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onMove,
|
||||||
|
}: TemplateMoveDialogProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const [selectedTeamId, setSelectedTeamId] = useState<number | null>(null);
|
const [selectedTeamId, setSelectedTeamId] = useState<number | null>(null);
|
||||||
|
|
||||||
const { data: teams, isPending: isLoadingTeams } = trpc.team.getTeams.useQuery();
|
const { data: teams, isLoading: isLoadingTeams } = trpc.team.getTeams.useQuery();
|
||||||
|
|
||||||
const { mutateAsync: moveTemplate, isPending } = trpc.template.moveTemplateToTeam.useMutation({
|
const { mutateAsync: moveTemplate, isPending } = trpc.template.moveTemplateToTeam.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: async () => {
|
||||||
router.refresh();
|
const team = teams?.find((team) => team.id === selectedTeamId);
|
||||||
|
|
||||||
|
if (team) {
|
||||||
|
await onMove?.({ templateId, teamUrl: team.url });
|
||||||
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Template moved`),
|
title: _(msg`Template moved`),
|
||||||
description: _(msg`The template has been successfully moved to the selected team.`),
|
description: _(msg`The template has been successfully moved to the selected team.`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
@ -73,7 +89,7 @@ export const MoveTemplateDialog = ({ templateId, open, onOpenChange }: MoveTempl
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const onMove = async () => {
|
const handleOnMove = async () => {
|
||||||
if (!selectedTeamId) {
|
if (!selectedTeamId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -108,9 +124,7 @@ export const MoveTemplateDialog = ({ templateId, open, onOpenChange }: MoveTempl
|
|||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Avatar className="h-8 w-8">
|
<Avatar className="h-8 w-8">
|
||||||
{team.avatarImageId && (
|
{team.avatarImageId && (
|
||||||
<AvatarImage
|
<AvatarImage src={formatAvatarUrl(team.avatarImageId)} />
|
||||||
src={`${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${team.avatarImageId}`}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<AvatarFallback className="text-sm text-gray-400">
|
<AvatarFallback className="text-sm text-gray-400">
|
||||||
@ -130,7 +144,11 @@ export const MoveTemplateDialog = ({ templateId, open, onOpenChange }: MoveTempl
|
|||||||
<Button variant="secondary" onClick={() => onOpenChange(false)}>
|
<Button variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
<Trans>Cancel</Trans>
|
<Trans>Cancel</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={onMove} loading={isPending} disabled={!selectedTeamId || isPending}>
|
<Button
|
||||||
|
onClick={handleOnMove}
|
||||||
|
loading={isPending}
|
||||||
|
disabled={!selectedTeamId || isPending}
|
||||||
|
>
|
||||||
{isPending ? <Trans>Moving...</Trans> : <Trans>Move</Trans>}
|
{isPending ? <Trans>Moving...</Trans> : <Trans>Move</Trans>}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
@ -0,0 +1,213 @@
|
|||||||
|
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 { useOptionalCurrentTeam } 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 = useOptionalCurrentTeam();
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,14 +1,14 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Trans, msg } from '@lingui/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import type { Recipient } from '@prisma/client';
|
||||||
|
import { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/client';
|
||||||
import { InfoIcon, Plus, Upload, X } from 'lucide-react';
|
import { InfoIcon, Plus, Upload, X } from 'lucide-react';
|
||||||
import { useFieldArray, useForm } from 'react-hook-form';
|
import { useFieldArray, useForm } from 'react-hook-form';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
|
|
||||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||||
@ -18,8 +18,6 @@ import {
|
|||||||
} from '@documenso/lib/constants/template';
|
} from '@documenso/lib/constants/template';
|
||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||||
import type { Recipient } from '@documenso/prisma/client';
|
|
||||||
import { DocumentDistributionMethod, DocumentSigningOrder } from '@documenso/prisma/client';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -94,7 +92,7 @@ const ZAddRecipientsForNewDocumentSchema = z
|
|||||||
|
|
||||||
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
|
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
|
||||||
|
|
||||||
export type UseTemplateDialogProps = {
|
export type TemplateUseDialogProps = {
|
||||||
templateId: number;
|
templateId: number;
|
||||||
templateSigningOrder?: DocumentSigningOrder | null;
|
templateSigningOrder?: DocumentSigningOrder | null;
|
||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
@ -103,19 +101,19 @@ export type UseTemplateDialogProps = {
|
|||||||
trigger?: React.ReactNode;
|
trigger?: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function UseTemplateDialog({
|
export function TemplateUseDialog({
|
||||||
recipients,
|
recipients,
|
||||||
documentDistributionMethod = DocumentDistributionMethod.EMAIL,
|
documentDistributionMethod = DocumentDistributionMethod.EMAIL,
|
||||||
documentRootPath,
|
documentRootPath,
|
||||||
templateId,
|
templateId,
|
||||||
templateSigningOrder,
|
templateSigningOrder,
|
||||||
trigger,
|
trigger,
|
||||||
}: UseTemplateDialogProps) {
|
}: TemplateUseDialogProps) {
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const form = useForm<TAddRecipientsForNewDocumentSchema>({
|
const form = useForm<TAddRecipientsForNewDocumentSchema>({
|
||||||
@ -179,7 +177,7 @@ export function UseTemplateDialog({
|
|||||||
documentPath += '?action=view-signing-links';
|
documentPath += '?action=view-signing-links';
|
||||||
}
|
}
|
||||||
|
|
||||||
router.push(documentPath);
|
await navigate(documentPath);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = AppError.parseError(err);
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
@ -1,16 +1,13 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Trans, msg } from '@lingui/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import type { ApiToken } from '@prisma/client';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import type { ApiToken } from '@documenso/prisma/client';
|
|
||||||
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';
|
||||||
import {
|
import {
|
||||||
@ -33,35 +30,31 @@ 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';
|
||||||
|
|
||||||
export type DeleteTokenDialogProps = {
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
teamId?: number;
|
|
||||||
|
export type TokenDeleteDialogProps = {
|
||||||
token: Pick<ApiToken, 'id' | 'name'>;
|
token: Pick<ApiToken, 'id' | 'name'>;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function DeleteTokenDialog({
|
export default function TokenDeleteDialog({ token, onDelete, children }: TokenDeleteDialogProps) {
|
||||||
teamId,
|
|
||||||
token,
|
|
||||||
onDelete,
|
|
||||||
children,
|
|
||||||
}: DeleteTokenDialogProps) {
|
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const router = useRouter();
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
const deleteMessage = _(msg`delete ${token.name}`);
|
const deleteMessage = _(msg`delete ${token.name}`);
|
||||||
|
|
||||||
const ZDeleteTokenDialogSchema = z.object({
|
const ZTokenDeleteDialogSchema = z.object({
|
||||||
tokenName: z.literal(deleteMessage, {
|
tokenName: z.literal(deleteMessage, {
|
||||||
errorMap: () => ({ message: _(msg`You must enter '${deleteMessage}' to proceed`) }),
|
errorMap: () => ({ message: _(msg`You must enter '${deleteMessage}' to proceed`) }),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
type TDeleteTokenByIdMutationSchema = z.infer<typeof ZDeleteTokenDialogSchema>;
|
type TDeleteTokenByIdMutationSchema = z.infer<typeof ZTokenDeleteDialogSchema>;
|
||||||
|
|
||||||
const { mutateAsync: deleteTokenMutation } = trpc.apiToken.deleteTokenById.useMutation({
|
const { mutateAsync: deleteTokenMutation } = trpc.apiToken.deleteTokenById.useMutation({
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
@ -70,7 +63,7 @@ export default function DeleteTokenDialog({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const form = useForm<TDeleteTokenByIdMutationSchema>({
|
const form = useForm<TDeleteTokenByIdMutationSchema>({
|
||||||
resolver: zodResolver(ZDeleteTokenDialogSchema),
|
resolver: zodResolver(ZTokenDeleteDialogSchema),
|
||||||
values: {
|
values: {
|
||||||
tokenName: '',
|
tokenName: '',
|
||||||
},
|
},
|
||||||
@ -80,7 +73,7 @@ export default function DeleteTokenDialog({
|
|||||||
try {
|
try {
|
||||||
await deleteTokenMutation({
|
await deleteTokenMutation({
|
||||||
id: token.id,
|
id: token.id,
|
||||||
teamId,
|
teamId: team?.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@ -90,8 +83,6 @@ export default function DeleteTokenDialog({
|
|||||||
});
|
});
|
||||||
|
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
|
|
||||||
router.refresh();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`An unknown error occurred`),
|
title: _(msg`An unknown error occurred`),
|
||||||
@ -1,12 +1,9 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Trans, msg } from '@lingui/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
|
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 type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
@ -39,22 +36,20 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
|
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
import { TriggerMultiSelectCombobox } from './trigger-multiselect-combobox';
|
import { WebhookMultiSelectCombobox } from '../general/webhook-multiselect-combobox';
|
||||||
|
|
||||||
const ZCreateWebhookFormSchema = ZCreateWebhookMutationSchema.omit({ teamId: true });
|
const ZCreateWebhookFormSchema = ZCreateWebhookMutationSchema.omit({ teamId: true });
|
||||||
|
|
||||||
type TCreateWebhookFormSchema = z.infer<typeof ZCreateWebhookFormSchema>;
|
type TCreateWebhookFormSchema = z.infer<typeof ZCreateWebhookFormSchema>;
|
||||||
|
|
||||||
export type CreateWebhookDialogProps = {
|
export type WebhookCreateDialogProps = {
|
||||||
trigger?: React.ReactNode;
|
trigger?: React.ReactNode;
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogProps) => {
|
export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const team = useOptionalCurrentTeam();
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@ -94,8 +89,6 @@ export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogPr
|
|||||||
});
|
});
|
||||||
|
|
||||||
form.reset();
|
form.reset();
|
||||||
|
|
||||||
router.refresh();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Error`),
|
title: _(msg`Error`),
|
||||||
@ -191,7 +184,7 @@ export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogPr
|
|||||||
<Trans>Triggers</Trans>
|
<Trans>Triggers</Trans>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<TriggerMultiSelectCombobox
|
<WebhookMultiSelectCombobox
|
||||||
listValues={value}
|
listValues={value}
|
||||||
onChange={(values: string[]) => {
|
onChange={(values: string[]) => {
|
||||||
onChange(values);
|
onChange(values);
|
||||||
@ -237,14 +230,13 @@ export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogPr
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<div className="flex w-full flex-nowrap gap-4">
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
<Trans>Cancel</Trans>
|
||||||
<Trans>Cancel</Trans>
|
</Button>
|
||||||
</Button>
|
|
||||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
<Trans>Create</Trans>
|
<Trans>Create</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
@ -1,16 +1,13 @@
|
|||||||
'use effect';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Trans, msg } from '@lingui/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import type { Webhook } from '@prisma/client';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import type { Webhook } from '@documenso/prisma/client';
|
|
||||||
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';
|
||||||
import {
|
import {
|
||||||
@ -35,18 +32,16 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
|
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export type DeleteWebhookDialogProps = {
|
export type WebhookDeleteDialogProps = {
|
||||||
webhook: Pick<Webhook, 'id' | 'webhookUrl'>;
|
webhook: Pick<Webhook, 'id' | 'webhookUrl'>;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DeleteWebhookDialog = ({ webhook, children }: DeleteWebhookDialogProps) => {
|
export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const team = useOptionalCurrentTeam();
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@ -81,8 +76,6 @@ export const DeleteWebhookDialog = ({ webhook, children }: DeleteWebhookDialogPr
|
|||||||
});
|
});
|
||||||
|
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
|
||||||
router.refresh();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`An unknown error occurred`),
|
title: _(msg`An unknown error occurred`),
|
||||||
@ -0,0 +1,355 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,68 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
@ -0,0 +1,413 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,238 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,137 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,50 @@
|
|||||||
|
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(),
|
||||||
|
});
|
||||||
@ -0,0 +1,646 @@
|
|||||||
|
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 }) => {
|
||||||
|
const { duplicate = false } = options ?? {};
|
||||||
|
|
||||||
|
if (lastActiveField) {
|
||||||
|
event?.preventDefault();
|
||||||
|
|
||||||
|
if (!duplicate) {
|
||||||
|
setFieldClipboard(lastActiveField);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Copied field',
|
||||||
|
description: 'Copied field to clipboard',
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[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 })}
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
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];
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user