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

@ -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

@ -4,15 +4,15 @@ import { deleteUser } from '@documenso/lib/server-only/user/delete-user';
test.use({ storageState: { cookies: [], origins: [] } });
/*
/*
Using them sequentially so the 2nd test
uses the details from the 1st (registration) test
*/
test.describe.configure({ mode: 'serial' });
const username = process.env.E2E_TEST_AUTHENTICATE_USERNAME;
const email = process.env.E2E_TEST_AUTHENTICATE_USER_EMAIL;
const password = process.env.E2E_TEST_AUTHENTICATE_USER_PASSWORD;
const username = 'Test User';
const email = 'test-user@auth-flow.documenso.com';
const password = 'Password123';
test('user can sign up with email and password', async ({ page }: { page: Page }) => {
await page.goto('/signup');

View File

@ -6,13 +6,14 @@
"main": "index.js",
"scripts": {
"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": [],
"author": "",
"devDependencies": {
"@playwright/test": "^1.18.1",
"@types/node": "^20.8.2",
"@documenso/prisma": "*",
"@documenso/web": "*"
},
"dependencies": {

View File

@ -28,8 +28,12 @@ export default defineConfig({
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
video: 'retain-on-failure',
},
timeout: 30_000,
/* Configure projects for major browsers */
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 { 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 { prisma } from '@documenso/prisma';
@ -174,6 +175,13 @@ export const stripeWebhookHandler = async (
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({
select: {
userId: true,
@ -218,6 +226,13 @@ export const stripeWebhookHandler = async (
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({
select: {
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 { 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: [
{
userId,
deletedAt: null,
},
{
status: {
not: ExtendedDocumentStatus.DRAFT,
},
status: ExtendedDocumentStatus.COMPLETED,
Recipient: {
some: {
email: user.email,
},
},
},
{
status: ExtendedDocumentStatus.PENDING,
Recipient: {
some: {
email: user.email,
},
},
deletedAt: null,
},
],
}))
.with(ExtendedDocumentStatus.INBOX, () => ({
@ -78,26 +86,29 @@ export const findDocuments = async ({
signingStatus: SigningStatus.NOT_SIGNED,
},
},
deletedAt: null,
}))
.with(ExtendedDocumentStatus.DRAFT, () => ({
userId,
status: ExtendedDocumentStatus.DRAFT,
deletedAt: null,
}))
.with(ExtendedDocumentStatus.PENDING, () => ({
OR: [
{
userId,
status: ExtendedDocumentStatus.PENDING,
deletedAt: null,
},
{
status: ExtendedDocumentStatus.PENDING,
Recipient: {
some: {
email: user.email,
signingStatus: SigningStatus.SIGNED,
},
},
deletedAt: null,
},
],
}))
@ -106,6 +117,7 @@ export const findDocuments = async ({
{
userId,
status: ExtendedDocumentStatus.COMPLETED,
deletedAt: null,
},
{
status: ExtendedDocumentStatus.COMPLETED,

View File

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

View File

@ -1,8 +1,10 @@
'use server';
import { nanoid } from 'nanoid';
import path from 'node:path';
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 { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import { signPdf } from '@documenso/signing';
@ -83,6 +85,18 @@ export const sealDocument = async ({ documentId, sendEmail = true }: SealDocumen
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({
where: {
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`);
}
if (document.deletedAt) {
throw new Error(`Document ${document.id} has been deleted`);
}
if (recipient?.signingStatus === SigningStatus.SIGNED) {
throw new Error(`Recipient ${recipient.id} has already signed`);
}

View File

@ -1,9 +1,7 @@
import { z } from 'zod';
import {
TFeatureFlagValue,
ZFeatureFlagValueSchema,
} from '@documenso/lib/client-only/providers/feature-flag.types';
import type { TFeatureFlagValue } from '@documenso/lib/client-only/providers/feature-flag.types';
import { ZFeatureFlagValueSchema } from '@documenso/lib/client-only/providers/feature-flag.types';
import { APP_BASE_URL } from '@documenso/lib/constants/app';
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
createdAt DateTime @default(now())
userId Int
user User @relation(fields: [userId], references: [id])
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
enum SubscriptionStatus {
@ -135,6 +135,7 @@ model Document {
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
completedAt DateTime?
deletedAt DateTime?
@@unique([documentDataId])
@@index([userId])

View File

@ -1,74 +1,22 @@
import { DocumentDataType, Role } 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 './index';
const seedDatabase = async () => {
const examplePdf = fs
.readFileSync(path.join(__dirname, '../../assets/example.pdf'))
.toString('base64');
const files = fs.readdirSync(path.join(__dirname, './seed'));
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: {},
});
for (const file of files) {
const stat = fs.statSync(path.join(__dirname, './seed', file));
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: {},
});
if (stat.isFile()) {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const mod = require(path.join(__dirname, './seed', file));
const examplePdfData = await prisma.documentData.upsert({
where: {
id: 'clmn0kv5k0000pe04vcqg5zla',
},
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: {},
});
if ('seedDatabase' in mod && typeof mod.seedDatabase === 'function') {
console.log(`[SEEDING]: ${file}`);
await mod.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 { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
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 { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
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 { updateTitle } from '@documenso/lib/server-only/document/update-title';
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
@ -20,6 +21,7 @@ import {
ZGetDocumentByIdQuerySchema,
ZGetDocumentByTokenQuerySchema,
ZResendDocumentMutationSchema,
ZSearchDocumentsMutationSchema,
ZSendDocumentMutationSchema,
ZSetFieldsForDocumentMutationSchema,
ZSetRecipientsForDocumentMutationSchema,
@ -97,15 +99,15 @@ export const documentRouter = router({
}
}),
deleteDraftDocument: authenticatedProcedure
deleteDocument: authenticatedProcedure
.input(ZDeleteDraftDocumentMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { id } = input;
const { id, status } = input;
const userId = ctx.user.id;
return await deleteDraftDocument({ id, userId });
return await deleteDocument({ id, userId, status });
} catch (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 { FieldType } from '@documenso/prisma/client';
import { DocumentStatus, FieldType } from '@documenso/prisma/client';
export const ZGetDocumentByIdQuerySchema = z.object({
id: z.number().min(1),
@ -80,6 +80,11 @@ export type TSendDocumentMutationSchema = z.infer<typeof ZSendDocumentMutationSc
export const ZDeleteDraftDocumentMutationSchema = z.object({
id: z.number().min(1),
status: z.nativeEnum(DocumentStatus),
});
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 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> & {
disabled?: boolean;

View File

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

View File

@ -1,17 +1,18 @@
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 { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords';
import { cn } from '@documenso/ui/lib/utils';
import { cn } from '../..//lib/utils';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@documenso/ui/primitives/tooltip';
import { Field } from '.prisma/client';
} from '../..//primitives/tooltip';
import type { Field } from '.prisma/client';
const tooltipVariants = cva('font-semibold', {
variants: {

View File

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

View File

@ -2,14 +2,16 @@
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 { P, match } from 'ts-pattern';
import { Signature } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import type { Signature } from '@documenso/prisma/client';
import { cn } from '../lib/utils';
import { Card, CardContent } from '../primitives/card';
export type SigningCardProps = {
className?: string;

View File

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

View File

@ -1,12 +1,14 @@
'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 { useDropzone } from 'react-dropzone';
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 = {
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 { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { nanoid } from '@documenso/lib/universal/id';
import { Field, FieldType, Recipient, SendStatus } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils';
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 type { Field, Recipient } from '@documenso/prisma/client';
import { FieldType, SendStatus } from '@documenso/prisma/client';
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 {
DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent,
DocumentFlowFormContainerFooter,
DocumentFlowFormContainerHeader,
DocumentFlowFormContainerStep,
} from './document-flow-root';
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({
weight: ['500'],
@ -53,7 +51,6 @@ export type AddFieldsFormProps = {
hideRecipients?: boolean;
recipients: Recipient[];
fields: Field[];
numberOfSteps: number;
onSubmit: (_data: TAddFieldsFormSchema) => void;
};
@ -62,10 +59,10 @@ export const AddFieldsFormPartial = ({
hideRecipients = false,
recipients,
fields,
numberOfSteps,
onSubmit,
}: AddFieldsFormProps) => {
const { isWithinPageBounds, getFieldPosition, getPage } = useDocumentElement();
const { currentStep, totalSteps, previousStep } = useStep();
const {
control,
@ -287,6 +284,10 @@ export const AddFieldsFormPartial = ({
return (
<>
<DocumentFlowFormContainerHeader
title={documentFlow.title}
description={documentFlow.description}
/>
<DocumentFlowFormContainerContent>
<div className="flex flex-col">
{selectedField && (
@ -513,15 +514,15 @@ export const AddFieldsFormPartial = ({
<DocumentFlowFormContainerFooter>
<DocumentFlowFormContainerStep
title={documentFlow.title}
step={documentFlow.stepIndex}
maxStep={numberOfSteps}
step={currentStep}
maxStep={totalSteps}
/>
<DocumentFlowFormContainerActions
loading={isSubmitting}
disabled={isSubmitting}
onGoBackClick={() => {
documentFlow.onBackStep?.();
previousStep();
remove();
}}
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 { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import { Field, FieldType } from '@documenso/prisma/client';
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { TAddSignatureFormSchema } from '@documenso/ui/primitives/document-flow/add-signature.types';
import type { Field } from '@documenso/prisma/client';
import { FieldType } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { FieldToolTip } from '../../components/field/field-tooltip';
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 {
DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent,
DocumentFlowFormContainerFooter,
DocumentFlowFormContainerHeader,
DocumentFlowFormContainerStep,
} from '@documenso/ui/primitives/document-flow/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';
} from './document-flow-root';
import {
SinglePlayerModeCustomTextField,
SinglePlayerModeSignatureField,
} from './single-player-mode-fields';
import type { DocumentFlowStep } from './types';
export type AddSignatureFormProps = {
defaultValues?: TAddSignatureFormSchema;
documentFlow: DocumentFlowStep;
fields: FieldWithSignature[];
numberOfSteps: number;
onSubmit: (_data: TAddSignatureFormSchema) => Promise<void> | void;
requireName?: boolean;
requireSignature?: boolean;
@ -47,11 +50,13 @@ export const AddSignatureFormPartial = ({
defaultValues,
documentFlow,
fields,
numberOfSteps,
onSubmit,
requireName = false,
requireSignature = true,
}: AddSignatureFormProps) => {
const { currentStep, totalSteps } = useStep();
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
// Refined schema which takes into account whether to allow an empty name or signature.
@ -206,46 +211,30 @@ export const AddSignatureFormPartial = ({
};
return (
<Form {...form}>
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
<DocumentFlowFormContainerContent>
<div className="space-y-4">
<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>
)}
/>
<>
<DocumentFlowFormContainerHeader
title={documentFlow.title}
description={documentFlow.description}
/>
{requireName && (
<Form {...form}>
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
<DocumentFlowFormContainerContent>
<div className="space-y-4">
<FormField
control={form.control}
name="name"
name="email"
render={({ field }) => (
<FormItem>
<FormLabel required={requireName}>Name</FormLabel>
<FormLabel required>Email</FormLabel>
<FormControl>
<Input
className="bg-background"
type="email"
autoComplete="email"
{...field}
onChange={(value) => {
onFormValueChange(FieldType.NAME);
onFormValueChange(FieldType.EMAIL);
field.onChange(value);
}}
/>
@ -254,91 +243,114 @@ export const AddSignatureFormPartial = ({
</FormItem>
)}
/>
)}
{requireSignature && (
<FormField
control={form.control}
name="signature"
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>
{requireName && (
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel required={requireName}>Name</FormLabel>
<FormControl>
<Input
className="bg-background"
{...field}
onChange={(value) => {
onFormValueChange(FieldType.NAME);
field.onChange(value);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<DocumentFlowFormContainerFooter>
<DocumentFlowFormContainerStep
title={documentFlow.title}
step={documentFlow.stepIndex}
maxStep={numberOfSteps}
/>
{requireSignature && (
<FormField
control={form.control}
name="signature"
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
loading={form.formState.isSubmitting}
disabled={form.formState.isSubmitting}
onGoBackClick={documentFlow.onBackStep}
onGoNextClick={form.handleSubmit(onValidateFields)}
/>
</DocumentFlowFormContainerFooter>
</fieldset>
<DocumentFlowFormContainerFooter>
<DocumentFlowFormContainerStep
title={documentFlow.title}
step={currentStep}
maxStep={totalSteps}
/>
{validateUninsertedFields && uninsertedFields[0] && (
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
Click to insert field
</FieldToolTip>
)}
<DocumentFlowFormContainerActions
loading={form.formState.isSubmitting}
disabled={form.formState.isSubmitting}
onGoBackClick={documentFlow.onBackStep}
onGoNextClick={form.handleSubmit(onValidateFields)}
/>
</DocumentFlowFormContainerFooter>
</fieldset>
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{localFields.map((field) =>
match(field.type)
.with(FieldType.DATE, FieldType.EMAIL, FieldType.NAME, () => {
return (
<SinglePlayerModeCustomTextField
{validateUninsertedFields && uninsertedFields[0] && (
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
Click to insert field
</FieldToolTip>
)}
<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)}
key={field.id}
field={field}
/>
);
})
.with(FieldType.SIGNATURE, () => (
<SinglePlayerModeSignatureField
onClick={insertField(field)}
key={field.id}
field={field}
/>
))
.otherwise(() => {
return null;
}),
)}
</ElementVisible>
</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 { nanoid } from '@documenso/lib/universal/id';
import { DocumentStatus, Field, Recipient, SendStatus } from '@documenso/prisma/client';
import { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { Button } from '@documenso/ui/primitives/button';
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 type { Field, Recipient } from '@documenso/prisma/client';
import { DocumentStatus, SendStatus } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
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 {
DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent,
DocumentFlowFormContainerFooter,
DocumentFlowFormContainerHeader,
DocumentFlowFormContainerStep,
} from './document-flow-root';
import { DocumentFlowStep } from './types';
import type { DocumentFlowStep } from './types';
export type AddSignersFormProps = {
documentFlow: DocumentFlowStep;
recipients: Recipient[];
fields: Field[];
document: DocumentWithData;
numberOfSteps: number;
onSubmit: (_data: TAddSignersFormSchema) => void;
};
export const AddSignersFormPartial = ({
documentFlow,
numberOfSteps,
recipients,
document,
fields: _fields,
@ -48,6 +50,8 @@ export const AddSignersFormPartial = ({
const initialId = useId();
const { currentStep, totalSteps, previousStep } = useStep();
const {
control,
handleSubmit,
@ -126,6 +130,10 @@ export const AddSignersFormPartial = ({
return (
<>
<DocumentFlowFormContainerHeader
title={documentFlow.title}
description={documentFlow.description}
/>
<DocumentFlowFormContainerContent>
<div className="flex w-full flex-col gap-y-4">
<AnimatePresence>
@ -221,15 +229,15 @@ export const AddSignersFormPartial = ({
<DocumentFlowFormContainerFooter>
<DocumentFlowFormContainerStep
title={documentFlow.title}
step={documentFlow.stepIndex}
maxStep={numberOfSteps}
step={currentStep}
maxStep={totalSteps}
/>
<DocumentFlowFormContainerActions
canGoBack={document.status === DocumentStatus.DRAFT}
loading={isSubmitting}
disabled={isSubmitting}
onGoBackClick={documentFlow.onBackStep}
onGoBackClick={previousStep}
onGoNextClick={() => void onFormSubmit()}
/>
</DocumentFlowFormContainerFooter>

View File

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

View File

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

View File

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

View File

@ -7,10 +7,11 @@ import { createPortal } from 'react-dom';
import { Rnd } from 'react-rnd';
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];

View File

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

View File

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

View File

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

View File

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

View File

@ -1,19 +1,12 @@
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 { AnimatePresence, motion } from 'framer-motion';
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} from 'react-hook-form';
import { cn } from '@documenso/ui/lib/utils';
import type { ControllerProps, FieldPath, FieldValues } from 'react-hook-form';
import { Controller, FormProvider, useFormContext } from 'react-hook-form';
import { cn } from '../../lib/utils';
import { Label } from '../label';
const Form = FormProvider;

View File

@ -3,16 +3,16 @@
import React, { useEffect, useMemo, useRef, useState } from '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 'react-pdf/dist/esm/Page/AnnotationLayer.css';
import 'react-pdf/dist/esm/Page/TextLayer.css';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { DocumentData } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils';
import type { DocumentData } from '@documenso/prisma/client';
import { cn } from '../lib/utils';
import { useToast } from './use-toast';
export type LoadedPDFDocument = PDFDocumentProxy;

View File

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