Merge branch 'main' into refactor-forms

This commit is contained in:
Lucas Smith
2023-12-08 16:31:13 +11:00
committed by GitHub
91 changed files with 2332 additions and 558 deletions

View File

@ -1,11 +1,10 @@
name: "Bug Report" name: 'Bug Report'
labels: ["bug"] labels: ['bug']
description: Create a bug report to help us improve description: Create a bug report to help us improve
body: body:
- type: markdown - type: markdown
attributes: attributes:
value: value: Thank you for reporting an issue.
Thank you for reporting an issue.
Please fill in as much of the form below as you're able to. Please fill in as much of the form below as you're able to.
- type: textarea - type: textarea
attributes: attributes:

View File

@ -1,4 +1,4 @@
name: "Feature Request" name: 'Feature Request'
description: Suggest a new idea or enhancement for this project description: Suggest a new idea or enhancement for this project
body: body:
- type: markdown - type: markdown

View File

@ -1,4 +1,4 @@
name: "General Improvement" name: 'General Improvement'
description: Suggest a minor enhancement or improvement for this project description: Suggest a minor enhancement or improvement for this project
body: body:
- type: markdown - type: markdown

View File

@ -4,29 +4,29 @@ updates:
- package-ecosystem: 'github-actions' - package-ecosystem: 'github-actions'
directory: '/' directory: '/'
schedule: schedule:
interval: "weekly" interval: 'weekly'
target-branch: "main" target-branch: 'main'
labels: labels:
- "ci dependencies" - 'ci dependencies'
- "ci" - 'ci'
open-pull-requests-limit: 0 open-pull-requests-limit: 0
- package-ecosystem: "npm" - package-ecosystem: 'npm'
directory: "/apps/marketing" directory: '/apps/marketing'
schedule: schedule:
interval: "weekly" interval: 'weekly'
target-branch: "main" target-branch: 'main'
labels: labels:
- "npm dependencies" - 'npm dependencies'
- "frontend" - 'frontend'
open-pull-requests-limit: 0 open-pull-requests-limit: 0
- package-ecosystem: "npm" - package-ecosystem: 'npm'
directory: "/apps/web" directory: '/apps/web'
schedule: schedule:
interval: "weekly" interval: 'weekly'
target-branch: "main" target-branch: 'main'
labels: labels:
- "npm dependencies" - 'npm dependencies'
- "frontend" - 'frontend'
open-pull-requests-limit: 0 open-pull-requests-limit: 0

21
.github/pr-labeler.yml vendored Normal file
View File

@ -0,0 +1,21 @@
'apps: marketing':
- apps/marketing/**
'apps: web':
- apps/web/**
'version bump 👀':
- '**/package.json'
- '**/package-lock.json'
'🚨 migrations 🚨':
- packages/prisma/migrations/**/migration.sql
'🚨 e2e changes 🚨':
- packages/app-tests/e2e/**
'🚨 .env changes 🚨':
- .env.example
'pkg: ee changes':
- packages/ee/**

View File

@ -1,10 +1,10 @@
name: "Continuous Integration" name: 'Continuous Integration'
on: on:
push: push:
branches: [ "main" ] branches: ['main']
pull_request: pull_request:
branches: [ "main" ] branches: ['main']
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
@ -19,12 +19,12 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v4
with: with:
fetch-depth: 2 fetch-depth: 2
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: 18 node-version: 18
cache: npm cache: npm
@ -43,10 +43,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v4
with: with:
fetch-depth: 2 fetch-depth: 2
- name: Build Docker Image - name: Build Docker Image
run: ./docker/build.sh run: ./docker/build.sh

View File

@ -1,11 +1,11 @@
name: "CodeQL" name: 'CodeQL'
on: on:
workflow_dispatch: workflow_dispatch:
push: push:
branches: [ "main" ] branches: ['main']
pull_request: pull_request:
branches: [ "main" ] branches: ['main']
jobs: jobs:
analyze: analyze:
@ -19,30 +19,30 @@ jobs:
strategy: strategy:
fail-fast: true fail-fast: true
matrix: matrix:
language: [ 'javascript' ] language: ['javascript']
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3 uses: actions/checkout@v4
- uses: actions/setup-node@v3 - uses: actions/setup-node@v4
with: with:
node-version: 18 node-version: 18
cache: npm cache: npm
- name: Install Dependencies - name: Install Dependencies
run: npm ci run: npm ci
- name: Copy env - name: Copy env
run: cp .env.example .env run: cp .env.example .env
- name: Build Documenso - name: Build Documenso
run: npm run build run: npm run build
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v2 uses: github/codeql-action/init@v2
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2 uses: github/codeql-action/analyze@v2

24
.github/workflows/deploy.yml vendored Normal file
View File

@ -0,0 +1,24 @@
name: Deploy to Production
on:
push:
tags:
- '*'
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
token: ${{ secrets.GH_TOKEN }}
- name: Push to release branch
run: |
git checkout release || git checkout -b release
git merge --ff-only main
git push origin release

View File

@ -1,51 +1,50 @@
name: Playwright Tests name: Playwright Tests
on: on:
push: push:
branches: [ "main" ] branches: ['main']
pull_request: pull_request:
branches: [ "main" ] branches: ['main']
jobs: jobs:
e2e_tests: e2e_tests:
name: "E2E Tests"
timeout-minutes: 60 timeout-minutes: 60
runs-on: ubuntu-latest runs-on: ubuntu-latest
services:
postgres:
image: postgres
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- uses: actions/setup-node@v3 - uses: actions/setup-node@v4
with: with:
node-version: 18 node-version: 18
cache: npm
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Copy env - name: Copy env
run: cp .env.example .env run: cp .env.example .env
- name: Start Services
run: npm run dx:up
- name: Install Playwright Browsers - name: Install Playwright Browsers
run: npx playwright install --with-deps run: npx playwright install --with-deps
- name: Generate Prisma Client - name: Generate Prisma Client
run: npm run prisma:generate -w @documenso/prisma run: npm run prisma:generate -w @documenso/prisma
- name: Create the database - name: Create the database
run: npm run prisma:migrate-dev run: npm run prisma:migrate-dev
- name: Seed the database
run: npm run prisma:seed
- name: Run Playwright tests - name: Run Playwright tests
run: npm run ci run: npm run ci
- uses: actions/upload-artifact@v3 - uses: actions/upload-artifact@v3
if: always() if: always()
with: with:
name: playwright-report name: test-results
path: playwright-report/ path: "packages/app-tests/**/test-results/*"
retention-days: 30 retention-days: 30
env: env:
NEXT_PRIVATE_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/documenso
NEXT_PRIVATE_DIRECT_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/documenso
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }} TURBO_TEAM: ${{ vars.TURBO_TEAM }}

29
.github/workflows/first-interaction.yml vendored Normal file
View File

@ -0,0 +1,29 @@
name: 'Welcome New Contributors'
on:
pull_request:
types: ['opened']
issues:
types: ['opened']
permissions:
pull-requests: write
issues: write
jobs:
welcome-message:
name: Welcome Contributors
if: github.event.action == 'opened'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/first-interaction@v1
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
pr-message: |
Thank you for creating your first Pull Request and for being a part of the open signing revolution! 💚🚀
<br /> Feel free to hop into our community in [Discord](https://documen.so/discord)
issue-message: |
Thank you for opening your first issue and for being a part of the open signing revolution!
<br /> One of our team members will review it and get back to you as soon as it possible 💚
<br /> Meanwhile, please feel free to hop into our community in [Discord](https://documen.so/discord)

View File

@ -0,0 +1,59 @@
name: 'Issue Assignee Check'
on:
issues:
types: ['assigned']
permissions:
issues: write
jobs:
countIssues:
if: ${{ github.event.issue.assignee }} && github.event.action == 'assigned' && github.event.sender.type == 'User'
runs-on: ubuntu-latest
steps:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: npm
- name: Install Octokit
run: npm install @octokit/rest@18
- name: Check Assigned User's Issue Count
id: parse-comment
uses: actions/github-script@v5
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const { Octokit } = require("@octokit/rest");
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
const username = context.payload.issue.assignee.login;
console.log(`Username Extracted: ${username}`);
const { data: issues } = await octokit.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
assignee: username,
state: 'open'
});
const issueCount = issues.length;
console.log(`Issue Count For ${username}: ${issueCount}`);
if (issueCount > 3) {
let issueCountMessage = `### 🚨 Documenso Police 🚨`;
issueCountMessage += `\n@${username} has ${issueCount} open issues assigned already. Consider whether this issue should be assigned to them or left open for another contributor.`;
await octokit.request('POST /repos/{owner}/{repo}/issues/{issue_number}/comments', {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: issueCountMessage,
headers: {
'Authorization': `token ${{ secrets.GITHUB_TOKEN }}`,
}
});
}

21
.github/workflows/issue-opened.yml vendored Normal file
View File

@ -0,0 +1,21 @@
name: 'Label Issues'
on:
issues:
types: ['opened', 'reopened']
jobs:
label_issues:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- uses: actions/github-script@v6
with:
script: |
github.rest.issues.addLabels({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
labels: ["needs triage"]
})

20
.github/workflows/pr-labeler.yml vendored Normal file
View File

@ -0,0 +1,20 @@
name: 'PR Labeler'
on:
- pull_request_target
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
labeler:
permissions:
contents: read
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v4
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
sync-labels: ''

View File

@ -0,0 +1,60 @@
name: 'PR Review Reminder'
on:
pull_request:
types: ['opened', 'reopened', 'ready_for_review', 'review_requested']
permissions:
pull-requests: write
jobs:
checkPRs:
if: ${{ github.event.pull_request.user.login }} && github.event.action == ('opened' || 'reopened' || 'ready_for_review' || 'review_requested')
runs-on: ubuntu-latest
steps:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: npm
- name: Install Octokit
run: npm install @octokit/rest@18
- name: Check user's PRs awaiting review
id: parse-prs
uses: actions/github-script@v5
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const { Octokit } = require("@octokit/rest");
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
const username = context.payload.pull_request.user.login;
console.log(`Username Extracted: ${username}`);
const { data: pullRequests } = await octokit.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
sort: 'created',
direction: 'asc',
});
const userPullRequests = pullRequests.filter(pr => pr.user.login === username && (pr.state === 'open' || pr.state === 'ready_for_review'));
const prCount = userPullRequests.length;
console.log(`PR Count for ${username}: ${prCount}`);
if (prCount > 3) {
let prReminderMessage = `🚨 @${username} has ${prCount} pull requests awaiting review. Please consider reviewing them when possible. 🚨`;
await octokit.request('POST /repos/{owner}/{repo}/issues/{issue_number}/comments', {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body: prReminderMessage,
headers: {
'Authorization': `token ${{ secrets.GITHUB_TOKEN }}`,
}
});
}

View File

@ -17,5 +17,28 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: amannn/action-semantic-pull-request@v5 - uses: amannn/action-semantic-pull-request@v5
id: lint_pr_title
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: marocchino/sticky-pull-request-comment@v2
if: always() && (steps.lint_pr_title.outputs.error_message != null)
with:
header: pr-title-lint-error
message: |
Hey There! and thank you for opening this pull request! 📝👋🏼
We require pull request titles to follow the [Conventional Commits Spec](https://www.conventionalcommits.org/en/v1.0.0/) and it looks like your proposed title needs to be adjusted.
Details:
```
${{ steps.lint_pr_title.outputs.error_message }}
```
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
uses: marocchino/sticky-pull-request-comment@v2
with:
header: pr-title-lint-error
message: |
Thank you for following the naming conventions for pull request titles! 💚🚀

25
.github/workflows/stale.yml vendored Normal file
View File

@ -0,0 +1,25 @@
name: 'Mark Stale Issues and PRs'
on:
schedule:
- cron: '0 */8 * * *'
jobs:
stale:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v4
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-pr-stale: 30
days-before-issue-stale: 30
stale-issue-message: 'This issue has not seen activity for a while. It will be closed in 30 days unless further activity is detected'
stale-pr-message: 'This PR has not seen activitiy for a while. It will be closed in 30 days unless further activity is detected.'
close-issue-message: 'This issue has been closed because of inactivity.'
close-pr-message: 'This PR has been closed because of inactivity.'
exempt-pr-labels: 'WIP,on-hold,needs review'
exempt-issue-labels: 'WIP,on-hold,needs review,roadmap'

View File

@ -6,5 +6,8 @@
"eslint.validate": ["typescript", "typescriptreact", "javascript", "javascriptreact"], "eslint.validate": ["typescript", "typescriptreact", "javascript", "javascriptreact"],
"javascript.preferences.importModuleSpecifier": "non-relative", "javascript.preferences.importModuleSpecifier": "non-relative",
"javascript.preferences.useAliasesForRenames": false, "javascript.preferences.useAliasesForRenames": false,
"typescript.enablePromptUseWorkspaceTsdk": true "typescript.enablePromptUseWorkspaceTsdk": true,
"files.eol": "\n",
"editor.tabSize": 2,
"editor.insertSpaces": true
} }

View File

@ -0,0 +1,13 @@
---
title: Careers at Documenso
---
# Careers at Documenso
So you love Documenso and all the things that we do and now you want to work with us to unlock the future of open signing?
---
## Open Positions
Unfortunately we have no open positions available at the moment. Our team has grown and so we must grow with it, please check back from time to time as now is not forever and we may be hiring again in the future.

View File

@ -29,7 +29,7 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
return ( return (
<div <div
className={cn('relative max-w-[100vw] pt-20 md:pt-28', { className={cn('relative flex min-h-[100vh] max-w-[100vw] flex-col pt-20 md:pt-28', {
'overflow-y-auto overflow-x-hidden': pathname !== '/singleplayer', 'overflow-y-auto overflow-x-hidden': pathname !== '/singleplayer',
})} })}
> >
@ -41,7 +41,7 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
<Header className="mx-auto h-16 max-w-screen-xl px-4 md:h-20 lg:px-8" /> <Header className="mx-auto h-16 max-w-screen-xl px-4 md:h-20 lg:px-8" />
</div> </div>
<div className="relative mx-auto max-w-screen-xl px-4 lg:px-8">{children}</div> <div className="relative mx-auto max-w-screen-xl flex-1 px-4 lg:px-8">{children}</div>
<Footer className="bg-background border-muted mt-24 border-t" /> <Footer className="bg-background border-muted mt-24 border-t" />
</div> </div>

View File

@ -17,15 +17,14 @@ import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add
import type { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types'; import type { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
import { AddSignatureFormPartial } from '@documenso/ui/primitives/document-flow/add-signature'; import { AddSignatureFormPartial } from '@documenso/ui/primitives/document-flow/add-signature';
import type { TAddSignatureFormSchema } from '@documenso/ui/primitives/document-flow/add-signature.types'; import type { TAddSignatureFormSchema } from '@documenso/ui/primitives/document-flow/add-signature.types';
import { import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
DocumentFlowFormContainer,
DocumentFlowFormContainerHeader,
} from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { Stepper } from '@documenso/ui/primitives/stepper';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
type SinglePlayerModeStep = 'fields' | 'sign'; const SinglePlayerModeSteps = ['fields', 'sign'] as const;
type SinglePlayerModeStep = (typeof SinglePlayerModeSteps)[number];
// !: This entire file is a hack to get around failed prerendering of // !: This entire file is a hack to get around failed prerendering of
// !: the Single Player Mode page. This regression was introduced during // !: the Single Player Mode page. This regression was introduced during
@ -226,37 +225,35 @@ export const SinglePlayerClient = () => {
</div> </div>
<div className="col-span-12 lg:col-span-6 xl:col-span-5"> <div className="col-span-12 lg:col-span-6 xl:col-span-5">
<DocumentFlowFormContainer className="top-24" onSubmit={(e) => e.preventDefault()}> <DocumentFlowFormContainer
<DocumentFlowFormContainerHeader className="top-24 lg:h-[calc(100vh-7rem)]"
title={currentDocumentFlow.title} onSubmit={(e) => e.preventDefault()}
description={currentDocumentFlow.description} >
/> <Stepper
currentStep={currentDocumentFlow.stepIndex}
{/* Add fields to PDF page. */} setCurrentStep={(step) => setStep(SinglePlayerModeSteps[step - 1])}
{step === 'fields' && ( >
{/* Add fields to PDF page. */}
<fieldset disabled={!uploadedFile} className="flex h-full flex-col"> <fieldset disabled={!uploadedFile} className="flex h-full flex-col">
<AddFieldsFormPartial <AddFieldsFormPartial
documentFlow={documentFlow.fields} documentFlow={documentFlow.fields}
hideRecipients={true} hideRecipients={true}
recipients={uploadedFile ? [placeholderRecipient] : []} recipients={uploadedFile ? [placeholderRecipient] : []}
numberOfSteps={Object.keys(documentFlow).length}
fields={fields} fields={fields}
onSubmit={onFieldsSubmit} onSubmit={onFieldsSubmit}
/> />
</fieldset> </fieldset>
)}
{/* Enter user details and signature. */} {/* Enter user details and signature. */}
{step === 'sign' && (
<AddSignatureFormPartial <AddSignatureFormPartial
documentFlow={documentFlow.sign} documentFlow={documentFlow.sign}
numberOfSteps={Object.keys(documentFlow).length}
fields={fields} fields={fields}
onSubmit={onSignSubmit} onSubmit={onSignSubmit}
requireName={Boolean(fields.find((field) => field.type === 'NAME'))} requireName={Boolean(fields.find((field) => field.type === 'NAME'))}
requireSignature={Boolean(fields.find((field) => field.type === 'SIGNATURE'))} requireSignature={Boolean(fields.find((field) => field.type === 'SIGNATURE'))}
/> />
)} </Stepper>
</DocumentFlowFormContainer> </DocumentFlowFormContainer>
</div> </div>
</div> </div>

View File

@ -31,6 +31,7 @@ const FOOTER_LINKS = [
{ href: 'https://status.documenso.com', text: 'Status', target: '_blank' }, { href: 'https://status.documenso.com', text: 'Status', target: '_blank' },
{ href: 'mailto:support@documenso.com', text: 'Support', target: '_blank' }, { href: 'mailto:support@documenso.com', text: 'Support', target: '_blank' },
{ href: '/oss-friends', text: 'OSS Friends' }, { href: '/oss-friends', text: 'OSS Friends' },
{ href: '/careers', text: 'Careers' },
{ href: '/privacy', text: 'Privacy' }, { href: '/privacy', text: 'Privacy' },
]; ];

View File

@ -4,6 +4,11 @@ import { appRouter } from '@documenso/trpc/server/router';
export const config = { export const config = {
maxDuration: 60, maxDuration: 60,
api: {
bodyParser: {
sizeLimit: '50mb',
},
},
}; };
export default trpcNext.createNextApiHandler({ export default trpcNext.createNextApiHandler({

View File

@ -8,6 +8,7 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"e2e:prepare": "next build && 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" "copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"

View File

@ -4,8 +4,8 @@ import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import type { DocumentData, Field, Recipient, User } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client'; import { DocumentStatus } from '@documenso/prisma/client';
import type { DocumentData, Field, Recipient, User } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
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';
@ -18,12 +18,10 @@ import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/ad
import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types'; import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
import { AddTitleFormPartial } from '@documenso/ui/primitives/document-flow/add-title'; import { AddTitleFormPartial } from '@documenso/ui/primitives/document-flow/add-title';
import type { TAddTitleFormSchema } from '@documenso/ui/primitives/document-flow/add-title.types'; import type { TAddTitleFormSchema } from '@documenso/ui/primitives/document-flow/add-title.types';
import { import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
DocumentFlowFormContainer,
DocumentFlowFormContainerHeader,
} from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { Stepper } from '@documenso/ui/primitives/stepper';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
export type EditDocumentFormProps = { export type EditDocumentFormProps = {
@ -36,6 +34,7 @@ export type EditDocumentFormProps = {
}; };
type EditDocumentStep = 'title' | 'signers' | 'fields' | 'subject'; type EditDocumentStep = 'title' | 'signers' | 'fields' | 'subject';
const EditDocumentSteps: EditDocumentStep[] = ['title', 'signers', 'fields', 'subject'];
export const EditDocumentForm = ({ export const EditDocumentForm = ({
className, className,
@ -48,6 +47,7 @@ export const EditDocumentForm = ({
const { toast } = useToast(); const { toast } = useToast();
const router = useRouter(); const router = useRouter();
// controlled stepper state
const [step, setStep] = useState<EditDocumentStep>( const [step, setStep] = useState<EditDocumentStep>(
document.status === DocumentStatus.DRAFT ? 'title' : 'signers', document.status === DocumentStatus.DRAFT ? 'title' : 'signers',
); );
@ -67,24 +67,19 @@ export const EditDocumentForm = ({
title: 'Add Signers', title: 'Add Signers',
description: 'Add the people who will sign the document.', description: 'Add the people who will sign the document.',
stepIndex: 2, stepIndex: 2,
onBackStep: () => document.status === DocumentStatus.DRAFT && setStep('title'),
}, },
fields: { fields: {
title: 'Add Fields', title: 'Add Fields',
description: 'Add all relevant fields for each recipient.', description: 'Add all relevant fields for each recipient.',
stepIndex: 3, stepIndex: 3,
onBackStep: () => setStep('signers'),
}, },
subject: { subject: {
title: 'Add Subject', title: 'Add Subject',
description: 'Add the subject and message you wish to send to signers.', description: 'Add the subject and message you wish to send to signers.',
stepIndex: 4, stepIndex: 4,
onBackStep: () => setStep('fields'),
}, },
}; };
const currentDocumentFlow = documentFlow[step];
const onAddTitleFormSubmit = async (data: TAddTitleFormSchema) => { const onAddTitleFormSubmit = async (data: TAddTitleFormSchema) => {
try { try {
// Custom invocation server action // Custom invocation server action
@ -116,7 +111,6 @@ export const EditDocumentForm = ({
}); });
router.refresh(); router.refresh();
setStep('fields'); setStep('fields');
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@ -138,7 +132,6 @@ export const EditDocumentForm = ({
}); });
router.refresh(); router.refresh();
setStep('subject'); setStep('subject');
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@ -181,6 +174,8 @@ export const EditDocumentForm = ({
} }
}; };
const currentDocumentFlow = documentFlow[step];
return ( return (
<div className={cn('grid w-full grid-cols-12 gap-8', className)}> <div className={cn('grid w-full grid-cols-12 gap-8', className)}>
<Card <Card
@ -197,56 +192,43 @@ export const EditDocumentForm = ({
className="lg:h-[calc(100vh-6rem)]" className="lg:h-[calc(100vh-6rem)]"
onSubmit={(e) => e.preventDefault()} onSubmit={(e) => e.preventDefault()}
> >
<DocumentFlowFormContainerHeader <Stepper
title={currentDocumentFlow.title} currentStep={currentDocumentFlow.stepIndex}
description={currentDocumentFlow.description} setCurrentStep={(step) => setStep(EditDocumentSteps[step - 1])}
/> >
{step === 'title' && (
<AddTitleFormPartial <AddTitleFormPartial
key={recipients.length} key={recipients.length}
documentFlow={documentFlow.title} documentFlow={documentFlow.title}
recipients={recipients} recipients={recipients}
fields={fields} fields={fields}
document={document} document={document}
numberOfSteps={Object.keys(documentFlow).length}
onSubmit={onAddTitleFormSubmit} onSubmit={onAddTitleFormSubmit}
/> />
)}
{step === 'signers' && (
<AddSignersFormPartial <AddSignersFormPartial
key={recipients.length} key={recipients.length}
documentFlow={documentFlow.signers} documentFlow={documentFlow.signers}
document={document} document={document}
recipients={recipients} recipients={recipients}
fields={fields} fields={fields}
numberOfSteps={Object.keys(documentFlow).length}
onSubmit={onAddSignersFormSubmit} onSubmit={onAddSignersFormSubmit}
/> />
)}
{step === 'fields' && (
<AddFieldsFormPartial <AddFieldsFormPartial
key={fields.length} key={fields.length}
documentFlow={documentFlow.fields} documentFlow={documentFlow.fields}
recipients={recipients} recipients={recipients}
fields={fields} fields={fields}
numberOfSteps={Object.keys(documentFlow).length}
onSubmit={onAddFieldsFormSubmit} onSubmit={onAddFieldsFormSubmit}
/> />
)}
{step === 'subject' && (
<AddSubjectFormPartial <AddSubjectFormPartial
key={recipients.length}
documentFlow={documentFlow.subject} documentFlow={documentFlow.subject}
document={document} document={document}
recipients={recipients} recipients={recipients}
fields={fields} fields={fields}
numberOfSteps={Object.keys(documentFlow).length}
onSubmit={onAddSubjectFormSubmit} onSubmit={onAddSubjectFormSubmit}
/> />
)} </Stepper>
</DocumentFlowFormContainer> </DocumentFlowFormContainer>
</div> </div>
</div> </div>

View File

@ -12,6 +12,7 @@ import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { trpc as trpcClient } from '@documenso/trpc/client'; import { trpc as trpcClient } from '@documenso/trpc/client';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DataTableActionButtonProps = { export type DataTableActionButtonProps = {
row: Document & { row: Document & {
@ -22,6 +23,7 @@ export type DataTableActionButtonProps = {
export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => { export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
const { data: session } = useSession(); const { data: session } = useSession();
const { toast } = useToast();
if (!session) { if (!session) {
return null; return null;
@ -37,39 +39,47 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const onDownloadClick = async () => { const onDownloadClick = async () => {
let document: DocumentWithData | null = null; try {
let document: DocumentWithData | null = null;
if (!recipient) { if (!recipient) {
document = await trpcClient.document.getDocumentById.query({ document = await trpcClient.document.getDocumentById.query({
id: row.id, id: row.id,
});
} else {
document = await trpcClient.document.getDocumentByToken.query({
token: recipient.token,
});
}
const documentData = document?.documentData;
if (!documentData) {
return;
}
const documentBytes = await getFile(documentData);
const blob = new Blob([documentBytes], {
type: 'application/pdf',
}); });
} else {
document = await trpcClient.document.getDocumentByToken.query({ const link = window.document.createElement('a');
token: recipient.token, const baseTitle = row.title.includes('.pdf') ? row.title.split('.pdf')[0] : row.title;
link.href = window.URL.createObjectURL(blob);
link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf';
link.click();
window.URL.revokeObjectURL(link.href);
} catch (error) {
toast({
title: 'Something went wrong',
description: 'An error occurred while trying to download file.',
variant: 'destructive',
}); });
} }
const documentData = document?.documentData;
if (!documentData) {
return;
}
const documentBytes = await getFile(documentData);
const blob = new Blob([documentBytes], {
type: 'application/pdf',
});
const link = window.document.createElement('a');
const baseTitle = row.title.includes('.pdf') ? row.title.split('.pdf')[0] : row.title;
link.href = window.URL.createObjectURL(blob);
link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf';
link.click();
window.URL.revokeObjectURL(link.href);
}; };
return match({ return match({

View File

@ -32,7 +32,7 @@ import {
} from '@documenso/ui/primitives/dropdown-menu'; } from '@documenso/ui/primitives/dropdown-menu';
import { ResendDocumentActionItem } from './_action-items/resend-document'; import { ResendDocumentActionItem } from './_action-items/resend-document';
import { DeleteDraftDocumentDialog } from './delete-draft-document-dialog'; import { DeleteDocumentDialog } from './delete-document-dialog';
import { DuplicateDocumentDialog } from './duplicate-document-dialog'; import { DuplicateDocumentDialog } from './duplicate-document-dialog';
export type DataTableActionDropdownProps = { export type DataTableActionDropdownProps = {
@ -60,7 +60,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
// const isPending = row.status === DocumentStatus.PENDING; // const isPending = row.status === DocumentStatus.PENDING;
const isComplete = row.status === DocumentStatus.COMPLETED; const isComplete = row.status === DocumentStatus.COMPLETED;
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; // const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const isDocumentDeletable = isOwner && row.status === DocumentStatus.DRAFT; const isDocumentDeletable = isOwner;
const onDownloadClick = async () => { const onDownloadClick = async () => {
let document: DocumentWithData | null = null; let document: DocumentWithData | null = null;
@ -161,8 +161,9 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
</DropdownMenuContent> </DropdownMenuContent>
{isDocumentDeletable && ( {isDocumentDeletable && (
<DeleteDraftDocumentDialog <DeleteDocumentDialog
id={row.id} id={row.id}
status={row.status}
open={isDeleteDialogOpen} open={isDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen} onOpenChange={setDeleteDialogOpen}
/> />

View File

@ -8,6 +8,7 @@ import { useSession } from 'next-auth/react';
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 type { FindResultSet } from '@documenso/lib/types/find-result-set'; import type { FindResultSet } from '@documenso/lib/types/find-result-set';
import type { Document, Recipient, User } from '@documenso/prisma/client'; import type { Document, Recipient, User } from '@documenso/prisma/client';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { DataTable } from '@documenso/ui/primitives/data-table'; import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
@ -74,12 +75,14 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
}, },
{ {
header: 'Actions', header: 'Actions',
cell: ({ row }) => ( cell: ({ row }) =>
<div className="flex items-center gap-x-4"> (!row.original.deletedAt ||
<DataTableActionButton row={row.original} /> row.original.status === ExtendedDocumentStatus.COMPLETED) && (
<DataTableActionDropdown row={row.original} /> <div className="flex items-center gap-x-4">
</div> <DataTableActionButton row={row.original} />
), <DataTableActionDropdown row={row.original} />
</div>
),
}, },
]} ]}
data={results.data} data={results.data}

View File

@ -1,5 +1,8 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { DocumentStatus } 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 {
@ -10,41 +13,46 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@documenso/ui/primitives/dialog'; } from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
type DeleteDraftDocumentDialogProps = { type DeleteDraftDocumentDialogProps = {
id: number; id: number;
open: boolean; open: boolean;
onOpenChange: (_open: boolean) => void; onOpenChange: (_open: boolean) => void;
status: DocumentStatus;
}; };
export const DeleteDraftDocumentDialog = ({ export const DeleteDocumentDialog = ({
id, id,
open, open,
onOpenChange, onOpenChange,
status,
}: DeleteDraftDocumentDialogProps) => { }: DeleteDraftDocumentDialogProps) => {
const router = useRouter(); const router = useRouter();
const { toast } = useToast(); const { toast } = useToast();
const { mutateAsync: deleteDocument, isLoading } = const [inputValue, setInputValue] = useState('');
trpcReact.document.deleteDraftDocument.useMutation({ const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT);
onSuccess: () => {
router.refresh();
toast({ const { mutateAsync: deleteDocument, isLoading } = trpcReact.document.deleteDocument.useMutation({
title: 'Document deleted', onSuccess: () => {
description: 'Your document has been successfully deleted.', router.refresh();
duration: 5000,
});
onOpenChange(false); toast({
}, title: 'Document deleted',
}); description: 'Your document has been successfully deleted.',
duration: 5000,
});
const onDraftDelete = async () => { onOpenChange(false);
},
});
const onDelete = async () => {
try { try {
await deleteDocument({ id }); await deleteDocument({ id, status });
} catch { } catch {
toast({ toast({
title: 'Something went wrong', title: 'Something went wrong',
@ -55,6 +63,11 @@ export const DeleteDraftDocumentDialog = ({
} }
}; };
const onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
setIsDeleteEnabled(event.target.value === 'delete');
};
return ( return (
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}> <Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
<DialogContent> <DialogContent>
@ -67,6 +80,17 @@ export const DeleteDraftDocumentDialog = ({
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{status !== DocumentStatus.DRAFT && (
<div className="mt-8">
<Input
type="text"
value={inputValue}
onChange={onInputChange}
placeholder="Type 'delete' to confirm"
/>
</div>
)}
<DialogFooter> <DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4"> <div className="flex w-full flex-1 flex-nowrap gap-4">
<Button <Button
@ -78,7 +102,14 @@ export const DeleteDraftDocumentDialog = ({
Cancel Cancel
</Button> </Button>
<Button type="button" loading={isLoading} onClick={onDraftDelete} className="flex-1"> <Button
type="button"
loading={isLoading}
onClick={onDelete}
disabled={!isDeleteEnabled}
variant="destructive"
className="flex-1"
>
Delete Delete
</Button> </Button>
</div> </div>

View File

@ -9,6 +9,7 @@ import { Loader } from 'lucide-react';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data'; import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { putFile } from '@documenso/lib/universal/upload/put-file'; import { putFile } from '@documenso/lib/universal/upload/put-file';
import { TRPCClientError } from '@documenso/trpc/client'; import { TRPCClientError } from '@documenso/trpc/client';
@ -23,6 +24,7 @@ export type UploadDocumentProps = {
export const UploadDocument = ({ className }: UploadDocumentProps) => { export const UploadDocument = ({ className }: UploadDocumentProps) => {
const router = useRouter(); const router = useRouter();
const analytics = useAnalytics();
const { data: session } = useSession(); const { data: session } = useSession();
const { toast } = useToast(); const { toast } = useToast();
@ -55,6 +57,12 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
duration: 5000, duration: 5000,
}); });
analytics.capture('App: Document Uploaded', {
userId: session?.user.id,
documentId: id,
timestamp: new Date().toISOString(),
});
router.push(`/documents/${id}`); router.push(`/documents/${id}`);
} catch (error) { } catch (error) {
console.error(error); console.error(error);

View File

@ -23,7 +23,7 @@ export const createCheckout = async ({ priceId }: CreateCheckoutOptions) => {
let stripeCustomer: Stripe.Customer | null = null; let stripeCustomer: Stripe.Customer | null = null;
// Find the Stripe customer for the current user subscription. // Find the Stripe customer for the current user subscription.
if (existingSubscription) { if (existingSubscription?.periodEnd && existingSubscription.periodEnd >= new Date()) {
stripeCustomer = await getStripeCustomerById(existingSubscription.customerId); stripeCustomer = await getStripeCustomerById(existingSubscription.customerId);
if (!stripeCustomer) { if (!stripeCustomer) {

View File

@ -67,18 +67,24 @@ export default async function CompletedSigningPage({
/> />
<div className="relative mt-6 flex w-full flex-col items-center"> <div className="relative mt-6 flex w-full flex-col items-center">
{match(document.status) {match({ status: document.status, deletedAt: document.deletedAt })
.with(DocumentStatus.COMPLETED, () => ( .with({ status: DocumentStatus.COMPLETED }, () => (
<div className="text-documenso-700 flex items-center text-center"> <div className="text-documenso-700 flex items-center text-center">
<CheckCircle2 className="mr-2 h-5 w-5" /> <CheckCircle2 className="mr-2 h-5 w-5" />
<span className="text-sm">Everyone has signed</span> <span className="text-sm">Everyone has signed</span>
</div> </div>
)) ))
.otherwise(() => ( .with({ deletedAt: null }, () => (
<div className="flex items-center text-center text-blue-600"> <div className="flex items-center text-center text-blue-600">
<Clock8 className="mr-2 h-5 w-5" /> <Clock8 className="mr-2 h-5 w-5" />
<span className="text-sm">Waiting for others to sign</span> <span className="text-sm">Waiting for others to sign</span>
</div> </div>
))
.otherwise(() => (
<div className="flex items-center text-center text-red-600">
<Clock8 className="mr-2 h-5 w-5" />
<span className="text-sm">Document no longer available to sign</span>
</div>
))} ))}
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl"> <h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
@ -86,16 +92,22 @@ export default async function CompletedSigningPage({
<span className="mt-1.5 block">"{document.title}"</span> <span className="mt-1.5 block">"{document.title}"</span>
</h2> </h2>
{match(document.status) {match({ status: document.status, deletedAt: document.deletedAt })
.with(DocumentStatus.COMPLETED, () => ( .with({ status: DocumentStatus.COMPLETED }, () => (
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base"> <p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
Everyone has signed! You will receive an Email copy of the signed document. Everyone has signed! You will receive an Email copy of the signed document.
</p> </p>
)) ))
.otherwise(() => ( .with({ deletedAt: null }, () => (
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base"> <p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
You will receive an Email copy of the signed document once everyone has signed. You will receive an Email copy of the signed document once everyone has signed.
</p> </p>
))
.otherwise(() => (
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
This document has been cancelled by the owner and is no longer available for others to
sign.
</p>
))} ))}
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4"> <div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">

View File

@ -7,6 +7,7 @@ import { useRouter } from 'next/navigation';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields'; import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import type { Document, Field, Recipient } from '@documenso/prisma/client'; import type { Document, Field, Recipient } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
@ -29,6 +30,7 @@ export type SigningFormProps = {
export const SigningForm = ({ document, recipient, fields }: SigningFormProps) => { export const SigningForm = ({ document, recipient, fields }: SigningFormProps) => {
const router = useRouter(); const router = useRouter();
const analytics = useAnalytics();
const { data: session } = useSession(); const { data: session } = useSession();
const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext(); const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext();
@ -60,6 +62,12 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
documentId: document.id, documentId: document.id,
}); });
analytics.capture('App: Recipient has completed signing', {
signerId: recipient.id,
documentId: document.id,
timestamp: new Date().toISOString(),
});
router.push(`/sign/${recipient.token}/complete`); router.push(`/sign/${recipient.token}/complete`);
}; };

View File

@ -0,0 +1,66 @@
import React from 'react';
import Link from 'next/link';
import { Clock8 } from 'lucide-react';
import { useSession } from 'next-auth/react';
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
import type { Document, Signature } from '@documenso/prisma/client';
import { SigningCard3D } from '@documenso/ui/components/signing-card';
type NoLongerAvailableProps = {
document: Document;
recipientName: string;
recipientSignature: Signature;
};
export const NoLongerAvailable = ({
document,
recipientName,
recipientSignature,
}: NoLongerAvailableProps) => {
const { data: session } = useSession();
return (
<div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-16 md:-mx-8 md:px-8 lg:pt-16 xl:pt-24">
<SigningCard3D
name={recipientName}
signature={recipientSignature}
signingCelebrationImage={signingCelebration}
/>
<div className="relative mt-2 flex w-full flex-col items-center">
<div className="mt-8 flex items-center text-center text-red-600">
<Clock8 className="mr-2 h-5 w-5" />
<span className="text-sm">Document Cancelled</span>
</div>
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
<span className="mt-1.5 block">"{document.title}"</span>
is no longer available to sign
</h2>
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
This document has been cancelled by the owner.
</p>
{session?.user ? (
<Link href="/documents" className="text-documenso-700 hover:text-documenso-600 mt-36">
Go Back Home
</Link>
) : (
<p className="text-muted-foreground/60 mt-36 text-sm">
Want to send slick signing links like this one?{' '}
<Link
href="https://documenso.com"
className="text-documenso-700 hover:text-documenso-600"
>
Check out Documenso.
</Link>
</p>
)}
</div>
</div>
);
};

View File

@ -8,6 +8,7 @@ import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document'; import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token'; import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client'; import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { ElementVisible } from '@documenso/ui/primitives/element-visible';
@ -17,6 +18,7 @@ import { DateField } from './date-field';
import { EmailField } from './email-field'; import { EmailField } from './email-field';
import { SigningForm } from './form'; import { SigningForm } from './form';
import { NameField } from './name-field'; import { NameField } from './name-field';
import { NoLongerAvailable } from './no-longer-available';
import { SigningProvider } from './provider'; import { SigningProvider } from './provider';
import { SignatureField } from './signature-field'; import { SignatureField } from './signature-field';
@ -55,6 +57,18 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
redirect(`/sign/${token}/complete`); redirect(`/sign/${token}/complete`);
} }
const [recipientSignature] = await getRecipientSignatures({ recipientId: recipient.id });
if (document.deletedAt) {
return (
<NoLongerAvailable
document={document}
recipientName={recipient.name}
recipientSignature={recipientSignature}
/>
);
}
return ( return (
<SigningProvider <SigningProvider
email={recipient.email} email={recipient.email}

View File

@ -4,7 +4,7 @@ import { useCallback, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Monitor, Moon, Sun } from 'lucide-react'; import { Loader, Monitor, Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
@ -12,6 +12,7 @@ import {
DOCUMENTS_PAGE_SHORTCUT, DOCUMENTS_PAGE_SHORTCUT,
SETTINGS_PAGE_SHORTCUT, SETTINGS_PAGE_SHORTCUT,
} from '@documenso/lib/constants/keyboard-shortcuts'; } from '@documenso/lib/constants/keyboard-shortcuts';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { import {
CommandDialog, CommandDialog,
CommandEmpty, CommandEmpty,
@ -29,13 +30,20 @@ const DOCUMENTS_PAGES = [
shortcut: DOCUMENTS_PAGE_SHORTCUT.replace('+', ''), shortcut: DOCUMENTS_PAGE_SHORTCUT.replace('+', ''),
}, },
{ label: 'Draft documents', path: '/documents?status=DRAFT' }, { label: 'Draft documents', path: '/documents?status=DRAFT' },
{ label: 'Completed documents', path: '/documents?status=COMPLETED' }, {
label: 'Completed documents',
path: '/documents?status=COMPLETED',
},
{ label: 'Pending documents', path: '/documents?status=PENDING' }, { label: 'Pending documents', path: '/documents?status=PENDING' },
{ label: 'Inbox documents', path: '/documents?status=INBOX' }, { label: 'Inbox documents', path: '/documents?status=INBOX' },
]; ];
const SETTINGS_PAGES = [ const SETTINGS_PAGES = [
{ label: 'Settings', path: '/settings', shortcut: SETTINGS_PAGE_SHORTCUT.replace('+', '') }, {
label: 'Settings',
path: '/settings',
shortcut: SETTINGS_PAGE_SHORTCUT.replace('+', ''),
},
{ label: 'Profile', path: '/settings/profile' }, { label: 'Profile', path: '/settings/profile' },
{ label: 'Password', path: '/settings/password' }, { label: 'Password', path: '/settings/password' },
]; ];
@ -53,6 +61,29 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [pages, setPages] = useState<string[]>([]); const [pages, setPages] = useState<string[]>([]);
const { data: searchDocumentsData, isLoading: isSearchingDocuments } =
trpcReact.document.searchDocuments.useQuery(
{
query: search,
},
{
keepPreviousData: true,
},
);
const searchResults = useMemo(() => {
if (!searchDocumentsData) {
return [];
}
return searchDocumentsData.map((document) => ({
label: document.title,
path: `/documents/${document.id}`,
value:
document.title + ' ' + document.Recipient.map((recipient) => recipient.email).join(' '),
}));
}, [searchDocumentsData]);
const currentPage = pages[pages.length - 1]; const currentPage = pages[pages.length - 1];
const toggleOpen = () => { const toggleOpen = () => {
@ -113,7 +144,13 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
}; };
return ( return (
<CommandDialog commandProps={{ onKeyDown: handleKeyDown }} open={open} onOpenChange={setOpen}> <CommandDialog
commandProps={{
onKeyDown: handleKeyDown,
}}
open={open}
onOpenChange={setOpen}
>
<CommandInput <CommandInput
value={search} value={search}
onValueChange={setSearch} onValueChange={setSearch}
@ -121,7 +158,17 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
/> />
<CommandList> <CommandList>
<CommandEmpty>No results found.</CommandEmpty> {isSearchingDocuments ? (
<CommandEmpty>
<div className="flex items-center justify-center">
<span className="animate-spin">
<Loader />
</span>
</div>
</CommandEmpty>
) : (
<CommandEmpty>No results found.</CommandEmpty>
)}
{!currentPage && ( {!currentPage && (
<> <>
<CommandGroup heading="Documents"> <CommandGroup heading="Documents">
@ -133,6 +180,11 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
<CommandGroup heading="Preferences"> <CommandGroup heading="Preferences">
<CommandItem onSelect={() => addPage('theme')}>Change theme</CommandItem> <CommandItem onSelect={() => addPage('theme')}>Change theme</CommandItem>
</CommandGroup> </CommandGroup>
{searchResults.length > 0 && (
<CommandGroup heading="Your documents">
<Commands push={push} pages={searchResults} />
</CommandGroup>
)}
</> </>
)} )}
{currentPage === 'theme' && <ThemeCommands setTheme={setTheme} />} {currentPage === 'theme' && <ThemeCommands setTheme={setTheme} />}
@ -146,10 +198,14 @@ const Commands = ({
pages, pages,
}: { }: {
push: (_path: string) => void; push: (_path: string) => void;
pages: { label: string; path: string; shortcut?: string }[]; pages: { label: string; path: string; shortcut?: string; value?: string }[];
}) => { }) => {
return pages.map((page) => ( return pages.map((page, idx) => (
<CommandItem key={page.path} onSelect={() => push(page.path)}> <CommandItem
key={page.path + idx}
value={page.value ?? page.label}
onSelect={() => push(page.path)}
>
{page.label} {page.label}
{page.shortcut && <CommandShortcut>{page.shortcut}</CommandShortcut>} {page.shortcut && <CommandShortcut>{page.shortcut}</CommandShortcut>}
</CommandItem> </CommandItem>

View File

@ -20,7 +20,7 @@ import { LuGithub } from 'react-icons/lu';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin'; import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
import { recipientInitials } from '@documenso/lib/utils/recipient-formatter'; import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
import { User } from '@documenso/prisma/client'; import type { User } from '@documenso/prisma/client';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar'; import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
@ -56,7 +56,11 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-10 w-10 rounded-full"> <Button
variant="ghost"
title="Profile Dropdown"
className="relative h-10 w-10 rounded-full"
>
<Avatar className="h-10 w-10"> <Avatar className="h-10 w-10">
<AvatarFallback>{avatarFallback}</AvatarFallback> <AvatarFallback>{avatarFallback}</AvatarFallback>
</Avatar> </Avatar>

View File

@ -5,6 +5,7 @@ import { signIn } from 'next-auth/react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { TRPCClientError } from '@documenso/trpc/client'; import { TRPCClientError } from '@documenso/trpc/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';
@ -64,6 +65,11 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
password, password,
callbackUrl: '/', callbackUrl: '/',
}); });
analytics.capture('App: User Sign Up', {
email,
timestamp: new Date().toISOString(),
});
} catch (err) { } catch (err) {
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') { if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
toast({ toast({

View File

@ -4,6 +4,11 @@ import { appRouter } from '@documenso/trpc/server/router';
export const config = { export const config = {
maxDuration: 60, maxDuration: 60,
api: {
bodyParser: {
sizeLimit: '50mb',
},
},
}; };
export default trpcNext.createNextApiHandler({ export default trpcNext.createNextApiHandler({

View File

@ -1,3 +1,4 @@
module.exports = { module.exports = {
'**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,mdx}': ['prettier --write'], '**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,mdx}': ['prettier --write'],
'**/*.yml': ['prettier --write'],
}; };

5
package-lock.json generated
View File

@ -29,7 +29,7 @@
}, },
"apps/marketing": { "apps/marketing": {
"name": "@documenso/marketing", "name": "@documenso/marketing",
"version": "1.2.1", "version": "1.2.3",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {
"@documenso/assets": "*", "@documenso/assets": "*",
@ -85,7 +85,7 @@
}, },
"apps/web": { "apps/web": {
"name": "@documenso/web", "name": "@documenso/web",
"version": "1.2.1", "version": "1.2.3",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {
"@documenso/assets": "*", "@documenso/assets": "*",
@ -19227,6 +19227,7 @@
"start-server-and-test": "^2.0.1" "start-server-and-test": "^2.0.1"
}, },
"devDependencies": { "devDependencies": {
"@documenso/prisma": "*",
"@documenso/web": "*", "@documenso/web": "*",
"@playwright/test": "^1.18.1", "@playwright/test": "^1.18.1",
"@types/node": "^20.8.2" "@types/node": "^20.8.2"

View File

@ -0,0 +1,192 @@
import { expect, test } from '@playwright/test';
import { TEST_USERS } from '@documenso/prisma/seed/pr-711-deletion-of-documents';
test.describe.configure({ mode: 'serial' });
test('[PR-711]: seeded documents should be visible', async ({ page }) => {
const [sender, ...recipients] = TEST_USERS;
await page.goto('/signin');
await page.getByLabel('Email').fill(sender.email);
await page.getByLabel('Password', { exact: true }).fill(sender.password);
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('/documents');
await expect(page.getByRole('link', { name: 'Document 1 - Completed' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Document 1 - Draft' })).toBeVisible();
await page.getByTitle('Profile Dropdown').click();
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
await page.waitForURL('/signin');
for (const recipient of recipients) {
await page.goto('/signin');
await page.getByLabel('Email').fill(recipient.email);
await page.getByLabel('Password', { exact: true }).fill(recipient.password);
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('/documents');
await expect(page.getByRole('link', { name: 'Document 1 - Completed' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Document 1 - Draft' })).not.toBeVisible();
await page.getByTitle('Profile Dropdown').click();
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
await page.waitForURL('/signin');
}
});
test('[PR-711]: deleting a completed document should not remove it from recipients', async ({
page,
}) => {
const [sender, ...recipients] = TEST_USERS;
await page.goto('/signin');
// sign in
await page.getByLabel('Email').fill(sender.email);
await page.getByLabel('Password', { exact: true }).fill(sender.password);
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('/documents');
// open actions menu
await page
.locator('tr', { hasText: 'Document 1 - Completed' })
.getByRole('cell', { name: 'Download' })
.getByRole('button')
.nth(1)
.click();
// delete document
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible();
// signout
await page.getByTitle('Profile Dropdown').click();
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
await page.waitForURL('/signin');
for (const recipient of recipients) {
await page.goto('/signin');
// sign in
await page.getByLabel('Email').fill(recipient.email);
await page.getByLabel('Password', { exact: true }).fill(recipient.password);
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('/documents');
await expect(page.getByRole('link', { name: 'Document 1 - Completed' })).toBeVisible();
await page.goto(`/sign/completed-token-${recipients.indexOf(recipient)}`);
await expect(page.getByText('Everyone has signed').nth(0)).toBeVisible();
await page.goto('/documents');
await page.getByTitle('Profile Dropdown').click();
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
await page.waitForURL('/signin');
}
});
test('[PR-711]: deleting a pending document should remove it from recipients', async ({ page }) => {
const [sender, ...recipients] = TEST_USERS;
for (const recipient of recipients) {
await page.goto(`/sign/pending-token-${recipients.indexOf(recipient)}`);
await expect(page.getByText('Waiting for others to sign').nth(0)).toBeVisible();
}
await page.goto('/signin');
// sign in
await page.getByLabel('Email').fill(sender.email);
await page.getByLabel('Password', { exact: true }).fill(sender.password);
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('/documents');
// open actions menu
await page.locator('tr', { hasText: 'Document 1 - Pending' }).getByRole('button').nth(1).click();
// delete document
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible();
// signout
await page.getByTitle('Profile Dropdown').click();
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
await page.waitForURL('/signin');
for (const recipient of recipients) {
await page.goto('/signin');
// sign in
await page.getByLabel('Email').fill(recipient.email);
await page.getByLabel('Password', { exact: true }).fill(recipient.password);
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('/documents');
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).not.toBeVisible();
await page.goto(`/sign/pending-token-${recipients.indexOf(recipient)}`);
await expect(page.getByText(/document.*cancelled/i).nth(0)).toBeVisible();
await page.goto('/documents');
await page.getByTitle('Profile Dropdown').click();
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
await page.waitForURL('/signin');
}
});
test('[PR-711]: deleting a draft document should remove it without additional prompting', async ({
page,
}) => {
const [sender] = TEST_USERS;
await page.goto('/signin');
// sign in
await page.getByLabel('Email').fill(sender.email);
await page.getByLabel('Password', { exact: true }).fill(sender.password);
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('/documents');
// open actions menu
await page
.locator('tr', { hasText: 'Document 1 - Draft' })
.getByRole('cell', { name: 'Edit' })
.getByRole('button')
.click();
// delete document
await page.getByRole('menuitem', { name: 'Delete' }).click();
await expect(page.getByPlaceholder("Type 'delete' to confirm")).not.toBeVisible();
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByRole('row', { name: /Document 1 - Draft/ })).not.toBeVisible();
});

View File

@ -0,0 +1,72 @@
import { expect, test } from '@playwright/test';
import { TEST_USERS } from '@documenso/prisma/seed/pr-713-add-document-search-to-command-menu';
test('[PR-713]: should see sent documents', async ({ page }) => {
const [user] = TEST_USERS;
await page.goto('/signin');
await page.getByLabel('Email').fill(user.email);
await page.getByLabel('Password', { exact: true }).fill(user.password);
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('/documents');
await page.keyboard.press('Meta+K');
await page.getByPlaceholder('Type a command or search...').fill('sent');
await expect(page.getByRole('option', { name: '[713] Document - Sent' })).toBeVisible();
await page.keyboard.press('Escape');
// signout
await page.getByTitle('Profile Dropdown').click();
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
});
test('[PR-713]: should see received documents', async ({ page }) => {
const [user] = TEST_USERS;
await page.goto('/signin');
await page.getByLabel('Email').fill(user.email);
await page.getByLabel('Password', { exact: true }).fill(user.password);
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('/documents');
await page.keyboard.press('Meta+K');
await page.getByPlaceholder('Type a command or search...').fill('received');
await expect(page.getByRole('option', { name: '[713] Document - Received' })).toBeVisible();
await page.keyboard.press('Escape');
// signout
await page.getByTitle('Profile Dropdown').click();
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
});
test('[PR-713]: should be able to search by recipient', async ({ page }) => {
const [user, recipient] = TEST_USERS;
await page.goto('/signin');
await page.getByLabel('Email').fill(user.email);
await page.getByLabel('Password', { exact: true }).fill(user.password);
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('/documents');
await page.keyboard.press('Meta+K');
await page.getByPlaceholder('Type a command or search...').fill(recipient.email);
await expect(page.getByRole('option', { name: '[713] Document - Sent' })).toBeVisible();
await page.keyboard.press('Escape');
// signout
await page.getByTitle('Profile Dropdown').click();
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
});

View File

@ -0,0 +1,75 @@
import { expect, test } from '@playwright/test';
import path from 'node:path';
import { TEST_USER } from '@documenso/prisma/seed/pr-718-add-stepper-component';
test(`[PR-718]: should be able to create a document`, async ({ page }) => {
await page.goto('/signin');
const documentTitle = `example-${Date.now()}.pdf`;
// Sign in
await page.getByLabel('Email').fill(TEST_USER.email);
await page.getByLabel('Password', { exact: true }).fill(TEST_USER.password);
await page.getByRole('button', { name: 'Sign In' }).click();
// Upload document
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
page.locator('input[type=file]').evaluate((e) => {
if (e instanceof HTMLInputElement) {
e.click();
}
}),
]);
await fileChooser.setFiles(path.join(__dirname, '../../../assets/example.pdf'));
// Wait to be redirected to the edit page
await page.waitForURL(/\/documents\/\d+/);
// Set title
await expect(page.getByRole('heading', { name: 'Add Title' })).toBeVisible();
await page.getByLabel('Title').fill(documentTitle);
await page.getByRole('button', { name: 'Continue' }).click();
// Add signers
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
await page.getByLabel('Email*').fill('user1@example.com');
await page.getByLabel('Name').fill('User 1');
await page.getByRole('button', { name: 'Continue' }).click();
// Add fields
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'User 1 Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('button', { name: 'Email Email' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 200,
},
});
await page.getByRole('button', { name: 'Continue' }).click();
// Add subject and send
await expect(page.getByRole('heading', { name: 'Add Subject' })).toBeVisible();
await page.getByRole('button', { name: 'Send' }).click();
await page.waitForURL('/documents');
// Assert document was created
await expect(page.getByRole('link', { name: documentTitle })).toBeVisible();
});

View File

@ -10,9 +10,9 @@ test.use({ storageState: { cookies: [], origins: [] } });
*/ */
test.describe.configure({ mode: 'serial' }); test.describe.configure({ mode: 'serial' });
const username = process.env.E2E_TEST_AUTHENTICATE_USERNAME; const username = 'Test User';
const email = process.env.E2E_TEST_AUTHENTICATE_USER_EMAIL; const email = 'test-user@auth-flow.documenso.com';
const password = process.env.E2E_TEST_AUTHENTICATE_USER_PASSWORD; const password = 'Password123';
test('user can sign up with email and password', async ({ page }: { page: Page }) => { test('user can sign up with email and password', async ({ page }: { page: Page }) => {
await page.goto('/signup'); await page.goto('/signup');

View File

@ -6,13 +6,14 @@
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"test:dev": "playwright test", "test:dev": "playwright test",
"test:e2e": "start-server-and-test \"(cd ../../apps/web && npm run start)\" http://localhost:3000 \"playwright test\"" "test:e2e": "start-server-and-test \"npm run start -w @documenso/web\" http://localhost:3000 \"playwright test\""
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.18.1", "@playwright/test": "^1.18.1",
"@types/node": "^20.8.2", "@types/node": "^20.8.2",
"@documenso/prisma": "*",
"@documenso/web": "*" "@documenso/web": "*"
}, },
"dependencies": { "dependencies": {

View File

@ -28,8 +28,12 @@ export default defineConfig({
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry', trace: 'on-first-retry',
video: 'retain-on-failure',
}, },
timeout: 30_000,
/* Configure projects for major browsers */ /* Configure projects for major browsers */
projects: [ projects: [
{ {

View File

@ -0,0 +1,8 @@
{
"extends": "@documenso/tsconfig/react-library.json",
"compilerOptions": {
"types": ["@documenso/tsconfig/process-env.d.ts"]
},
"include": ["**/*.ts", "**/*.tsx", "**/*.d.ts"],
"exclude": ["dist", "build", "node_modules"]
}

View File

@ -1,9 +1,10 @@
import { NextApiRequest, NextApiResponse } from 'next'; import type { NextApiRequest, NextApiResponse } from 'next';
import { buffer } from 'micro'; import { buffer } from 'micro';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { Stripe, stripe } from '@documenso/lib/server-only/stripe'; import type { Stripe } from '@documenso/lib/server-only/stripe';
import { stripe } from '@documenso/lib/server-only/stripe';
import { getFlag } from '@documenso/lib/universal/get-feature-flag'; import { getFlag } from '@documenso/lib/universal/get-feature-flag';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
@ -174,6 +175,13 @@ export const stripeWebhookHandler = async (
const subscription = await stripe.subscriptions.retrieve(subscriptionId); const subscription = await stripe.subscriptions.retrieve(subscriptionId);
if (subscription.status === 'incomplete_expired') {
return res.status(200).json({
success: true,
message: 'Webhook received',
});
}
const result = await prisma.subscription.findFirst({ const result = await prisma.subscription.findFirst({
select: { select: {
userId: true, userId: true,
@ -218,6 +226,13 @@ export const stripeWebhookHandler = async (
const subscription = await stripe.subscriptions.retrieve(subscriptionId); const subscription = await stripe.subscriptions.retrieve(subscriptionId);
if (subscription.status === 'incomplete_expired') {
return res.status(200).json({
success: true,
message: 'Webhook received',
});
}
const result = await prisma.subscription.findFirst({ const result = await prisma.subscription.findFirst({
select: { select: {
userId: true, userId: true,

View File

@ -1,4 +1,4 @@
import Stripe from 'stripe'; import type Stripe from 'stripe';
import { hashSync } from '@documenso/lib/server-only/auth/hash'; import { hashSync } from '@documenso/lib/server-only/auth/hash';
import { sealDocument } from '@documenso/lib/server-only/document/seal-document'; import { sealDocument } from '@documenso/lib/server-only/document/seal-document';

View File

@ -0,0 +1,34 @@
import { Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image';
export interface TemplateDocumentCancelProps {
inviterName: string;
inviterEmail: string;
documentName: string;
assetBaseUrl: string;
}
export const TemplateDocumentCancel = ({
inviterName,
documentName,
assetBaseUrl,
}: TemplateDocumentCancelProps) => {
return (
<>
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section>
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
{inviterName} has cancelled the document
<br />"{documentName}"
</Text>
<Text className="my-1 text-center text-base text-slate-400">
You don't need to sign it anymore.
</Text>
</Section>
</>
);
};
export default TemplateDocumentCancel;

View File

@ -0,0 +1,66 @@
import config from '@documenso/tailwind-config';
import { Body, Container, Head, Hr, Html, Img, Preview, Section, Tailwind } from '../components';
import type { TemplateDocumentCancelProps } from '../template-components/template-document-cancel';
import { TemplateDocumentCancel } from '../template-components/template-document-cancel';
import { TemplateFooter } from '../template-components/template-footer';
export type DocumentCancelEmailTemplateProps = Partial<TemplateDocumentCancelProps>;
export const DocumentCancelTemplate = ({
inviterName = 'Lucas Smith',
inviterEmail = 'lucas@documenso.com',
documentName = 'Open Source Pledge.pdf',
assetBaseUrl = 'http://localhost:3002',
}: DocumentCancelEmailTemplateProps) => {
const previewText = `${inviterName} has cancelled the document ${documentName}, you don't need to sign it anymore.`;
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind
config={{
theme: {
extend: {
colors: config.theme.extend.colors,
},
},
}}
>
<Body className="mx-auto my-auto bg-white font-sans">
<Section>
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
<Section>
<Img
src={getAssetUrl('/static/logo.png')}
alt="Documenso Logo"
className="mb-4 h-6"
/>
<TemplateDocumentCancel
inviterName={inviterName}
inviterEmail={inviterEmail}
documentName={documentName}
assetBaseUrl={assetBaseUrl}
/>
</Section>
</Container>
<Hr className="mx-auto mt-12 max-w-xl" />
<Container className="mx-auto max-w-xl">
<TemplateFooter />
</Container>
</Section>
</Body>
</Tailwind>
</Html>
);
};
export default DocumentCancelTemplate;

View File

@ -0,0 +1,88 @@
'use server';
import { createElement } from 'react';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@documenso/prisma/client';
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
export type DeleteDocumentOptions = {
id: number;
userId: number;
status: DocumentStatus;
};
export const deleteDocument = async ({ id, userId, status }: DeleteDocumentOptions) => {
// if the document is a draft, hard-delete
if (status === DocumentStatus.DRAFT) {
return await prisma.document.delete({ where: { id, userId, status: DocumentStatus.DRAFT } });
}
// if the document is pending, send cancellation emails to all recipients
if (status === DocumentStatus.PENDING) {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
const document = await prisma.document.findUnique({
where: {
id,
status,
userId,
},
include: {
Recipient: true,
documentMeta: true,
},
});
if (!document) {
throw new Error('Document not found');
}
if (document.Recipient.length > 0) {
await Promise.all(
document.Recipient.map(async (recipient) => {
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
const template = createElement(DocumentCancelTemplate, {
documentName: document.title,
inviterName: user.name || undefined,
inviterEmail: user.email,
assetBaseUrl,
});
await mailer.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: 'Document Cancelled',
html: render(template),
text: render(template, { plainText: true }),
});
}),
);
}
}
// If the document is not a draft, only soft-delete.
return await prisma.document.update({
where: {
id,
},
data: {
deletedAt: new Date().toISOString(),
},
});
};

View File

@ -1,13 +0,0 @@
'use server';
import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@documenso/prisma/client';
export type DeleteDraftDocumentOptions = {
id: number;
userId: number;
};
export const deleteDraftDocument = async ({ id, userId }: DeleteDraftDocumentOptions) => {
return await prisma.document.delete({ where: { id, userId, status: DocumentStatus.DRAFT } });
};

View File

@ -55,17 +55,25 @@ export const findDocuments = async ({
OR: [ OR: [
{ {
userId, userId,
deletedAt: null,
}, },
{ {
status: { status: ExtendedDocumentStatus.COMPLETED,
not: ExtendedDocumentStatus.DRAFT,
},
Recipient: { Recipient: {
some: { some: {
email: user.email, email: user.email,
}, },
}, },
}, },
{
status: ExtendedDocumentStatus.PENDING,
Recipient: {
some: {
email: user.email,
},
},
deletedAt: null,
},
], ],
})) }))
.with(ExtendedDocumentStatus.INBOX, () => ({ .with(ExtendedDocumentStatus.INBOX, () => ({
@ -78,26 +86,29 @@ export const findDocuments = async ({
signingStatus: SigningStatus.NOT_SIGNED, signingStatus: SigningStatus.NOT_SIGNED,
}, },
}, },
deletedAt: null,
})) }))
.with(ExtendedDocumentStatus.DRAFT, () => ({ .with(ExtendedDocumentStatus.DRAFT, () => ({
userId, userId,
status: ExtendedDocumentStatus.DRAFT, status: ExtendedDocumentStatus.DRAFT,
deletedAt: null,
})) }))
.with(ExtendedDocumentStatus.PENDING, () => ({ .with(ExtendedDocumentStatus.PENDING, () => ({
OR: [ OR: [
{ {
userId, userId,
status: ExtendedDocumentStatus.PENDING, status: ExtendedDocumentStatus.PENDING,
deletedAt: null,
}, },
{ {
status: ExtendedDocumentStatus.PENDING, status: ExtendedDocumentStatus.PENDING,
Recipient: { Recipient: {
some: { some: {
email: user.email, email: user.email,
signingStatus: SigningStatus.SIGNED, signingStatus: SigningStatus.SIGNED,
}, },
}, },
deletedAt: null,
}, },
], ],
})) }))
@ -106,6 +117,7 @@ export const findDocuments = async ({
{ {
userId, userId,
status: ExtendedDocumentStatus.COMPLETED, status: ExtendedDocumentStatus.COMPLETED,
deletedAt: null,
}, },
{ {
status: ExtendedDocumentStatus.COMPLETED, status: ExtendedDocumentStatus.COMPLETED,

View File

@ -1,5 +1,6 @@
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { SigningStatus, User } from '@documenso/prisma/client'; import type { User } from '@documenso/prisma/client';
import { SigningStatus } from '@documenso/prisma/client';
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status'; import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
@ -16,6 +17,7 @@ export const getStats = async ({ user }: GetStatsInput) => {
}, },
where: { where: {
userId: user.id, userId: user.id,
deletedAt: null,
}, },
}), }),
prisma.document.groupBy({ prisma.document.groupBy({
@ -31,6 +33,7 @@ export const getStats = async ({ user }: GetStatsInput) => {
signingStatus: SigningStatus.NOT_SIGNED, signingStatus: SigningStatus.NOT_SIGNED,
}, },
}, },
deletedAt: null,
}, },
}), }),
prisma.document.groupBy({ prisma.document.groupBy({
@ -39,15 +42,27 @@ export const getStats = async ({ user }: GetStatsInput) => {
_all: true, _all: true,
}, },
where: { where: {
status: { OR: [
not: ExtendedDocumentStatus.DRAFT, {
}, status: ExtendedDocumentStatus.PENDING,
Recipient: { Recipient: {
some: { some: {
email: user.email, email: user.email,
signingStatus: SigningStatus.SIGNED, signingStatus: SigningStatus.SIGNED,
},
},
deletedAt: null,
}, },
}, {
status: ExtendedDocumentStatus.COMPLETED,
Recipient: {
some: {
email: user.email,
signingStatus: SigningStatus.SIGNED,
},
},
},
],
}, },
}), }),
]); ]);

View File

@ -1,8 +1,10 @@
'use server'; 'use server';
import { nanoid } from 'nanoid';
import path from 'node:path'; import path from 'node:path';
import { PDFDocument } from 'pdf-lib'; import { PDFDocument } from 'pdf-lib';
import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client'; import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import { signPdf } from '@documenso/signing'; import { signPdf } from '@documenso/signing';
@ -83,6 +85,18 @@ export const sealDocument = async ({ documentId, sendEmail = true }: SealDocumen
arrayBuffer: async () => Promise.resolve(pdfBuffer), arrayBuffer: async () => Promise.resolve(pdfBuffer),
}); });
const postHog = PostHogServerClient();
if (postHog) {
postHog.capture({
distinctId: nanoid(),
event: 'App: Document Sealed',
properties: {
documentId: document.id,
},
});
}
await prisma.documentData.update({ await prisma.documentData.update({
where: { where: {
id: documentData.id, id: documentData.id,

View File

@ -0,0 +1,81 @@
import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@documenso/prisma/client';
export type SearchDocumentsWithKeywordOptions = {
query: string;
userId: number;
limit?: number;
};
export const searchDocumentsWithKeyword = async ({
query,
userId,
limit = 5,
}: SearchDocumentsWithKeywordOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
const documents = await prisma.document.findMany({
where: {
OR: [
{
title: {
contains: query,
mode: 'insensitive',
},
userId: userId,
deletedAt: null,
},
{
Recipient: {
some: {
email: {
contains: query,
mode: 'insensitive',
},
},
},
userId: userId,
deletedAt: null,
},
{
status: DocumentStatus.COMPLETED,
Recipient: {
some: {
email: user.email,
},
},
title: {
contains: query,
mode: 'insensitive',
},
},
{
status: DocumentStatus.PENDING,
Recipient: {
some: {
email: user.email,
},
},
title: {
contains: query,
mode: 'insensitive',
},
deletedAt: null,
},
],
},
include: {
Recipient: true,
},
orderBy: {
createdAt: 'desc',
},
take: limit,
});
return documents;
};

View File

@ -37,6 +37,10 @@ export const signFieldWithToken = async ({
throw new Error(`Document ${document.id} has already been completed`); throw new Error(`Document ${document.id} has already been completed`);
} }
if (document.deletedAt) {
throw new Error(`Document ${document.id} has been deleted`);
}
if (recipient?.signingStatus === SigningStatus.SIGNED) { if (recipient?.signingStatus === SigningStatus.SIGNED) {
throw new Error(`Recipient ${recipient.id} has already signed`); throw new Error(`Recipient ${recipient.id} has already signed`);
} }

View File

@ -1,9 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { import type { TFeatureFlagValue } from '@documenso/lib/client-only/providers/feature-flag.types';
TFeatureFlagValue, import { ZFeatureFlagValueSchema } from '@documenso/lib/client-only/providers/feature-flag.types';
ZFeatureFlagValueSchema,
} from '@documenso/lib/client-only/providers/feature-flag.types';
import { APP_BASE_URL } from '@documenso/lib/constants/app'; import { APP_BASE_URL } from '@documenso/lib/constants/app';
import { LOCAL_FEATURE_FLAGS, isFeatureFlagEnabled } from '@documenso/lib/constants/feature-flags'; import { LOCAL_FEATURE_FLAGS, isFeatureFlagEnabled } from '@documenso/lib/constants/feature-flags';

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Document" ADD COLUMN "deletedAt" TIMESTAMP(3);

View File

@ -0,0 +1,5 @@
-- DropForeignKey
ALTER TABLE "VerificationToken" DROP CONSTRAINT "VerificationToken_userId_fkey";
-- AddForeignKey
ALTER TABLE "VerificationToken" ADD CONSTRAINT "VerificationToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -60,7 +60,7 @@ model VerificationToken {
expires DateTime expires DateTime
createdAt DateTime @default(now()) createdAt DateTime @default(now())
userId Int userId Int
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
} }
enum SubscriptionStatus { enum SubscriptionStatus {
@ -135,6 +135,7 @@ model Document {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt updatedAt DateTime @default(now()) @updatedAt
completedAt DateTime? completedAt DateTime?
deletedAt DateTime?
@@unique([documentDataId]) @@unique([documentDataId])
@@index([userId]) @@index([userId])

View File

@ -1,74 +1,22 @@
import { DocumentDataType, Role } from '@prisma/client';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { hashSync } from '@documenso/lib/server-only/auth/hash';
import { prisma } from './index';
const seedDatabase = async () => { const seedDatabase = async () => {
const examplePdf = fs const files = fs.readdirSync(path.join(__dirname, './seed'));
.readFileSync(path.join(__dirname, '../../assets/example.pdf'))
.toString('base64');
const exampleUser = await prisma.user.upsert({ for (const file of files) {
where: { const stat = fs.statSync(path.join(__dirname, './seed', file));
email: 'example@documenso.com',
},
create: {
name: 'Example User',
email: 'example@documenso.com',
password: hashSync('password'),
roles: [Role.USER],
},
update: {},
});
const adminUser = await prisma.user.upsert({ if (stat.isFile()) {
where: { // eslint-disable-next-line @typescript-eslint/no-var-requires
email: 'admin@documenso.com', const mod = require(path.join(__dirname, './seed', file));
},
create: {
name: 'Admin User',
email: 'admin@documenso.com',
password: hashSync('password'),
roles: [Role.USER, Role.ADMIN],
},
update: {},
});
const examplePdfData = await prisma.documentData.upsert({ if ('seedDatabase' in mod && typeof mod.seedDatabase === 'function') {
where: { console.log(`[SEEDING]: ${file}`);
id: 'clmn0kv5k0000pe04vcqg5zla', await mod.seedDatabase();
}, }
create: { }
id: 'clmn0kv5k0000pe04vcqg5zla', }
type: DocumentDataType.BYTES_64,
data: examplePdf,
initialData: examplePdf,
},
update: {},
});
await prisma.document.upsert({
where: {
id: 1,
},
create: {
id: 1,
title: 'Example Document',
documentDataId: examplePdfData.id,
userId: exampleUser.id,
Recipient: {
create: {
name: String(adminUser.name),
email: adminUser.email,
token: Math.random().toString(36).slice(2, 9),
},
},
},
update: {},
});
}; };
seedDatabase() seedDatabase()

View File

@ -0,0 +1,67 @@
import fs from 'node:fs';
import path from 'node:path';
import { hashSync } from '@documenso/lib/server-only/auth/hash';
import { prisma } from '..';
import { DocumentDataType, Role } from '../client';
export const seedDatabase = async () => {
const examplePdf = fs
.readFileSync(path.join(__dirname, '../../../assets/example.pdf'))
.toString('base64');
const exampleUser = await prisma.user.upsert({
where: {
email: 'example@documenso.com',
},
create: {
name: 'Example User',
email: 'example@documenso.com',
password: hashSync('password'),
roles: [Role.USER],
},
update: {},
});
const adminUser = await prisma.user.upsert({
where: {
email: 'admin@documenso.com',
},
create: {
name: 'Admin User',
email: 'admin@documenso.com',
password: hashSync('password'),
roles: [Role.USER, Role.ADMIN],
},
update: {},
});
const examplePdfData = await prisma.documentData.upsert({
where: {
id: 'clmn0kv5k0000pe04vcqg5zla',
},
create: {
id: 'clmn0kv5k0000pe04vcqg5zla',
type: DocumentDataType.BYTES_64,
data: examplePdf,
initialData: examplePdf,
},
update: {},
});
await prisma.document.create({
data: {
title: 'Example Document',
documentDataId: examplePdfData.id,
userId: exampleUser.id,
Recipient: {
create: {
name: String(adminUser.name),
email: adminUser.email,
token: Math.random().toString(36).slice(2, 9),
},
},
},
});
};

View File

@ -0,0 +1,221 @@
import type { User } from '@prisma/client';
import fs from 'node:fs';
import path from 'node:path';
import { hashSync } from '@documenso/lib/server-only/auth/hash';
import { prisma } from '..';
import {
DocumentDataType,
DocumentStatus,
FieldType,
Prisma,
ReadStatus,
SendStatus,
SigningStatus,
} from '../client';
const PULL_REQUEST_NUMBER = 711;
const EMAIL_DOMAIN = `pr-${PULL_REQUEST_NUMBER}.documenso.com`;
export const TEST_USERS = [
{
name: 'Sender 1',
email: `sender1@${EMAIL_DOMAIN}`,
password: 'Password123',
},
{
name: 'Sender 2',
email: `sender2@${EMAIL_DOMAIN}`,
password: 'Password123',
},
{
name: 'Sender 3',
email: `sender3@${EMAIL_DOMAIN}`,
password: 'Password123',
},
] as const;
const examplePdf = fs
.readFileSync(path.join(__dirname, '../../../assets/example.pdf'))
.toString('base64');
export const seedDatabase = async () => {
const users = await Promise.all(
TEST_USERS.map(async (u) =>
prisma.user.create({
data: {
name: u.name,
email: u.email,
password: hashSync(u.password),
emailVerified: new Date(),
},
}),
),
);
const [user1, user2, user3] = users;
await createDraftDocument(user1, [user2, user3]);
await createPendingDocument(user1, [user2, user3]);
await createCompletedDocument(user1, [user2, user3]);
};
const createDraftDocument = async (sender: User, recipients: User[]) => {
const documentData = await prisma.documentData.create({
data: {
type: DocumentDataType.BYTES_64,
data: examplePdf,
initialData: examplePdf,
},
});
const document = await prisma.document.create({
data: {
title: `[${PULL_REQUEST_NUMBER}] Document 1 - Draft`,
status: DocumentStatus.DRAFT,
documentDataId: documentData.id,
userId: sender.id,
},
});
for (const recipient of recipients) {
const index = recipients.indexOf(recipient);
await prisma.recipient.create({
data: {
email: String(recipient.email),
name: String(recipient.name),
token: `draft-token-${index}`,
readStatus: ReadStatus.NOT_OPENED,
sendStatus: SendStatus.NOT_SENT,
signingStatus: SigningStatus.NOT_SIGNED,
signedAt: new Date(),
Document: {
connect: {
id: document.id,
},
},
Field: {
create: {
page: 1,
type: FieldType.NAME,
inserted: true,
customText: String(recipient.name),
positionX: new Prisma.Decimal(1),
positionY: new Prisma.Decimal(1),
width: new Prisma.Decimal(1),
height: new Prisma.Decimal(1),
documentId: document.id,
},
},
},
});
}
};
const createPendingDocument = async (sender: User, recipients: User[]) => {
const documentData = await prisma.documentData.create({
data: {
type: DocumentDataType.BYTES_64,
data: examplePdf,
initialData: examplePdf,
},
});
const document = await prisma.document.create({
data: {
title: `[${PULL_REQUEST_NUMBER}] Document 1 - Pending`,
status: DocumentStatus.PENDING,
documentDataId: documentData.id,
userId: sender.id,
},
});
for (const recipient of recipients) {
const index = recipients.indexOf(recipient);
await prisma.recipient.create({
data: {
email: String(recipient.email),
name: String(recipient.name),
token: `pending-token-${index}`,
readStatus: ReadStatus.OPENED,
sendStatus: SendStatus.SENT,
signingStatus: SigningStatus.SIGNED,
signedAt: new Date(),
Document: {
connect: {
id: document.id,
},
},
Field: {
create: {
page: 1,
type: FieldType.NAME,
inserted: true,
customText: String(recipient.name),
positionX: new Prisma.Decimal(1),
positionY: new Prisma.Decimal(1),
width: new Prisma.Decimal(1),
height: new Prisma.Decimal(1),
documentId: document.id,
},
},
},
});
}
};
const createCompletedDocument = async (sender: User, recipients: User[]) => {
const documentData = await prisma.documentData.create({
data: {
type: DocumentDataType.BYTES_64,
data: examplePdf,
initialData: examplePdf,
},
});
const document = await prisma.document.create({
data: {
title: `[${PULL_REQUEST_NUMBER}] Document 1 - Completed`,
status: DocumentStatus.COMPLETED,
documentDataId: documentData.id,
userId: sender.id,
},
});
for (const recipient of recipients) {
const index = recipients.indexOf(recipient);
await prisma.recipient.create({
data: {
email: String(recipient.email),
name: String(recipient.name),
token: `completed-token-${index}`,
readStatus: ReadStatus.OPENED,
sendStatus: SendStatus.SENT,
signingStatus: SigningStatus.SIGNED,
signedAt: new Date(),
Document: {
connect: {
id: document.id,
},
},
Field: {
create: {
page: 1,
type: FieldType.NAME,
inserted: true,
customText: String(recipient.name),
positionX: new Prisma.Decimal(1),
positionY: new Prisma.Decimal(1),
width: new Prisma.Decimal(1),
height: new Prisma.Decimal(1),
documentId: document.id,
},
},
},
});
}
};

View File

@ -0,0 +1,167 @@
import type { User } from '@prisma/client';
import fs from 'node:fs';
import path from 'node:path';
import { hashSync } from '@documenso/lib/server-only/auth/hash';
import { prisma } from '..';
import {
DocumentDataType,
DocumentStatus,
FieldType,
Prisma,
ReadStatus,
SendStatus,
SigningStatus,
} from '../client';
//
// https://github.com/documenso/documenso/pull/713
//
const PULL_REQUEST_NUMBER = 713;
const EMAIL_DOMAIN = `pr-${PULL_REQUEST_NUMBER}.documenso.com`;
export const TEST_USERS = [
{
name: 'User 1',
email: `user1@${EMAIL_DOMAIN}`,
password: 'Password123',
},
{
name: 'User 2',
email: `user2@${EMAIL_DOMAIN}`,
password: 'Password123',
},
] as const;
const examplePdf = fs
.readFileSync(path.join(__dirname, '../../../assets/example.pdf'))
.toString('base64');
export const seedDatabase = async () => {
const users = await Promise.all(
TEST_USERS.map(async (u) =>
prisma.user.create({
data: {
name: u.name,
email: u.email,
password: hashSync(u.password),
emailVerified: new Date(),
},
}),
),
);
const [user1, user2] = users;
await createSentDocument(user1, [user2]);
await createReceivedDocument(user2, [user1]);
};
const createSentDocument = async (sender: User, recipients: User[]) => {
const documentData = await prisma.documentData.create({
data: {
type: DocumentDataType.BYTES_64,
data: examplePdf,
initialData: examplePdf,
},
});
const document = await prisma.document.create({
data: {
title: `[${PULL_REQUEST_NUMBER}] Document - Sent`,
status: DocumentStatus.PENDING,
documentDataId: documentData.id,
userId: sender.id,
},
});
for (const recipient of recipients) {
const index = recipients.indexOf(recipient);
await prisma.recipient.create({
data: {
email: String(recipient.email),
name: String(recipient.name),
token: `sent-token-${index}`,
readStatus: ReadStatus.NOT_OPENED,
sendStatus: SendStatus.SENT,
signingStatus: SigningStatus.NOT_SIGNED,
signedAt: new Date(),
Document: {
connect: {
id: document.id,
},
},
Field: {
create: {
page: 1,
type: FieldType.NAME,
inserted: true,
customText: String(recipient.name),
positionX: new Prisma.Decimal(1),
positionY: new Prisma.Decimal(1),
width: new Prisma.Decimal(1),
height: new Prisma.Decimal(1),
documentId: document.id,
},
},
},
});
}
};
const createReceivedDocument = async (sender: User, recipients: User[]) => {
const documentData = await prisma.documentData.create({
data: {
type: DocumentDataType.BYTES_64,
data: examplePdf,
initialData: examplePdf,
},
});
const document = await prisma.document.create({
data: {
title: `[${PULL_REQUEST_NUMBER}] Document - Received`,
status: DocumentStatus.PENDING,
documentDataId: documentData.id,
userId: sender.id,
},
});
for (const recipient of recipients) {
const index = recipients.indexOf(recipient);
await prisma.recipient.create({
data: {
email: String(recipient.email),
name: String(recipient.name),
token: `received-token-${index}`,
readStatus: ReadStatus.NOT_OPENED,
sendStatus: SendStatus.SENT,
signingStatus: SigningStatus.NOT_SIGNED,
signedAt: new Date(),
Document: {
connect: {
id: document.id,
},
},
Field: {
create: {
page: 1,
type: FieldType.NAME,
inserted: true,
customText: String(recipient.name),
positionX: new Prisma.Decimal(1),
positionY: new Prisma.Decimal(1),
width: new Prisma.Decimal(1),
height: new Prisma.Decimal(1),
documentId: document.id,
},
},
},
});
}
};

View File

@ -0,0 +1,28 @@
import { hashSync } from '@documenso/lib/server-only/auth/hash';
import { prisma } from '..';
//
// https://github.com/documenso/documenso/pull/713
//
const PULL_REQUEST_NUMBER = 718;
const EMAIL_DOMAIN = `pr-${PULL_REQUEST_NUMBER}.documenso.com`;
export const TEST_USER = {
name: 'User 1',
email: `user1@${EMAIL_DOMAIN}`,
password: 'Password123',
} as const;
export const seedDatabase = async () => {
await prisma.user.create({
data: {
name: TEST_USER.name,
email: TEST_USER.email,
password: hashSync(TEST_USER.password),
emailVerified: new Date(),
},
});
};

View File

@ -3,11 +3,12 @@ import { TRPCError } from '@trpc/server';
import { getServerLimits } from '@documenso/ee/server-only/limits/server'; import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta'; import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
import { createDocument } from '@documenso/lib/server-only/document/create-document'; import { createDocument } from '@documenso/lib/server-only/document/create-document';
import { deleteDraftDocument } from '@documenso/lib/server-only/document/delete-draft-document'; import { deleteDocument } from '@documenso/lib/server-only/document/delete-document';
import { duplicateDocumentById } from '@documenso/lib/server-only/document/duplicate-document-by-id'; import { duplicateDocumentById } from '@documenso/lib/server-only/document/duplicate-document-by-id';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token'; import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { resendDocument } from '@documenso/lib/server-only/document/resend-document'; import { resendDocument } from '@documenso/lib/server-only/document/resend-document';
import { searchDocumentsWithKeyword } from '@documenso/lib/server-only/document/search-documents-with-keyword';
import { sendDocument } from '@documenso/lib/server-only/document/send-document'; import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { updateTitle } from '@documenso/lib/server-only/document/update-title'; import { updateTitle } from '@documenso/lib/server-only/document/update-title';
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document'; import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
@ -20,6 +21,7 @@ import {
ZGetDocumentByIdQuerySchema, ZGetDocumentByIdQuerySchema,
ZGetDocumentByTokenQuerySchema, ZGetDocumentByTokenQuerySchema,
ZResendDocumentMutationSchema, ZResendDocumentMutationSchema,
ZSearchDocumentsMutationSchema,
ZSendDocumentMutationSchema, ZSendDocumentMutationSchema,
ZSetFieldsForDocumentMutationSchema, ZSetFieldsForDocumentMutationSchema,
ZSetRecipientsForDocumentMutationSchema, ZSetRecipientsForDocumentMutationSchema,
@ -97,15 +99,15 @@ export const documentRouter = router({
} }
}), }),
deleteDraftDocument: authenticatedProcedure deleteDocument: authenticatedProcedure
.input(ZDeleteDraftDocumentMutationSchema) .input(ZDeleteDraftDocumentMutationSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
const { id } = input; const { id, status } = input;
const userId = ctx.user.id; const userId = ctx.user.id;
return await deleteDraftDocument({ id, userId }); return await deleteDocument({ id, userId, status });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@ -240,4 +242,23 @@ export const documentRouter = router({
}); });
} }
}), }),
searchDocuments: authenticatedProcedure
.input(ZSearchDocumentsMutationSchema)
.query(async ({ input, ctx }) => {
const { query } = input;
try {
const documents = await searchDocumentsWithKeyword({
query,
userId: ctx.user.id,
});
return documents;
} catch (error) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We are unable to search for documents. Please try again later.',
});
}
}),
}); });

View File

@ -1,6 +1,6 @@
import { z } from 'zod'; import { z } from 'zod';
import { FieldType } from '@documenso/prisma/client'; import { DocumentStatus, FieldType } from '@documenso/prisma/client';
export const ZGetDocumentByIdQuerySchema = z.object({ export const ZGetDocumentByIdQuerySchema = z.object({
id: z.number().min(1), id: z.number().min(1),
@ -80,6 +80,11 @@ export type TSendDocumentMutationSchema = z.infer<typeof ZSendDocumentMutationSc
export const ZDeleteDraftDocumentMutationSchema = z.object({ export const ZDeleteDraftDocumentMutationSchema = z.object({
id: z.number().min(1), id: z.number().min(1),
status: z.nativeEnum(DocumentStatus),
}); });
export type TDeleteDraftDocumentMutationSchema = z.infer<typeof ZDeleteDraftDocumentMutationSchema>; export type TDeleteDraftDocumentMutationSchema = z.infer<typeof ZDeleteDraftDocumentMutationSchema>;
export const ZSearchDocumentsMutationSchema = z.object({
query: z.string(),
});

View File

@ -7,8 +7,9 @@ import { Download } from 'lucide-react';
import { getFile } from '@documenso/lib/universal/upload/get-file'; import { getFile } from '@documenso/lib/universal/upload/get-file';
import type { DocumentData } from '@documenso/prisma/client'; import type { DocumentData } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { Button } from '../../primitives/button';
import { useToast } from '../../primitives/use-toast';
export type DownloadButtonProps = HTMLAttributes<HTMLButtonElement> & { export type DownloadButtonProps = HTMLAttributes<HTMLButtonElement> & {
disabled?: boolean; disabled?: boolean;

View File

@ -13,8 +13,9 @@ import {
} from '@documenso/lib/constants/toast'; } from '@documenso/lib/constants/toast';
import { generateTwitterIntent } from '@documenso/lib/universal/generate-twitter-intent'; import { generateTwitterIntent } from '@documenso/lib/universal/generate-twitter-intent';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { cn } from '../../lib/utils';
import { Button } from '../../primitives/button';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -22,8 +23,8 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from '@documenso/ui/primitives/dialog'; } from '../../primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '../../primitives/use-toast';
export type DocumentShareButtonProps = HTMLAttributes<HTMLButtonElement> & { export type DocumentShareButtonProps = HTMLAttributes<HTMLButtonElement> & {
token?: string; token?: string;

View File

@ -1,17 +1,18 @@
import { TooltipArrow } from '@radix-ui/react-tooltip'; import { TooltipArrow } from '@radix-ui/react-tooltip';
import { VariantProps, cva } from 'class-variance-authority'; import type { VariantProps } from 'class-variance-authority';
import { cva } from 'class-variance-authority';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords'; import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords';
import { cn } from '@documenso/ui/lib/utils';
import { cn } from '../..//lib/utils';
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from '@documenso/ui/primitives/tooltip'; } from '../..//primitives/tooltip';
import type { Field } from '.prisma/client';
import { Field } from '.prisma/client';
const tooltipVariants = cva('font-semibold', { const tooltipVariants = cva('font-semibold', {
variants: { variants: {

View File

@ -5,9 +5,10 @@ import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords'; import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords';
import { Field } from '@documenso/prisma/client'; import type { Field } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { cn } from '../../lib/utils';
import { Card, CardContent } from '../../primitives/card';
export type FieldRootContainerProps = { export type FieldRootContainerProps = {
field: Field; field: Field;

View File

@ -2,14 +2,16 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import Image, { StaticImageData } from 'next/image'; import type { StaticImageData } from 'next/image';
import Image from 'next/image';
import { animate, motion, useMotionTemplate, useMotionValue, useTransform } from 'framer-motion'; import { animate, motion, useMotionTemplate, useMotionValue, useTransform } from 'framer-motion';
import { P, match } from 'ts-pattern'; import { P, match } from 'ts-pattern';
import { Signature } from '@documenso/prisma/client'; import type { Signature } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { cn } from '../lib/utils';
import { Card, CardContent } from '../primitives/card';
export type SigningCardProps = { export type SigningCardProps = {
className?: string; className?: string;

View File

@ -3,16 +3,11 @@ import * as React from 'react';
import { Check, ChevronsUpDown } from 'lucide-react'; import { Check, ChevronsUpDown } from 'lucide-react';
import { Role } from '@documenso/prisma/client'; import { Role } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { cn } from '../lib/utils';
import { import { Button } from './button';
Command, import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from './command';
CommandEmpty, import { Popover, PopoverContent, PopoverTrigger } from './popover';
CommandGroup,
CommandInput,
CommandItem,
} from '@documenso/ui/primitives/command';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
type ComboboxProps = { type ComboboxProps = {
listValues: string[]; listValues: string[];

View File

@ -1,12 +1,14 @@
'use client'; 'use client';
import { Variants, motion } from 'framer-motion'; import type { Variants } from 'framer-motion';
import { motion } from 'framer-motion';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { useDropzone } from 'react-dropzone'; import { useDropzone } from 'react-dropzone';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions'; import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { cn } from '../lib/utils';
import { Card, CardContent } from './card';
const DocumentDropzoneContainerVariants: Variants = { const DocumentDropzoneContainerVariants: Variants = {
initial: { initial: {

View File

@ -11,29 +11,27 @@ import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-c
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element'; import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { nanoid } from '@documenso/lib/universal/id'; import { nanoid } from '@documenso/lib/universal/id';
import { Field, FieldType, Recipient, SendStatus } from '@documenso/prisma/client'; import type { Field, Recipient } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils'; import { FieldType, SendStatus } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from '@documenso/ui/primitives/command';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { TAddFieldsFormSchema } from './add-fields.types'; import { cn } from '../../lib/utils';
import { Button } from '../button';
import { Card, CardContent } from '../card';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from '../command';
import { Popover, PopoverContent, PopoverTrigger } from '../popover';
import { useStep } from '../stepper';
import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip';
import type { TAddFieldsFormSchema } from './add-fields.types';
import { import {
DocumentFlowFormContainerActions, DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent, DocumentFlowFormContainerContent,
DocumentFlowFormContainerFooter, DocumentFlowFormContainerFooter,
DocumentFlowFormContainerHeader,
DocumentFlowFormContainerStep, DocumentFlowFormContainerStep,
} from './document-flow-root'; } from './document-flow-root';
import { FieldItem } from './field-item'; import { FieldItem } from './field-item';
import { DocumentFlowStep, FRIENDLY_FIELD_TYPE } from './types'; import type { DocumentFlowStep } from './types';
import { FRIENDLY_FIELD_TYPE } from './types';
const fontCaveat = Caveat({ const fontCaveat = Caveat({
weight: ['500'], weight: ['500'],
@ -53,7 +51,6 @@ export type AddFieldsFormProps = {
hideRecipients?: boolean; hideRecipients?: boolean;
recipients: Recipient[]; recipients: Recipient[];
fields: Field[]; fields: Field[];
numberOfSteps: number;
onSubmit: (_data: TAddFieldsFormSchema) => void; onSubmit: (_data: TAddFieldsFormSchema) => void;
}; };
@ -62,10 +59,10 @@ export const AddFieldsFormPartial = ({
hideRecipients = false, hideRecipients = false,
recipients, recipients,
fields, fields,
numberOfSteps,
onSubmit, onSubmit,
}: AddFieldsFormProps) => { }: AddFieldsFormProps) => {
const { isWithinPageBounds, getFieldPosition, getPage } = useDocumentElement(); const { isWithinPageBounds, getFieldPosition, getPage } = useDocumentElement();
const { currentStep, totalSteps, previousStep } = useStep();
const { const {
control, control,
@ -287,6 +284,10 @@ export const AddFieldsFormPartial = ({
return ( return (
<> <>
<DocumentFlowFormContainerHeader
title={documentFlow.title}
description={documentFlow.description}
/>
<DocumentFlowFormContainerContent> <DocumentFlowFormContainerContent>
<div className="flex flex-col"> <div className="flex flex-col">
{selectedField && ( {selectedField && (
@ -513,15 +514,15 @@ export const AddFieldsFormPartial = ({
<DocumentFlowFormContainerFooter> <DocumentFlowFormContainerFooter>
<DocumentFlowFormContainerStep <DocumentFlowFormContainerStep
title={documentFlow.title} title={documentFlow.title}
step={documentFlow.stepIndex} step={currentStep}
maxStep={numberOfSteps} maxStep={totalSteps}
/> />
<DocumentFlowFormContainerActions <DocumentFlowFormContainerActions
loading={isSubmitting} loading={isSubmitting}
disabled={isSubmitting} disabled={isSubmitting}
onGoBackClick={() => { onGoBackClick={() => {
documentFlow.onBackStep?.(); previousStep();
remove(); remove();
}} }}
onGoNextClick={() => void onFormSubmit()} onGoNextClick={() => void onFormSubmit()}

View File

@ -9,35 +9,38 @@ import { match } from 'ts-pattern';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields'; import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import { Field, FieldType } from '@documenso/prisma/client'; import type { Field } from '@documenso/prisma/client';
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { FieldType } from '@documenso/prisma/client';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { FieldToolTip } from '../../components/field/field-tooltip';
import { TAddSignatureFormSchema } from '@documenso/ui/primitives/document-flow/add-signature.types'; import { cn } from '../../lib/utils';
import { Card, CardContent } from '../card';
import { ElementVisible } from '../element-visible';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../form/form';
import { Input } from '../input';
import { SignaturePad } from '../signature-pad';
import { useStep } from '../stepper';
import type { TAddSignatureFormSchema } from './add-signature.types';
import { ZAddSignatureFormSchema } from './add-signature.types';
import { import {
DocumentFlowFormContainerActions, DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent, DocumentFlowFormContainerContent,
DocumentFlowFormContainerFooter, DocumentFlowFormContainerFooter,
DocumentFlowFormContainerHeader,
DocumentFlowFormContainerStep, DocumentFlowFormContainerStep,
} from '@documenso/ui/primitives/document-flow/document-flow-root'; } from './document-flow-root';
import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../form/form';
import { ZAddSignatureFormSchema } from './add-signature.types';
import { import {
SinglePlayerModeCustomTextField, SinglePlayerModeCustomTextField,
SinglePlayerModeSignatureField, SinglePlayerModeSignatureField,
} from './single-player-mode-fields'; } from './single-player-mode-fields';
import type { DocumentFlowStep } from './types';
export type AddSignatureFormProps = { export type AddSignatureFormProps = {
defaultValues?: TAddSignatureFormSchema; defaultValues?: TAddSignatureFormSchema;
documentFlow: DocumentFlowStep; documentFlow: DocumentFlowStep;
fields: FieldWithSignature[]; fields: FieldWithSignature[];
numberOfSteps: number;
onSubmit: (_data: TAddSignatureFormSchema) => Promise<void> | void; onSubmit: (_data: TAddSignatureFormSchema) => Promise<void> | void;
requireName?: boolean; requireName?: boolean;
requireSignature?: boolean; requireSignature?: boolean;
@ -47,11 +50,13 @@ export const AddSignatureFormPartial = ({
defaultValues, defaultValues,
documentFlow, documentFlow,
fields, fields,
numberOfSteps,
onSubmit, onSubmit,
requireName = false, requireName = false,
requireSignature = true, requireSignature = true,
}: AddSignatureFormProps) => { }: AddSignatureFormProps) => {
const { currentStep, totalSteps } = useStep();
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false); const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
// Refined schema which takes into account whether to allow an empty name or signature. // Refined schema which takes into account whether to allow an empty name or signature.
@ -206,46 +211,30 @@ export const AddSignatureFormPartial = ({
}; };
return ( return (
<Form {...form}> <>
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}> <DocumentFlowFormContainerHeader
<DocumentFlowFormContainerContent> title={documentFlow.title}
<div className="space-y-4"> description={documentFlow.description}
<FormField />
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel required>Email</FormLabel>
<FormControl>
<Input
className="bg-background"
type="email"
autoComplete="email"
{...field}
onChange={(value) => {
onFormValueChange(FieldType.EMAIL);
field.onChange(value);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{requireName && ( <Form {...form}>
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
<DocumentFlowFormContainerContent>
<div className="space-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="name" name="email"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel required={requireName}>Name</FormLabel> <FormLabel required>Email</FormLabel>
<FormControl> <FormControl>
<Input <Input
className="bg-background" className="bg-background"
type="email"
autoComplete="email"
{...field} {...field}
onChange={(value) => { onChange={(value) => {
onFormValueChange(FieldType.NAME); onFormValueChange(FieldType.EMAIL);
field.onChange(value); field.onChange(value);
}} }}
/> />
@ -254,91 +243,114 @@ export const AddSignatureFormPartial = ({
</FormItem> </FormItem>
)} )}
/> />
)}
{requireSignature && ( {requireName && (
<FormField <FormField
control={form.control} control={form.control}
name="signature" name="name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel required={requireSignature}>Signature</FormLabel> <FormLabel required={requireName}>Name</FormLabel>
<FormControl> <FormControl>
<Card <Input
className={cn('mt-2', { className="bg-background"
'rounded-sm ring-2 ring-red-500 ring-offset-2 transition-all': {...field}
form.formState.errors.signature, onChange={(value) => {
})} onFormValueChange(FieldType.NAME);
gradient={!form.formState.errors.signature} field.onChange(value);
degrees={-120} }}
> />
<CardContent className="p-0"> </FormControl>
<SignaturePad <FormMessage />
className="h-44 w-full" </FormItem>
defaultValue={field.value} )}
onBlur={field.onBlur} />
onChange={(value) => { )}
onFormValueChange(FieldType.SIGNATURE);
field.onChange(value);
}}
/>
</CardContent>
</Card>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
</DocumentFlowFormContainerContent>
<DocumentFlowFormContainerFooter> {requireSignature && (
<DocumentFlowFormContainerStep <FormField
title={documentFlow.title} control={form.control}
step={documentFlow.stepIndex} name="signature"
maxStep={numberOfSteps} render={({ field }) => (
/> <FormItem>
<FormLabel required={requireSignature}>Signature</FormLabel>
<FormControl>
<Card
className={cn('mt-2', {
'rounded-sm ring-2 ring-red-500 ring-offset-2 transition-all':
form.formState.errors.signature,
})}
gradient={!form.formState.errors.signature}
degrees={-120}
>
<CardContent className="p-0">
<SignaturePad
className="h-44 w-full"
defaultValue={field.value}
onBlur={field.onBlur}
onChange={(value) => {
onFormValueChange(FieldType.SIGNATURE);
field.onChange(value);
}}
/>
</CardContent>
</Card>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
</DocumentFlowFormContainerContent>
<DocumentFlowFormContainerActions <DocumentFlowFormContainerFooter>
loading={form.formState.isSubmitting} <DocumentFlowFormContainerStep
disabled={form.formState.isSubmitting} title={documentFlow.title}
onGoBackClick={documentFlow.onBackStep} step={currentStep}
onGoNextClick={form.handleSubmit(onValidateFields)} maxStep={totalSteps}
/> />
</DocumentFlowFormContainerFooter>
</fieldset>
{validateUninsertedFields && uninsertedFields[0] && ( <DocumentFlowFormContainerActions
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning"> loading={form.formState.isSubmitting}
Click to insert field disabled={form.formState.isSubmitting}
</FieldToolTip> onGoBackClick={documentFlow.onBackStep}
)} onGoNextClick={form.handleSubmit(onValidateFields)}
/>
</DocumentFlowFormContainerFooter>
</fieldset>
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}> {validateUninsertedFields && uninsertedFields[0] && (
{localFields.map((field) => <FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
match(field.type) Click to insert field
.with(FieldType.DATE, FieldType.EMAIL, FieldType.NAME, () => { </FieldToolTip>
return ( )}
<SinglePlayerModeCustomTextField
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{localFields.map((field) =>
match(field.type)
.with(FieldType.DATE, FieldType.EMAIL, FieldType.NAME, () => {
return (
<SinglePlayerModeCustomTextField
onClick={insertField(field)}
key={field.id}
field={field}
/>
);
})
.with(FieldType.SIGNATURE, () => (
<SinglePlayerModeSignatureField
onClick={insertField(field)} onClick={insertField(field)}
key={field.id} key={field.id}
field={field} field={field}
/> />
); ))
}) .otherwise(() => {
.with(FieldType.SIGNATURE, () => ( return null;
<SinglePlayerModeSignatureField }),
onClick={insertField(field)} )}
key={field.id} </ElementVisible>
field={field} </Form>
/> </>
))
.otherwise(() => {
return null;
}),
)}
</ElementVisible>
</Form>
); );
}; };

View File

@ -9,35 +9,37 @@ import { Controller, useFieldArray, useForm } from 'react-hook-form';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { nanoid } from '@documenso/lib/universal/id'; import { nanoid } from '@documenso/lib/universal/id';
import { DocumentStatus, Field, Recipient, SendStatus } from '@documenso/prisma/client'; import type { Field, Recipient } from '@documenso/prisma/client';
import { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import { DocumentStatus, SendStatus } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { TAddSignersFormSchema, ZAddSignersFormSchema } from './add-signers.types'; import { Button } from '../button';
import { FormErrorMessage } from '../form/form-error-message';
import { Input } from '../input';
import { Label } from '../label';
import { useStep } from '../stepper';
import { useToast } from '../use-toast';
import type { TAddSignersFormSchema } from './add-signers.types';
import { ZAddSignersFormSchema } from './add-signers.types';
import { import {
DocumentFlowFormContainerActions, DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent, DocumentFlowFormContainerContent,
DocumentFlowFormContainerFooter, DocumentFlowFormContainerFooter,
DocumentFlowFormContainerHeader,
DocumentFlowFormContainerStep, DocumentFlowFormContainerStep,
} from './document-flow-root'; } from './document-flow-root';
import { DocumentFlowStep } from './types'; import type { DocumentFlowStep } from './types';
export type AddSignersFormProps = { export type AddSignersFormProps = {
documentFlow: DocumentFlowStep; documentFlow: DocumentFlowStep;
recipients: Recipient[]; recipients: Recipient[];
fields: Field[]; fields: Field[];
document: DocumentWithData; document: DocumentWithData;
numberOfSteps: number;
onSubmit: (_data: TAddSignersFormSchema) => void; onSubmit: (_data: TAddSignersFormSchema) => void;
}; };
export const AddSignersFormPartial = ({ export const AddSignersFormPartial = ({
documentFlow, documentFlow,
numberOfSteps,
recipients, recipients,
document, document,
fields: _fields, fields: _fields,
@ -48,6 +50,8 @@ export const AddSignersFormPartial = ({
const initialId = useId(); const initialId = useId();
const { currentStep, totalSteps, previousStep } = useStep();
const { const {
control, control,
handleSubmit, handleSubmit,
@ -126,6 +130,10 @@ export const AddSignersFormPartial = ({
return ( return (
<> <>
<DocumentFlowFormContainerHeader
title={documentFlow.title}
description={documentFlow.description}
/>
<DocumentFlowFormContainerContent> <DocumentFlowFormContainerContent>
<div className="flex w-full flex-col gap-y-4"> <div className="flex w-full flex-col gap-y-4">
<AnimatePresence> <AnimatePresence>
@ -221,15 +229,15 @@ export const AddSignersFormPartial = ({
<DocumentFlowFormContainerFooter> <DocumentFlowFormContainerFooter>
<DocumentFlowFormContainerStep <DocumentFlowFormContainerStep
title={documentFlow.title} title={documentFlow.title}
step={documentFlow.stepIndex} step={currentStep}
maxStep={numberOfSteps} maxStep={totalSteps}
/> />
<DocumentFlowFormContainerActions <DocumentFlowFormContainerActions
canGoBack={document.status === DocumentStatus.DRAFT} canGoBack={document.status === DocumentStatus.DRAFT}
loading={isSubmitting} loading={isSubmitting}
disabled={isSubmitting} disabled={isSubmitting}
onGoBackClick={documentFlow.onBackStep} onGoBackClick={previousStep}
onGoNextClick={() => void onFormSubmit()} onGoNextClick={() => void onFormSubmit()}
/> />
</DocumentFlowFormContainerFooter> </DocumentFlowFormContainerFooter>

View File

@ -2,28 +2,30 @@
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { DocumentStatus, Field, Recipient } from '@documenso/prisma/client'; import type { Field, Recipient } from '@documenso/prisma/client';
import { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import { DocumentStatus } from '@documenso/prisma/client';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { TAddSubjectFormSchema } from './add-subject.types'; import { FormErrorMessage } from '../form/form-error-message';
import { Input } from '../input';
import { Label } from '../label';
import { useStep } from '../stepper';
import { Textarea } from '../textarea';
import type { TAddSubjectFormSchema } from './add-subject.types';
import { import {
DocumentFlowFormContainerActions, DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent, DocumentFlowFormContainerContent,
DocumentFlowFormContainerFooter, DocumentFlowFormContainerFooter,
DocumentFlowFormContainerHeader,
DocumentFlowFormContainerStep, DocumentFlowFormContainerStep,
} from './document-flow-root'; } from './document-flow-root';
import { DocumentFlowStep } from './types'; import type { DocumentFlowStep } from './types';
export type AddSubjectFormProps = { export type AddSubjectFormProps = {
documentFlow: DocumentFlowStep; documentFlow: DocumentFlowStep;
recipients: Recipient[]; recipients: Recipient[];
fields: Field[]; fields: Field[];
document: DocumentWithData; document: DocumentWithData;
numberOfSteps: number;
onSubmit: (_data: TAddSubjectFormSchema) => void; onSubmit: (_data: TAddSubjectFormSchema) => void;
}; };
@ -32,7 +34,6 @@ export const AddSubjectFormPartial = ({
recipients: _recipients, recipients: _recipients,
fields: _fields, fields: _fields,
document, document,
numberOfSteps,
onSubmit, onSubmit,
}: AddSubjectFormProps) => { }: AddSubjectFormProps) => {
const { const {
@ -49,9 +50,14 @@ export const AddSubjectFormPartial = ({
}); });
const onFormSubmit = handleSubmit(onSubmit); const onFormSubmit = handleSubmit(onSubmit);
const { currentStep, totalSteps, previousStep } = useStep();
return ( return (
<> <>
<DocumentFlowFormContainerHeader
title={documentFlow.title}
description={documentFlow.description}
/>
<DocumentFlowFormContainerContent> <DocumentFlowFormContainerContent>
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex flex-col gap-y-4"> <div className="flex flex-col gap-y-4">
@ -124,15 +130,15 @@ export const AddSubjectFormPartial = ({
<DocumentFlowFormContainerFooter> <DocumentFlowFormContainerFooter>
<DocumentFlowFormContainerStep <DocumentFlowFormContainerStep
title={documentFlow.title} title={documentFlow.title}
step={documentFlow.stepIndex} step={currentStep}
maxStep={numberOfSteps} maxStep={totalSteps}
/> />
<DocumentFlowFormContainerActions <DocumentFlowFormContainerActions
loading={isSubmitting} loading={isSubmitting}
disabled={isSubmitting} disabled={isSubmitting}
goNextLabel={document.status === DocumentStatus.DRAFT ? 'Send' : 'Update'} goNextLabel={document.status === DocumentStatus.DRAFT ? 'Send' : 'Update'}
onGoBackClick={documentFlow.onBackStep} onGoBackClick={previousStep}
onGoNextClick={() => void onFormSubmit()} onGoNextClick={() => void onFormSubmit()}
/> />
</DocumentFlowFormContainerFooter> </DocumentFlowFormContainerFooter>

View File

@ -4,15 +4,17 @@ import { useForm } from 'react-hook-form';
import type { Field, Recipient } from '@documenso/prisma/client'; import type { Field, Recipient } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { FormErrorMessage } from '../form/form-error-message';
import { Input } from '../input';
import { Label } from '../label';
import { useStep } from '../stepper';
import type { TAddTitleFormSchema } from './add-title.types'; import type { TAddTitleFormSchema } from './add-title.types';
import { import {
DocumentFlowFormContainerActions, DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent, DocumentFlowFormContainerContent,
DocumentFlowFormContainerFooter, DocumentFlowFormContainerFooter,
DocumentFlowFormContainerHeader,
DocumentFlowFormContainerStep, DocumentFlowFormContainerStep,
} from './document-flow-root'; } from './document-flow-root';
import type { DocumentFlowStep } from './types'; import type { DocumentFlowStep } from './types';
@ -22,7 +24,6 @@ export type AddTitleFormProps = {
recipients: Recipient[]; recipients: Recipient[];
fields: Field[]; fields: Field[];
document: DocumentWithData; document: DocumentWithData;
numberOfSteps: number;
onSubmit: (_data: TAddTitleFormSchema) => void; onSubmit: (_data: TAddTitleFormSchema) => void;
}; };
@ -31,7 +32,6 @@ export const AddTitleFormPartial = ({
recipients: _recipients, recipients: _recipients,
fields: _fields, fields: _fields,
document, document,
numberOfSteps,
onSubmit, onSubmit,
}: AddTitleFormProps) => { }: AddTitleFormProps) => {
const { const {
@ -46,8 +46,14 @@ export const AddTitleFormPartial = ({
const onFormSubmit = handleSubmit(onSubmit); const onFormSubmit = handleSubmit(onSubmit);
const { stepIndex, currentStep, totalSteps, previousStep } = useStep();
return ( return (
<> <>
<DocumentFlowFormContainerHeader
title={documentFlow.title}
description={documentFlow.description}
/>
<DocumentFlowFormContainerContent> <DocumentFlowFormContainerContent>
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex flex-col gap-y-4"> <div className="flex flex-col gap-y-4">
@ -72,14 +78,15 @@ export const AddTitleFormPartial = ({
<DocumentFlowFormContainerFooter> <DocumentFlowFormContainerFooter>
<DocumentFlowFormContainerStep <DocumentFlowFormContainerStep
title={documentFlow.title} title={documentFlow.title}
step={documentFlow.stepIndex} step={currentStep}
maxStep={numberOfSteps} maxStep={totalSteps}
/> />
<DocumentFlowFormContainerActions <DocumentFlowFormContainerActions
loading={isSubmitting} loading={isSubmitting}
disabled={isSubmitting} disabled={isSubmitting}
onGoBackClick={documentFlow.onBackStep} canGoBack={stepIndex !== 0}
onGoBackClick={previousStep}
onGoNextClick={() => void onFormSubmit()} onGoNextClick={() => void onFormSubmit()}
/> />
</DocumentFlowFormContainerFooter> </DocumentFlowFormContainerFooter>

View File

@ -1,11 +1,12 @@
'use client'; 'use client';
import React, { HTMLAttributes } from 'react'; import type { HTMLAttributes } from 'react';
import React from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '../../lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '../button';
export type DocumentFlowFormContainerProps = HTMLAttributes<HTMLFormElement> & { export type DocumentFlowFormContainerProps = HTMLAttributes<HTMLFormElement> & {
children?: React.ReactNode; children?: React.ReactNode;

View File

@ -7,10 +7,11 @@ import { createPortal } from 'react-dom';
import { Rnd } from 'react-rnd'; import { Rnd } from 'react-rnd';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { FRIENDLY_FIELD_TYPE, TDocumentFlowFormSchema } from './types'; import { cn } from '../../lib/utils';
import { Card, CardContent } from '../card';
import type { TDocumentFlowFormSchema } from './types';
import { FRIENDLY_FIELD_TYPE } from './types';
type Field = TDocumentFlowFormSchema['fields'][0]; type Field = TDocumentFlowFormSchema['fields'][0];

View File

@ -2,7 +2,8 @@ import { useState } from 'react';
import { Loader } from 'lucide-react'; import { Loader } from 'lucide-react';
import { Button, ButtonProps } from '@documenso/ui/primitives/button'; import type { ButtonProps } from '../button';
import { Button } from '../button';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -11,7 +12,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from '@documenso/ui/primitives/dialog'; } from '../dialog';
export type SendDocumentActionDialogProps = ButtonProps & { export type SendDocumentActionDialogProps = ButtonProps & {
loading?: boolean; loading?: boolean;

View File

@ -13,9 +13,11 @@ import {
MIN_HANDWRITING_FONT_SIZE, MIN_HANDWRITING_FONT_SIZE,
MIN_STANDARD_FONT_SIZE, MIN_STANDARD_FONT_SIZE,
} from '@documenso/lib/constants/pdf'; } from '@documenso/lib/constants/pdf';
import { Field, FieldType } from '@documenso/prisma/client'; import type { Field } from '@documenso/prisma/client';
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { FieldType } from '@documenso/prisma/client';
import { FieldRootContainer } from '@documenso/ui/components/field/field'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { FieldRootContainer } from '../../components/field/field';
export type SinglePlayerModeFieldContainerProps = { export type SinglePlayerModeFieldContainerProps = {
field: FieldWithSignature; field: FieldWithSignature;

View File

@ -53,7 +53,7 @@ export const FRIENDLY_FIELD_TYPE: Record<FieldType, string> = {
export interface DocumentFlowStep { export interface DocumentFlowStep {
title: string; title: string;
description: string; description: string;
stepIndex: number; stepIndex?: number;
onBackStep?: () => unknown; onBackStep?: () => unknown;
onNextStep?: () => unknown; onNextStep?: () => unknown;
} }

View File

@ -1,6 +1,6 @@
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '../../lib/utils';
export type FormErrorMessageProps = { export type FormErrorMessageProps = {
className?: string; className?: string;

View File

@ -1,19 +1,12 @@
import * as React from 'react'; import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label'; import type * as LabelPrimitive from '@radix-ui/react-label';
import { Slot } from '@radix-ui/react-slot'; import { Slot } from '@radix-ui/react-slot';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { import type { ControllerProps, FieldPath, FieldValues } from 'react-hook-form';
Controller, import { Controller, FormProvider, useFormContext } from 'react-hook-form';
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} from 'react-hook-form';
import { cn } from '@documenso/ui/lib/utils';
import { cn } from '../../lib/utils';
import { Label } from '../label'; import { Label } from '../label';
const Form = FormProvider; const Form = FormProvider;

View File

@ -3,16 +3,16 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Loader } from 'lucide-react'; import { Loader } from 'lucide-react';
import { PDFDocumentProxy } from 'pdfjs-dist'; import type { PDFDocumentProxy } from 'pdfjs-dist';
import { Document as PDFDocument, Page as PDFPage, pdfjs } from 'react-pdf'; import { Document as PDFDocument, Page as PDFPage, pdfjs } from 'react-pdf';
import 'react-pdf/dist/esm/Page/AnnotationLayer.css'; import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
import 'react-pdf/dist/esm/Page/TextLayer.css'; import 'react-pdf/dist/esm/Page/TextLayer.css';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { getFile } from '@documenso/lib/universal/upload/get-file'; import { getFile } from '@documenso/lib/universal/upload/get-file';
import { DocumentData } from '@documenso/prisma/client'; import type { DocumentData } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils';
import { cn } from '../lib/utils';
import { useToast } from './use-toast'; import { useToast } from './use-toast';
export type LoadedPDFDocument = PDFDocumentProxy; export type LoadedPDFDocument = PDFDocumentProxy;

View File

@ -1,20 +1,12 @@
'use client'; 'use client';
import { import type { HTMLAttributes, MouseEvent, PointerEvent, TouchEvent } from 'react';
HTMLAttributes, import { useEffect, useMemo, useRef, useState } from 'react';
MouseEvent,
PointerEvent,
TouchEvent,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { StrokeOptions, getStroke } from 'perfect-freehand'; import type { StrokeOptions } from 'perfect-freehand';
import { getStroke } from 'perfect-freehand';
import { cn } from '@documenso/ui/lib/utils';
import { cn } from '../../lib/utils';
import { getSvgPathFromStroke } from './helper'; import { getSvgPathFromStroke } from './helper';
import { Point } from './point'; import { Point } from './point';

View File

@ -0,0 +1,109 @@
import React, { createContext, useContext, useState } from 'react';
import type { FC } from 'react';
type StepContextValue = {
isCompleting: boolean;
stepIndex: number;
currentStep: number;
totalSteps: number;
isFirst: boolean;
isLast: boolean;
nextStep: () => void;
previousStep: () => void;
};
const StepContext = createContext<StepContextValue | null>(null);
type StepperProps = {
children: React.ReactNode;
onComplete?: () => void | Promise<void>;
onStepChanged?: (currentStep: number) => void;
currentStep?: number; // external control prop
setCurrentStep?: (step: number) => void; // external control function
};
export const Stepper: FC<StepperProps> = ({
children,
onComplete,
onStepChanged,
currentStep: propCurrentStep,
setCurrentStep: propSetCurrentStep,
}) => {
const [stateCurrentStep, stateSetCurrentStep] = useState(1);
const [isCompleting, setIsCompleting] = useState(false);
// Determine if props are provided, otherwise use state
const isControlled = propCurrentStep !== undefined && propSetCurrentStep !== undefined;
const currentStep = isControlled ? propCurrentStep : stateCurrentStep;
const setCurrentStep = isControlled ? propSetCurrentStep : stateSetCurrentStep;
const totalSteps = React.Children.count(children);
const handleComplete = async () => {
try {
if (!onComplete) {
return;
}
setIsCompleting(true);
await onComplete();
setIsCompleting(false);
} catch (error) {
setIsCompleting(false);
throw error;
}
};
const handleStepChange = (nextStep: number) => {
setCurrentStep(nextStep);
onStepChanged?.(nextStep);
};
const nextStep = () => {
if (currentStep < totalSteps) {
void handleStepChange(currentStep + 1);
} else {
void handleComplete();
}
};
const previousStep = () => {
if (currentStep > 1) {
void handleStepChange(currentStep - 1);
}
};
// Empty stepper
if (totalSteps === 0) {
return null;
}
const currentChild = React.Children.toArray(children)[currentStep - 1];
const stepContextValue: StepContextValue = {
isCompleting,
stepIndex: currentStep - 1,
currentStep,
totalSteps,
isFirst: currentStep === 1,
isLast: currentStep === totalSteps,
nextStep,
previousStep,
};
return <StepContext.Provider value={stepContextValue}>{currentChild}</StepContext.Provider>;
};
/** Hook for children to use the step context */
export const useStep = (): StepContextValue => {
const context = useContext(StepContext);
if (!context) {
throw new Error('useStep must be used within a Stepper');
}
return context;
};