feat: add envelope editor

This commit is contained in:
David Nguyen
2025-10-12 23:35:54 +11:00
parent bf89bc781b
commit 0da8e7dbc6
307 changed files with 24657 additions and 3681 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

@ -25,20 +25,21 @@ 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 envelope = await unsafeGetEntireEnvelope({
id: {
type: 'documentId',
type: 'envelopeId',
id,
},
type: EnvelopeType.DOCUMENT,
@ -57,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: () => {
@ -167,6 +168,12 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
<hr className="my-4" />
<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,24 +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 { 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';
@ -30,93 +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({
id: {
type: 'documentId',
id: 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">
@ -128,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>
@ -171,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={mapFieldsWithRecipients(document.fields, recipients)}
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>
))
@ -208,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',
);
@ -224,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,160 +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({
id: {
type: 'documentId',
id: documentId,
const {
data: envelope,
isLoading: isLoadingEnvelope,
isError: isErrorEnvelope,
} = trpc.envelope.get.useQuery(
{
envelopeId: params.id,
},
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,
{
retry: false,
},
documentRootPath,
});
}
);
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

@ -5,11 +5,10 @@ import { Trans } from '@lingui/react/macro';
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 { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { logDocumentAccess } from '@documenso/lib/utils/logger';
@ -27,24 +26,22 @@ 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 envelope = await getEnvelopeById({
id: {
type: 'documentId',
id: documentId,
type: 'envelopeId',
id,
},
type: EnvelopeType.DOCUMENT,
userId: user.id,
@ -52,18 +49,12 @@ export async function loader({ params, request }: Route.LoaderArgs) {
}).catch(() => null);
if (!envelope) {
throw redirect(documentRootPath);
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,
});
@ -71,6 +62,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
// Only return necessary data
document: {
id: mapSecondaryIdToDocumentId(envelope.secondaryId),
envelopeId: envelope.id,
title: envelope.title,
status: envelope.status,
user: {
@ -81,7 +73,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
updatedAt: envelope.updatedAt,
documentMeta: envelope.documentMeta,
},
recipients,
recipients: envelope.recipients,
documentRootPath,
};
}
@ -140,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

@ -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>
);
}