diff --git a/apps/web/src/app/(signing)/sign/[token]/page.tsx b/apps/web/src/app/(signing)/sign/[token]/page.tsx index 17789453e..97babb82f 100644 --- a/apps/web/src/app/(signing)/sign/[token]/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/page.tsx @@ -57,7 +57,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp redirect(`/sign/${token}/complete`); } - const recipientSignature = (await getRecipientSignatures({ recipientId: recipient.id }))[0]; + const [recipientSignature] = await getRecipientSignatures({ recipientId: recipient.id }); if (document.deletedAt) { return ( diff --git a/apps/web/src/components/(dashboard)/common/command-menu.tsx b/apps/web/src/components/(dashboard)/common/command-menu.tsx index 8d21f8d6a..ab0bba6af 100644 --- a/apps/web/src/components/(dashboard)/common/command-menu.tsx +++ b/apps/web/src/components/(dashboard)/common/command-menu.tsx @@ -4,7 +4,7 @@ import { useCallback, useMemo, useState } from 'react'; 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 { useHotkeys } from 'react-hotkeys-hook'; @@ -12,6 +12,7 @@ import { DOCUMENTS_PAGE_SHORTCUT, SETTINGS_PAGE_SHORTCUT, } from '@documenso/lib/constants/keyboard-shortcuts'; +import { trpc as trpcReact } from '@documenso/trpc/react'; import { CommandDialog, CommandEmpty, @@ -29,13 +30,20 @@ const DOCUMENTS_PAGES = [ shortcut: DOCUMENTS_PAGE_SHORTCUT.replace('+', ''), }, { 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: 'Inbox documents', path: '/documents?status=INBOX' }, ]; 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: 'Password', path: '/settings/password' }, ]; @@ -53,6 +61,29 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { const [search, setSearch] = useState(''); const [pages, setPages] = useState([]); + 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 toggleOpen = () => { @@ -113,7 +144,13 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { }; return ( - + - No results found. + {isSearchingDocuments ? ( + +
+ + + +
+
+ ) : ( + No results found. + )} {!currentPage && ( <> @@ -133,6 +180,11 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { addPage('theme')}>Change theme + {searchResults.length > 0 && ( + + + + )} )} {currentPage === 'theme' && } @@ -146,10 +198,14 @@ const Commands = ({ pages, }: { push: (_path: string) => void; - pages: { label: string; path: string; shortcut?: string }[]; + pages: { label: string; path: string; shortcut?: string; value?: string }[]; }) => { - return pages.map((page) => ( - push(page.path)}> + return pages.map((page, idx) => ( + push(page.path)} + > {page.label} {page.shortcut && {page.shortcut}} diff --git a/packages/app-tests/e2e/pr-713-add-document-search-to-command-menu.spec.ts b/packages/app-tests/e2e/pr-713-add-document-search-to-command-menu.spec.ts new file mode 100644 index 000000000..e9ae60d0e --- /dev/null +++ b/packages/app-tests/e2e/pr-713-add-document-search-to-command-menu.spec.ts @@ -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(); +}); diff --git a/packages/lib/server-only/document/search-documents-with-keyword.ts b/packages/lib/server-only/document/search-documents-with-keyword.ts new file mode 100644 index 000000000..c4014d37f --- /dev/null +++ b/packages/lib/server-only/document/search-documents-with-keyword.ts @@ -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; +}; diff --git a/packages/prisma/seed/pr-713-add-document-search-to-command-menu.ts b/packages/prisma/seed/pr-713-add-document-search-to-command-menu.ts new file mode 100644 index 000000000..22e8897a9 --- /dev/null +++ b/packages/prisma/seed/pr-713-add-document-search-to-command-menu.ts @@ -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, + }, + }, + }, + }); + } +}; diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index 225aa8a9e..fc6ea2377 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -8,6 +8,7 @@ import { duplicateDocumentById } from '@documenso/lib/server-only/document/dupli 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, @@ -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.', + }); + } + }), }); diff --git a/packages/trpc/server/document-router/schema.ts b/packages/trpc/server/document-router/schema.ts index 6728ab36c..71ee9766d 100644 --- a/packages/trpc/server/document-router/schema.ts +++ b/packages/trpc/server/document-router/schema.ts @@ -84,3 +84,7 @@ export const ZDeleteDraftDocumentMutationSchema = z.object({ }); export type TDeleteDraftDocumentMutationSchema = z.infer; + +export const ZSearchDocumentsMutationSchema = z.object({ + query: z.string(), +});