mirror of
https://github.com/documenso/documenso.git
synced 2025-11-21 20:21:38 +10:00
feat: add envelope editor
This commit is contained in:
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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" />
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user