feat: add envelopes (#2025)

This PR is handles the changes required to support envelopes. The new
envelope editor/signing page will be hidden during release.

The core changes here is to migrate the documents and templates model to
a centralized envelopes model.

Even though Documents and Templates are removed, from the user
perspective they will still exist as we remap envelopes to documents and
templates.
This commit is contained in:
David Nguyen
2025-10-14 21:56:36 +11:00
committed by GitHub
parent 7b17156e56
commit 7f09ba72f4
447 changed files with 33467 additions and 9622 deletions

View File

@ -7,6 +7,7 @@ import { OrganisationProvider } from '@documenso/lib/client-only/providers/organ
import { useSession } from '@documenso/lib/client-only/providers/session';
import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
import { SITE_SETTINGS_BANNER_ID } from '@documenso/lib/server-only/site-settings/schemas/banner';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { AppBanner } from '~/components/general/app-banner';
@ -42,7 +43,7 @@ export async function loader({ request }: Route.LoaderArgs) {
};
}
export default function Layout({ loaderData, params }: Route.ComponentProps) {
export default function Layout({ loaderData, params, matches }: Route.ComponentProps) {
const { banner } = loaderData;
const { user, organisations } = useSession();
@ -71,6 +72,13 @@ export default function Layout({ loaderData, params }: Route.ComponentProps) {
const orgNotFound = params.orgUrl && !currentOrganisation;
const teamNotFound = params.teamUrl && !currentTeam;
// Hide the header for editor routes.
const hideHeader = matches.some(
(match) =>
match?.id === 'routes/_authenticated+/t.$teamUrl+/documents.$id.edit' ||
match?.id === 'routes/_authenticated+/t.$teamUrl+/templates.$id.edit',
);
if (orgNotFound || teamNotFound) {
return (
<GenericErrorLayout
@ -110,9 +118,13 @@ export default function Layout({ loaderData, params }: Route.ComponentProps) {
{banner && <AppBanner banner={banner} />}
<Header />
{!hideHeader && <Header />}
<main className="mt-8 pb-8 md:mt-12 md:pb-12">
<main
className={cn({
'mt-8 pb-8 md:mt-12 md:pb-12': !hideHeader,
})}
>
<Outlet />
</main>
</TeamProvider>

View File

@ -1,11 +1,11 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { SigningStatus } from '@prisma/client';
import { EnvelopeType, SigningStatus } from '@prisma/client';
import { DateTime } from 'luxon';
import { Link, redirect } from 'react-router';
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
import { unsafeGetEntireEnvelope } from '@documenso/lib/server-only/admin/get-entire-document';
import { trpc } from '@documenso/trpc/react';
import {
Accordion,
@ -25,24 +25,31 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { AdminDocumentDeleteDialog } from '~/components/dialogs/admin-document-delete-dialog';
import { DocumentStatus } from '~/components/general/document/document-status';
import { AdminDocumentJobsTable } from '~/components/tables/admin-document-jobs-table';
import { AdminDocumentRecipientItemTable } from '~/components/tables/admin-document-recipient-item-table';
import type { Route } from './+types/documents.$id';
export async function loader({ params }: Route.LoaderArgs) {
const id = Number(params.id);
const id = params.id;
if (isNaN(id)) {
if (!id || !id.startsWith('envelope_')) {
throw redirect('/admin/documents');
}
const document = await getEntireDocument({ id });
const envelope = await unsafeGetEntireEnvelope({
id: {
type: 'envelopeId',
id,
},
type: EnvelopeType.DOCUMENT,
});
return { document };
return { envelope };
}
export default function AdminDocumentDetailsPage({ loaderData }: Route.ComponentProps) {
const { document } = loaderData;
const { envelope } = loaderData;
const { _, i18n } = useLingui();
const { toast } = useToast();
@ -51,8 +58,8 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
trpc.admin.document.reseal.useMutation({
onSuccess: () => {
toast({
title: _(msg`Success`),
description: _(msg`Document resealed`),
title: _(msg`Sealing job started`),
description: _(msg`See the background jobs tab for the status`),
});
},
onError: () => {
@ -68,11 +75,11 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
<div>
<div className="flex items-start justify-between">
<div className="flex items-center gap-x-4">
<h1 className="text-2xl font-semibold">{document.title}</h1>
<DocumentStatus status={document.status} />
<h1 className="text-2xl font-semibold">{envelope.title}</h1>
<DocumentStatus status={envelope.status} />
</div>
{document.deletedAt && (
{envelope.deletedAt && (
<Badge size="large" variant="destructive">
<Trans>Deleted</Trans>
</Badge>
@ -81,11 +88,11 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
<div className="text-muted-foreground mt-4 text-sm">
<div>
<Trans>Created on</Trans>: {i18n.date(document.createdAt, DateTime.DATETIME_MED)}
<Trans>Created on</Trans>: {i18n.date(envelope.createdAt, DateTime.DATETIME_MED)}
</div>
<div>
<Trans>Last updated at</Trans>: {i18n.date(document.updatedAt, DateTime.DATETIME_MED)}
<Trans>Last updated at</Trans>: {i18n.date(envelope.updatedAt, DateTime.DATETIME_MED)}
</div>
</div>
@ -102,12 +109,12 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
<Button
variant="outline"
loading={isResealDocumentLoading}
disabled={document.recipients.some(
disabled={envelope.recipients.some(
(recipient) =>
recipient.signingStatus !== SigningStatus.SIGNED &&
recipient.signingStatus !== SigningStatus.REJECTED,
)}
onClick={() => resealDocument({ id: document.id })}
onClick={() => resealDocument({ id: envelope.id })}
>
<Trans>Reseal document</Trans>
</Button>
@ -123,7 +130,7 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
</TooltipProvider>
<Button variant="outline" asChild>
<Link to={`/admin/users/${document.userId}`}>
<Link to={`/admin/users/${envelope.userId}`}>
<Trans>Go to owner</Trans>
</Link>
</Button>
@ -136,7 +143,7 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
<div className="mt-4">
<Accordion type="multiple" className="space-y-4">
{document.recipients.map((recipient) => (
{envelope.recipients.map((recipient) => (
<AccordionItem
key={recipient.id}
value={recipient.id.toString()}
@ -161,7 +168,13 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
<hr className="my-4" />
{document && <AdminDocumentDeleteDialog document={document} />}
<div className="mt-4">
<AdminDocumentJobsTable envelopeId={envelope.id} />
</div>
<hr className="my-4" />
{envelope && <AdminDocumentDeleteDialog envelopeId={envelope.id} />}
</div>
);
}

View File

@ -64,7 +64,7 @@ export default function AdminDocumentsPage() {
cell: ({ row }) => {
return (
<Link
to={`/admin/documents/${row.original.id}`}
to={`/admin/documents/${row.original.envelopeId}`}
className="block max-w-[5rem] truncate font-medium hover:underline md:max-w-[10rem]"
>
{row.original.title}

View File

@ -1,21 +1,25 @@
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
import { msg } from '@lingui/core/macro';
import { Plural, Trans, useLingui } from '@lingui/react/macro';
import { DocumentStatus } from '@prisma/client';
import { ChevronLeft, Users2 } from 'lucide-react';
import { Link, redirect } from 'react-router';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { logDocumentAccess } from '@documenso/lib/utils/logger';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
import { trpc } from '@documenso/trpc/react';
import {
DocumentReadOnlyFields,
mapFieldsWithRecipients,
} from '@documenso/ui/components/document/document-read-only-fields';
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { Spinner } from '@documenso/ui/primitives/spinner';
import { DocumentPageViewButton } from '~/components/general/document/document-page-view-button';
import { DocumentPageViewDropdown } from '~/components/general/document/document-page-view-dropdown';
@ -27,90 +31,66 @@ import {
DocumentStatus as DocumentStatusComponent,
FRIENDLY_STATUS_MAP,
} from '~/components/general/document/document-status';
import { EnvelopeRendererFileSelector } from '~/components/general/envelope-editor/envelope-file-selector';
import EnvelopeGenericPageRenderer from '~/components/general/envelope-editor/envelope-generic-page-renderer';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import { useCurrentTeam } from '~/providers/team';
import type { Route } from './+types/documents.$id._index';
export async function loader({ params, request }: Route.LoaderArgs) {
const { user } = await getSession(request);
export default function DocumentPage({ params }: Route.ComponentProps) {
const { t } = useLingui();
const { user } = useSession();
const teamUrl = params.teamUrl;
const team = useCurrentTeam();
if (!teamUrl) {
throw new Response('Not Found', { status: 404 });
const {
data: envelope,
isLoading: isLoadingEnvelope,
isError: isErrorEnvelope,
} = trpc.envelope.get.useQuery({
envelopeId: params.id,
});
if (isLoadingEnvelope) {
return (
<div className="text-foreground flex w-screen flex-col items-center justify-center gap-2 py-64">
<Spinner />
<Trans>Loading</Trans>
</div>
);
}
const team = await getTeamByUrl({ userId: user.id, teamUrl });
const { id } = params;
const documentId = Number(id);
if (isErrorEnvelope || !envelope) {
return (
<GenericErrorLayout
errorCode={404}
errorCodeMap={{
404: {
heading: msg`Not found`,
subHeading: msg`404 Not found`,
message: msg`The document you are looking for may have been removed, renamed or may have never
existed.`,
},
}}
primaryButton={
<Button asChild>
<Link to={`/t/${team.url}/documents`}>
<Trans>Go back</Trans>
</Link>
</Button>
}
/>
);
}
const documentRootPath = formatDocumentsPath(team.url);
if (!documentId || Number.isNaN(documentId)) {
throw redirect(documentRootPath);
}
const document = await getDocumentWithDetailsById({
documentId,
userId: user.id,
teamId: team.id,
}).catch(() => null);
// Todo: 401 or 404 page.
if (!document) {
throw redirect(documentRootPath);
}
const documentVisibility = document?.visibility;
const currentTeamMemberRole = team.currentTeamRole;
const isRecipient = document?.recipients.find((recipient) => recipient.email === user.email);
let canAccessDocument = true;
if (!isRecipient && document?.userId !== user.id) {
canAccessDocument = match([documentVisibility, currentTeamMemberRole])
.with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true)
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true)
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MEMBER], () => true)
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.ADMIN], () => true)
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.MANAGER], () => true)
.with([DocumentVisibility.ADMIN, TeamMemberRole.ADMIN], () => true)
.otherwise(() => false);
}
if (!document || !document.documentData || !canAccessDocument) {
throw redirect(documentRootPath);
}
logDocumentAccess({
request,
documentId,
userId: user.id,
});
return superLoaderJson({
document,
documentRootPath,
});
}
export default function DocumentPage() {
const loaderData = useSuperLoaderData<typeof loader>();
const { _ } = useLingui();
const { user } = useSession();
const { document, documentRootPath } = loaderData;
const { recipients, documentData, documentMeta } = document;
return (
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
{document.status === DocumentStatus.PENDING && (
<DocumentRecipientLinkCopyDialog recipients={recipients} />
{envelope.status === DocumentStatus.PENDING && (
<DocumentRecipientLinkCopyDialog recipients={envelope.recipients} />
)}
<Link to={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
@ -122,35 +102,35 @@ export default function DocumentPage() {
<div>
<h1
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
title={document.title}
title={envelope.title}
>
{document.title}
{envelope.title}
</h1>
<div className="mt-2.5 flex items-center gap-x-6">
<DocumentStatusComponent
inheritColor
status={document.status}
status={envelope.status}
className="text-muted-foreground"
/>
{recipients.length > 0 && (
{envelope.recipients.length > 0 && (
<div className="text-muted-foreground flex items-center">
<Users2 className="mr-2 h-5 w-5" />
<StackAvatarsWithTooltip
recipients={recipients}
documentStatus={document.status}
recipients={envelope.recipients}
documentStatus={envelope.status}
position="bottom"
>
<span>
<Trans>{recipients.length} Recipient(s)</Trans>
<Trans>{envelope.recipients.length} Recipient(s)</Trans>
</span>
</StackAvatarsWithTooltip>
</div>
)}
{document.deletedAt && (
{envelope.deletedAt && (
<Badge variant="destructive">
<Trans>Document deleted</Trans>
</Badge>
@ -165,33 +145,47 @@ export default function DocumentPage() {
gradient
>
<CardContent className="p-2">
<PDFViewer document={document} key={documentData.id} documentData={documentData} />
{envelope.internalVersion === 2 ? (
<EnvelopeRenderProvider envelope={envelope} fields={envelope.fields}>
<EnvelopeRendererFileSelector fields={envelope.fields} className="mb-4 p-0" />
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
</EnvelopeRenderProvider>
) : (
<>
{envelope.status !== DocumentStatus.COMPLETED && (
<DocumentReadOnlyFields
fields={mapFieldsWithRecipients(envelope.fields, envelope.recipients)}
documentMeta={envelope.documentMeta || undefined}
showRecipientTooltip={true}
showRecipientColors={true}
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
/>
)}
<PDFViewer
document={envelope}
key={envelope.envelopeItems[0].id}
documentData={envelope.envelopeItems[0].documentData}
/>
</>
)}
</CardContent>
</Card>
{document.status !== DocumentStatus.COMPLETED && (
<DocumentReadOnlyFields
fields={document.fields}
documentMeta={documentMeta || undefined}
showRecipientTooltip={true}
showRecipientColors={true}
recipientIds={recipients.map((recipient) => recipient.id)}
/>
)}
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
<div className="space-y-6">
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
<div className="flex flex-row items-center justify-between px-4">
<h3 className="text-foreground text-2xl font-semibold">
{_(FRIENDLY_STATUS_MAP[document.status].labelExtended)}
{t(FRIENDLY_STATUS_MAP[envelope.status].labelExtended)}
</h3>
<DocumentPageViewDropdown document={document} />
<DocumentPageViewDropdown envelope={envelope} />
</div>
<p className="text-muted-foreground mt-2 px-4 text-sm">
{match(document.status)
{match(envelope.status)
.with(DocumentStatus.COMPLETED, () => (
<Trans>This document has been signed by all recipients</Trans>
))
@ -202,7 +196,7 @@ export default function DocumentPage() {
<Trans>This document is currently a draft and has not been sent</Trans>
))
.with(DocumentStatus.PENDING, () => {
const pendingRecipients = recipients.filter(
const pendingRecipients = envelope.recipients.filter(
(recipient) => recipient.signingStatus === 'NOT_SIGNED',
);
@ -218,18 +212,21 @@ export default function DocumentPage() {
</p>
<div className="mt-4 border-t px-4 pt-4">
<DocumentPageViewButton document={document} />
<DocumentPageViewButton envelope={envelope} />
</div>
</section>
{/* Document information section. */}
<DocumentPageViewInformation document={document} userId={user.id} />
<DocumentPageViewInformation envelope={envelope} userId={user.id} />
{/* Recipients section. */}
<DocumentPageViewRecipients document={document} documentRootPath={documentRootPath} />
<DocumentPageViewRecipients envelope={envelope} documentRootPath={documentRootPath} />
{/* Recent activity section. */}
<DocumentPageViewRecentActivity documentId={document.id} userId={user.id} />
<DocumentPageViewRecentActivity
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
userId={user.id}
/>
</div>
</div>
</div>

View File

@ -0,0 +1,90 @@
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { ChevronLeftIcon } from 'lucide-react';
import { Link, Outlet, isRouteErrorResponse, redirect } from 'react-router';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { Button } from '@documenso/ui/primitives/button';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import type { Route } from './+types/settings._layout';
/**
* This file is very similar for templates as well. Any changes here should also be adjusted there as well.
*
* File: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._layout.tsx
*/
export async function loader({ request, params }: Route.LoaderArgs) {
const { id } = params;
const documentId = Number(id);
// If ID is a number, redirect to use envelope ID instead.
if (!Number.isNaN(documentId)) {
const { user } = await getSession(request);
const team = await getTeamByUrl({
userId: user.id,
teamUrl: params.teamUrl,
});
const envelope = await getEnvelopeById({
id: {
type: 'documentId',
id: documentId,
},
type: EnvelopeType.DOCUMENT,
userId: user.id,
teamId: team.id,
}).catch((err) => {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.NOT_FOUND) {
throw new Response('Not Found', { status: 404 });
}
throw err;
});
const url = new URL(request.url);
throw redirect(url.pathname.replace(`/documents/${id}`, `/documents/${envelope.id}`));
}
}
export default function DocumentsLayout() {
return <Outlet />;
}
export function ErrorBoundary({ error, params }: Route.ErrorBoundaryProps) {
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
const errorCodeMap = {
404: {
subHeading: msg`404 Document not found`,
heading: msg`Oops! Something went wrong.`,
message: msg`The document you are looking for could not be found.`,
},
};
return (
<GenericErrorLayout
errorCode={errorCode}
errorCodeMap={errorCodeMap}
secondaryButton={null}
primaryButton={
<Button asChild className="w-32">
<Link to={`/t/${params.teamUrl}/documents`}>
<ChevronLeftIcon className="mr-2 h-4 w-4" />
<Trans>Go Back</Trans>
</Link>
</Button>
}
/>
);
}

View File

@ -1,157 +1,107 @@
import { Plural, Trans } from '@lingui/react/macro';
import { TeamMemberRole } from '@prisma/client';
import { ChevronLeft, Users2 } from 'lucide-react';
import { Link, redirect } from 'react-router';
import { match } from 'ts-pattern';
import { useEffect } from 'react';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { logDocumentAccess } from '@documenso/lib/utils/logger';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { Link, useNavigate } from 'react-router';
import { DocumentEditForm } from '~/components/general/document/document-edit-form';
import { DocumentStatus } from '~/components/general/document/document-status';
import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover';
import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import { EnvelopeEditorProvider } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Spinner } from '@documenso/ui/primitives/spinner';
import EnvelopeEditor from '~/components/general/envelope-editor/envelope-editor';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { useCurrentTeam } from '~/providers/team';
import type { Route } from './+types/documents.$id.edit';
export async function loader({ params, request }: Route.LoaderArgs) {
const { user } = await getSession(request);
export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
const navigate = useNavigate();
const team = useCurrentTeam();
const teamUrl = params.teamUrl;
if (!teamUrl) {
throw new Response('Not Found', { status: 404 });
}
const team = await getTeamByUrl({ userId: user.id, teamUrl });
const { id } = params;
const documentId = Number(id);
const documentRootPath = formatDocumentsPath(team.url);
if (!documentId || Number.isNaN(documentId)) {
throw redirect(documentRootPath);
}
const document = await getDocumentWithDetailsById({
documentId,
userId: user.id,
teamId: team.id,
}).catch(() => null);
if (document?.teamId && !team?.url) {
throw redirect(documentRootPath);
}
const documentVisibility = document?.visibility;
const currentTeamMemberRole = team.currentTeamRole;
const isRecipient = document?.recipients.find((recipient) => recipient.email === user.email);
let canAccessDocument = true;
if (!isRecipient && document?.userId !== user.id) {
canAccessDocument = match([documentVisibility, currentTeamMemberRole])
.with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true)
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true)
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MEMBER], () => true)
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.ADMIN], () => true)
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.MANAGER], () => true)
.with([DocumentVisibility.ADMIN, TeamMemberRole.ADMIN], () => true)
.otherwise(() => false);
}
if (!document) {
throw redirect(documentRootPath);
}
if (team && !canAccessDocument) {
throw redirect(documentRootPath);
}
if (isDocumentCompleted(document.status)) {
throw redirect(`${documentRootPath}/${documentId}`);
}
logDocumentAccess({
request,
documentId,
userId: user.id,
});
return superLoaderJson({
document: {
...document,
folder: null,
const {
data: envelope,
isLoading: isLoadingEnvelope,
isError: isErrorEnvelope,
} = trpc.envelope.get.useQuery(
{
envelopeId: params.id,
},
documentRootPath,
});
}
{
retry: false,
},
);
export default function DocumentEditPage() {
const { document, documentRootPath } = useSuperLoaderData<typeof loader>();
/**
* Need to handle redirecting to legacy editor on the client side to reduce server
* requests for the majority use case.
*/
useEffect(() => {
if (!envelope) {
return;
}
const { recipients } = document;
const pathPrefix =
envelope.type === EnvelopeType.DOCUMENT
? formatDocumentsPath(team.url)
: formatTemplatesPath(team.url);
if (envelope.teamId !== team.id) {
void navigate(pathPrefix, { replace: true });
} else if (envelope.internalVersion !== 2) {
void navigate(`${pathPrefix}/${envelope.id}/legacy_editor`, { replace: true });
}
}, [envelope, team, navigate]);
if (envelope && (envelope.teamId !== team.id || envelope.internalVersion !== 2)) {
return (
<div className="text-foreground flex h-screen w-screen flex-col items-center justify-center gap-2">
<Spinner />
<Trans>Redirecting</Trans>
</div>
);
}
if (isLoadingEnvelope) {
return (
<div className="text-foreground flex h-screen w-screen flex-col items-center justify-center gap-2">
<Spinner />
<Trans>Loading</Trans>
</div>
);
}
if (isErrorEnvelope || !envelope) {
return (
<GenericErrorLayout
errorCode={404}
errorCodeMap={{
404: {
heading: msg`Not found`,
subHeading: msg`404 Not found`,
message: msg`The document you are looking for may have been removed, renamed or may have never
existed.`,
},
}}
primaryButton={
<Button asChild>
<Link to={`/t/${team.url}/documents`}>
<Trans>Go home</Trans>
</Link>
</Button>
}
/>
);
}
return (
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
<Link to={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
<Trans>Documents</Trans>
</Link>
<div className="mt-4 flex w-full items-end justify-between">
<div className="flex-1">
<h1
className="block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
title={document.title}
>
{document.title}
</h1>
<div className="mt-2.5 flex items-center gap-x-6">
<DocumentStatus
inheritColor
status={document.status}
className="text-muted-foreground"
/>
{recipients.length > 0 && (
<div className="text-muted-foreground flex items-center">
<Users2 className="mr-2 h-5 w-5" />
<StackAvatarsWithTooltip
recipients={recipients}
documentStatus={document.status}
position="bottom"
>
<span>
<Plural one="1 Recipient" other="# Recipients" value={recipients.length} />
</span>
</StackAvatarsWithTooltip>
</div>
)}
</div>
</div>
{document.useLegacyFieldInsertion && (
<div>
<LegacyFieldWarningPopover type="document" documentId={document.id} />
</div>
)}
</div>
<DocumentEditForm
className="mt-6"
initialDocument={document}
documentRootPath={documentRootPath}
/>
</div>
<EnvelopeEditorProvider initialEnvelope={envelope}>
<EnvelopeRenderProvider envelope={envelope}>
<EnvelopeEditor />
</EnvelopeRenderProvider>
</EnvelopeEditorProvider>
);
}

View File

@ -0,0 +1,139 @@
import { Plural, Trans } from '@lingui/react/macro';
import { ChevronLeft, Users2 } from 'lucide-react';
import { Link, redirect } from 'react-router';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { logDocumentAccess } from '@documenso/lib/utils/logger';
import { canAccessTeamDocument, formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentEditForm } from '~/components/general/document/document-edit-form';
import { DocumentStatus } from '~/components/general/document/document-status';
import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover';
import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/documents.$id.edit';
export async function loader({ params, request }: Route.LoaderArgs) {
const { id, teamUrl } = params;
if (!id || !teamUrl) {
throw new Response('Not Found', { status: 404 });
}
const { user } = await getSession(request);
const team = await getTeamByUrl({ userId: user.id, teamUrl });
const documentRootPath = formatDocumentsPath(team.url);
const document = await getDocumentWithDetailsById({
id: {
type: 'envelopeId',
id,
},
userId: user.id,
teamId: team.id,
}).catch(() => null);
if (!document) {
throw new Response('Not Found', { status: 404 });
}
const documentVisibility = document.visibility;
const currentTeamMemberRole = team.currentTeamRole;
const isRecipient = document.recipients.find((recipient) => recipient.email === user.email);
let canAccessDocument = true;
if (!isRecipient && document.userId !== user.id) {
canAccessDocument = canAccessTeamDocument(currentTeamMemberRole, documentVisibility);
}
if (!canAccessDocument) {
throw new Response('Not Found', { status: 404 });
}
if (isDocumentCompleted(document.status)) {
throw redirect(`${documentRootPath}/${id}`);
}
logDocumentAccess({
request,
documentId: document.id,
userId: user.id,
});
return superLoaderJson({
document: {
...document,
folder: null,
},
documentRootPath,
});
}
export default function DocumentEditPage() {
const { document, documentRootPath } = useSuperLoaderData<typeof loader>();
const { recipients } = document;
return (
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
<Link to={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
<Trans>Documents</Trans>
</Link>
<div className="mt-4 flex w-full items-end justify-between">
<div className="flex-1">
<h1
className="block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
title={document.title}
>
{document.title}
</h1>
<div className="mt-2.5 flex items-center gap-x-6">
<DocumentStatus
inheritColor
status={document.status}
className="text-muted-foreground"
/>
{recipients.length > 0 && (
<div className="text-muted-foreground flex items-center">
<Users2 className="mr-2 h-5 w-5" />
<StackAvatarsWithTooltip
recipients={recipients}
documentStatus={document.status}
position="bottom"
>
<span>
<Plural one="1 Recipient" other="# Recipients" value={recipients.length} />
</span>
</StackAvatarsWithTooltip>
</div>
)}
</div>
</div>
{document.useLegacyFieldInsertion && (
<div>
<LegacyFieldWarningPopover type="document" documentId={document.id} />
</div>
)}
</div>
<DocumentEditForm
className="mt-6"
initialDocument={document}
documentRootPath={documentRootPath}
/>
</div>
);
}

View File

@ -2,15 +2,15 @@ import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { Recipient } from '@prisma/client';
import { EnvelopeType, type Recipient } from '@prisma/client';
import { ChevronLeft } from 'lucide-react';
import { DateTime } from 'luxon';
import { Link, redirect } from 'react-router';
import { Link } from 'react-router';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { logDocumentAccess } from '@documenso/lib/utils/logger';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { Card } from '@documenso/ui/primitives/card';
@ -26,45 +26,54 @@ import { DocumentLogsTable } from '~/components/tables/document-logs-table';
import type { Route } from './+types/documents.$id.logs';
export async function loader({ params, request }: Route.LoaderArgs) {
const { id, teamUrl } = params;
if (!id || !teamUrl) {
throw new Response('Not Found', { status: 404 });
}
const { user } = await getSession(request);
const team = await getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl });
const { id } = params;
const documentId = Number(id);
const team = await getTeamByUrl({ userId: user.id, teamUrl });
const documentRootPath = formatDocumentsPath(team.url);
if (!documentId || Number.isNaN(documentId)) {
throw redirect(documentRootPath);
}
const document = await getDocumentById({
documentId,
const envelope = await getEnvelopeById({
id: {
type: 'envelopeId',
id,
},
type: EnvelopeType.DOCUMENT,
userId: user.id,
teamId: team?.id,
teamId: team.id,
}).catch(() => null);
if (!document || !document.documentData) {
throw redirect(documentRootPath);
if (!envelope) {
throw new Response('Not Found', { status: 404 });
}
const recipients = await getRecipientsForDocument({
documentId,
userId: user.id,
teamId: team?.id,
});
logDocumentAccess({
request,
documentId,
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
userId: user.id,
});
return {
document,
recipients,
// Only return necessary data
document: {
id: mapSecondaryIdToDocumentId(envelope.secondaryId),
envelopeId: envelope.id,
title: envelope.title,
status: envelope.status,
user: {
name: envelope.user.name,
email: envelope.user.email,
},
createdAt: envelope.createdAt,
updatedAt: envelope.updatedAt,
documentMeta: envelope.documentMeta,
},
recipients: envelope.recipients,
documentRootPath,
};
}
@ -123,7 +132,7 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps)
return (
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
<Link
to={`${documentRootPath}/${document.id}`}
to={`${documentRootPath}/${document.envelopeId}`}
className="flex items-center text-[#7AC455] hover:opacity-80"
>
<ChevronLeft className="mr-2 inline-block h-5 w-5" />

View File

@ -108,6 +108,7 @@ export default function DocumentsPage() {
}
}, [data?.stats]);
// Todo: Envelopes - Change the dropzone wrapper to create to V2 documents after we're ready.
return (
<DocumentDropZoneWrapper>
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">

View File

@ -2,8 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import type { TemplateDirectLink } from '@prisma/client';
import { TemplateType } from '@prisma/client';
import { type TemplateDirectLink, TemplateType } from '@prisma/client';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { useSession } from '@documenso/lib/client-only/providers/session';

View File

@ -1,20 +1,27 @@
import { Trans } from '@lingui/react/macro';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentSigningOrder, SigningStatus } from '@prisma/client';
import { ChevronLeft, LucideEdit } from 'lucide-react';
import { Link, redirect, useNavigate } from 'react-router';
import { Link, useNavigate } from 'react-router';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { Spinner } from '@documenso/ui/primitives/spinner';
import { TemplateBulkSendDialog } from '~/components/dialogs/template-bulk-send-dialog';
import { TemplateDirectLinkDialogWrapper } from '~/components/dialogs/template-direct-link-dialog-wrapper';
import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog';
import { TemplateUseDialog } from '~/components/dialogs/template-use-dialog';
import { EnvelopeRendererFileSelector } from '~/components/general/envelope-editor/envelope-file-selector';
import EnvelopeGenericPageRenderer from '~/components/general/envelope-editor/envelope-generic-page-renderer';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { TemplateDirectLinkBadge } from '~/components/general/template/template-direct-link-badge';
import { TemplatePageViewDocumentsTable } from '~/components/general/template/template-page-view-documents-table';
import { TemplatePageViewInformation } from '~/components/general/template/template-page-view-information';
@ -22,55 +29,65 @@ import { TemplatePageViewRecentActivity } from '~/components/general/template/te
import { TemplatePageViewRecipients } from '~/components/general/template/template-page-view-recipients';
import { TemplateType } from '~/components/general/template/template-type';
import { TemplatesTableActionDropdown } from '~/components/tables/templates-table-action-dropdown';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import { useCurrentTeam } from '~/providers/team';
import type { Route } from './+types/templates.$id._index';
export async function loader({ params, request }: Route.LoaderArgs) {
const { user } = await getSession(request);
const team = await getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl });
const { id } = params;
const templateId = Number(id);
const templateRootPath = formatTemplatesPath(team.url);
const documentRootPath = formatDocumentsPath(team.url);
if (!templateId || Number.isNaN(templateId)) {
throw redirect(templateRootPath);
}
const template = await getTemplateById({
id: templateId,
userId: user.id,
teamId: team?.id,
}).catch(() => null);
if (!template || !template.templateDocumentData || (template?.teamId && !team.url)) {
throw redirect(templateRootPath);
}
return superLoaderJson({
user,
team,
template,
templateRootPath,
documentRootPath,
});
}
export default function TemplatePage() {
const { user, team, template, templateRootPath, documentRootPath } =
useSuperLoaderData<typeof loader>();
const { templateDocumentData, fields, recipients, templateMeta } = template;
export default function TemplatePage({ params }: Route.ComponentProps) {
const { t } = useLingui();
const { user } = useSession();
const navigate = useNavigate();
const team = useCurrentTeam();
const {
data: envelope,
isLoading: isLoadingEnvelope,
isError: isErrorEnvelope,
} = trpc.envelope.get.useQuery({
envelopeId: params.id,
});
if (isLoadingEnvelope) {
return (
<div className="text-foreground flex w-screen flex-col items-center justify-center gap-2 py-64">
<Spinner />
<Trans>Loading</Trans>
</div>
);
}
if (isErrorEnvelope || !envelope) {
return (
<GenericErrorLayout
errorCode={404}
errorCodeMap={{
404: {
heading: msg`Not found`,
subHeading: msg`404 Not found`,
message: msg`The template you are looking for may have been removed, renamed or may have never
existed.`,
},
}}
primaryButton={
<Button asChild>
<Link to={`/t/${team.url}/templates`}>
<Trans>Go back</Trans>
</Link>
</Button>
}
/>
);
}
const documentRootPath = formatDocumentsPath(team.url);
const templateRootPath = formatTemplatesPath(team.url);
// Remap to fit the DocumentReadOnlyFields component.
const readOnlyFields = fields.map((field) => {
const recipient = recipients.find((recipient) => recipient.id === field.recipientId) || {
const readOnlyFields = envelope.fields.map((field) => {
const recipient = envelope.recipients.find(
(recipient) => recipient.id === field.recipientId,
) || {
name: '',
email: '',
signingStatus: SigningStatus.NOT_SIGNED,
@ -83,10 +100,10 @@ export default function TemplatePage() {
};
});
const mockedDocumentMeta = templateMeta
const mockedDocumentMeta = envelope.documentMeta
? {
...templateMeta,
signingOrder: templateMeta.signingOrder || DocumentSigningOrder.SEQUENTIAL,
...envelope.documentMeta,
signingOrder: envelope.documentMeta.signingOrder || DocumentSigningOrder.SEQUENTIAL,
documentId: 0,
}
: undefined;
@ -102,31 +119,42 @@ export default function TemplatePage() {
<div>
<h1
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
title={template.title}
title={envelope.title}
>
{template.title}
{envelope.title}
</h1>
<div className="mt-2.5 flex items-center">
<TemplateType inheritColor className="text-muted-foreground" type={template.type} />
<TemplateType
inheritColor
className="text-muted-foreground"
type={envelope.templateType}
/>
{template.directLink?.token && (
{envelope.directLink?.token && (
<TemplateDirectLinkBadge
className="ml-4"
token={template.directLink.token}
enabled={template.directLink.enabled}
token={envelope.directLink.token}
enabled={envelope.directLink.enabled}
/>
)}
</div>
</div>
<div className="mt-2 flex flex-row space-x-4 sm:mt-0 sm:self-end">
<TemplateDirectLinkDialogWrapper template={template} />
<TemplateDirectLinkDialog
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
directLink={envelope.directLink}
recipients={envelope.recipients}
/>
<TemplateBulkSendDialog templateId={template.id} recipients={template.recipients} />
<TemplateBulkSendDialog
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
recipients={envelope.recipients}
/>
<Button className="w-full" asChild>
<Link to={`${templateRootPath}/${template.id}/edit`}>
<Link to={`${templateRootPath}/${envelope.id}/edit`}>
<LucideEdit className="mr-1.5 h-3.5 w-3.5" />
<Trans>Edit Template</Trans>
</Link>
@ -140,19 +168,33 @@ export default function TemplatePage() {
gradient
>
<CardContent className="p-2">
<PDFViewer document={template} key={template.id} documentData={templateDocumentData} />
{envelope.internalVersion === 2 ? (
<EnvelopeRenderProvider envelope={envelope} fields={envelope.fields}>
<EnvelopeRendererFileSelector fields={envelope.fields} className="mb-4 p-0" />
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
</EnvelopeRenderProvider>
) : (
<>
<DocumentReadOnlyFields
fields={readOnlyFields}
showFieldStatus={false}
showRecipientTooltip={true}
showRecipientColors={true}
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
documentMeta={mockedDocumentMeta}
/>
<PDFViewer
document={envelope}
key={envelope.envelopeItems[0].id}
documentData={envelope.envelopeItems[0].documentData}
/>
</>
)}
</CardContent>
</Card>
<DocumentReadOnlyFields
fields={readOnlyFields}
showFieldStatus={false}
showRecipientTooltip={true}
showRecipientColors={true}
recipientIds={recipients.map((recipient) => recipient.id)}
documentMeta={mockedDocumentMeta}
/>
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
<div className="space-y-6">
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
@ -163,7 +205,11 @@ export default function TemplatePage() {
<div>
<TemplatesTableActionDropdown
row={template}
row={{
...envelope,
id: mapSecondaryIdToTemplateId(envelope.secondaryId),
envelopeId: envelope.id,
}}
teamId={team?.id}
templateRootPath={templateRootPath}
onDelete={async () => navigate(templateRootPath)}
@ -177,9 +223,9 @@ export default function TemplatePage() {
<div className="mt-4 border-t px-4 pt-4">
<TemplateUseDialog
templateId={template.id}
templateSigningOrder={template.templateMeta?.signingOrder}
recipients={template.recipients}
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
templateSigningOrder={envelope.documentMeta?.signingOrder}
recipients={envelope.recipients}
documentRootPath={documentRootPath}
trigger={
<Button className="w-full">
@ -191,15 +237,19 @@ export default function TemplatePage() {
</section>
{/* Template information section. */}
<TemplatePageViewInformation template={template} userId={user.id} />
<TemplatePageViewInformation template={envelope} userId={user.id} />
{/* Recipients section. */}
<TemplatePageViewRecipients template={template} templateRootPath={templateRootPath} />
<TemplatePageViewRecipients
recipients={envelope.recipients}
envelopeId={envelope.id}
templateRootPath={templateRootPath}
/>
{/* Recent activity section. */}
<TemplatePageViewRecentActivity
documentRootPath={documentRootPath}
templateId={template.id}
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
/>
</div>
</div>
@ -210,7 +260,9 @@ export default function TemplatePage() {
<Trans>Documents created from template</Trans>
</h1>
<TemplatePageViewDocumentsTable templateId={template.id} />
<TemplatePageViewDocumentsTable
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
/>
</div>
</div>
);

View File

@ -0,0 +1,90 @@
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { ChevronLeftIcon } from 'lucide-react';
import { Link, Outlet, isRouteErrorResponse, redirect } from 'react-router';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { Button } from '@documenso/ui/primitives/button';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import type { Route } from './+types/settings._layout';
/**
* This file is very similar for documents as well. Any changes here should also be adjusted there as well.
*
* File: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._layout.tsx
*/
export async function loader({ request, params }: Route.LoaderArgs) {
const { id } = params;
const templateId = Number(id);
// If ID is a number, redirect to use envelope ID instead.
if (!Number.isNaN(templateId)) {
const { user } = await getSession(request);
const team = await getTeamByUrl({
userId: user.id,
teamUrl: params.teamUrl,
});
const envelope = await getEnvelopeById({
id: {
type: 'templateId',
id: templateId,
},
type: EnvelopeType.TEMPLATE,
userId: user.id,
teamId: team.id,
}).catch((err) => {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.NOT_FOUND) {
throw new Response('Not Found', { status: 404 });
}
throw err;
});
const url = new URL(request.url);
throw redirect(url.pathname.replace(`/templates/${id}`, `/templates/${envelope.id}`));
}
}
export default function TemplatesLayout() {
return <Outlet />;
}
export function ErrorBoundary({ error, params }: Route.ErrorBoundaryProps) {
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
const errorCodeMap = {
404: {
subHeading: msg`404 Template not found`,
heading: msg`Oops! Something went wrong.`,
message: msg`The template you are looking for could not be found.`,
},
};
return (
<GenericErrorLayout
errorCode={errorCode}
errorCodeMap={errorCodeMap}
secondaryButton={null}
primaryButton={
<Button asChild className="w-32">
<Link to={`/t/${params.teamUrl}/templates`}>
<ChevronLeftIcon className="mr-2 h-4 w-4" />
<Trans>Go Back</Trans>
</Link>
</Button>
}
/>
);
}

View File

@ -1,108 +1,3 @@
import { Trans } from '@lingui/react/macro';
import { ChevronLeft } from 'lucide-react';
import { Link, redirect } from 'react-router';
import EnvelopeEditorPage from './documents.$id.edit';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { TemplateDirectLinkDialogWrapper } from '~/components/dialogs/template-direct-link-dialog-wrapper';
import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover';
import { TemplateDirectLinkBadge } from '~/components/general/template/template-direct-link-badge';
import { TemplateEditForm } from '~/components/general/template/template-edit-form';
import { TemplateType } from '~/components/general/template/template-type';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/templates.$id.edit';
export async function loader({ params, request }: Route.LoaderArgs) {
const { user } = await getSession(request);
const team: TGetTeamByUrlResponse = await getTeamByUrl({
userId: user.id,
teamUrl: params.teamUrl,
});
const { id } = params;
const templateId = Number(id);
const templateRootPath = formatTemplatesPath(team?.url);
if (!templateId || Number.isNaN(templateId)) {
throw redirect(templateRootPath);
}
const template = await getTemplateById({
id: templateId,
userId: user.id,
teamId: team?.id,
}).catch(() => null);
if (!template || !template.templateDocumentData) {
throw redirect(templateRootPath);
}
return superLoaderJson({
template: {
...template,
folder: null,
},
templateRootPath,
});
}
export default function TemplateEditPage() {
const { template, templateRootPath } = useSuperLoaderData<typeof loader>();
return (
<div className="mx-auto -mt-4 max-w-screen-xl px-4 md:px-8">
<div className="flex flex-col justify-between sm:flex-row">
<div>
<Link
to={`${templateRootPath}/${template.id}`}
className="flex items-center text-[#7AC455] hover:opacity-80"
>
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
<Trans>Template</Trans>
</Link>
<h1
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
title={template.title}
>
{template.title}
</h1>
<div className="mt-2.5 flex items-center">
<TemplateType inheritColor className="text-muted-foreground" type={template.type} />
{template.directLink?.token && (
<TemplateDirectLinkBadge
className="ml-4"
token={template.directLink.token}
enabled={template.directLink.enabled}
/>
)}
</div>
</div>
<div className="mt-2 flex items-center gap-2 sm:mt-0 sm:self-end">
<TemplateDirectLinkDialogWrapper template={template} />
{template.useLegacyFieldInsertion && (
<div>
<LegacyFieldWarningPopover type="template" templateId={template.id} />
</div>
)}
</div>
</div>
<TemplateEditForm
className="mt-6"
initialTemplate={template}
templateRootPath={templateRootPath}
/>
</div>
);
}
export default EnvelopeEditorPage;

View File

@ -0,0 +1,111 @@
import { Trans } from '@lingui/react/macro';
import { ChevronLeft } from 'lucide-react';
import { Link, redirect } from 'react-router';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog';
import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover';
import { TemplateDirectLinkBadge } from '~/components/general/template/template-direct-link-badge';
import { TemplateEditForm } from '~/components/general/template/template-edit-form';
import { TemplateType } from '~/components/general/template/template-type';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/templates.$id.edit';
export async function loader({ params, request }: Route.LoaderArgs) {
const { id, teamUrl } = params;
if (!id || !teamUrl) {
throw new Response('Not Found', { status: 404 });
}
const { user } = await getSession(request);
const team = await getTeamByUrl({ userId: user.id, teamUrl });
const templateRootPath = formatTemplatesPath(team.url);
const template = await getTemplateById({
id: {
type: 'envelopeId',
id,
},
userId: user.id,
teamId: team.id,
}).catch(() => null);
if (!template || !template.templateDocumentData) {
throw redirect(templateRootPath);
}
return superLoaderJson({
template: {
...template,
folder: null,
},
templateRootPath,
});
}
export default function TemplateEditPage() {
const { template, templateRootPath } = useSuperLoaderData<typeof loader>();
return (
<div className="mx-auto -mt-4 max-w-screen-xl px-4 md:px-8">
<div className="flex flex-col justify-between sm:flex-row">
<div>
<Link
to={`${templateRootPath}/${template.envelopeId}`}
className="flex items-center text-[#7AC455] hover:opacity-80"
>
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
<Trans>Template</Trans>
</Link>
<h1
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
title={template.title}
>
{template.title}
</h1>
<div className="mt-2.5 flex items-center">
<TemplateType inheritColor className="text-muted-foreground" type={template.type} />
{template.directLink?.token && (
<TemplateDirectLinkBadge
className="ml-4"
token={template.directLink.token}
enabled={template.directLink.enabled}
/>
)}
</div>
</div>
<div className="mt-2 flex items-center gap-2 sm:mt-0 sm:self-end">
<TemplateDirectLinkDialog
templateId={template.id}
directLink={template.directLink}
recipients={template.recipients}
/>
{template.useLegacyFieldInsertion && (
<div>
<LegacyFieldWarningPopover type="template" templateId={template.id} />
</div>
)}
</div>
</div>
<TemplateEditForm
className="mt-6"
initialTemplate={template}
templateRootPath={templateRootPath}
/>
</div>
);
}