Compare commits

...
13 changed files with 1363 additions and 1040 deletions
@@ -21,6 +21,7 @@ export type DocumentAttachmentsPopoverProps = {
envelopeId: string;
buttonClassName?: string;
buttonSize?: 'sm' | 'default';
collapseLabelOnMobile?: boolean;
};
const ZAttachmentFormSchema = z.object({
@@ -35,6 +36,7 @@ export const DocumentAttachmentsPopover = ({
envelopeId,
buttonClassName,
buttonSize,
collapseLabelOnMobile = false,
}: DocumentAttachmentsPopoverProps) => {
const { toast } = useToast();
const { _ } = useLingui();
@@ -129,7 +131,7 @@ export const DocumentAttachmentsPopover = ({
<Button variant="outline" className={cn('gap-2', buttonClassName)} size={buttonSize}>
<Paperclip className="h-4 w-4" />
<span>
<span className={cn(collapseLabelOnMobile && 'sr-only sm:not-sr-only')}>
<Trans>Attachments</Trans>
{attachments && attachments.data.length > 0 && <span className="ml-1">({attachments.data.length})</span>}
</span>
@@ -19,6 +19,7 @@ import { z } from 'zod';
export type EmbeddedEditorAttachmentPopoverProps = {
buttonClassName?: string;
buttonSize?: 'sm' | 'default';
collapseLabelOnMobile?: boolean;
};
const ZAttachmentFormSchema = z.object({
@@ -32,6 +33,7 @@ type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>;
export const EmbeddedEditorAttachmentPopover = ({
buttonClassName,
buttonSize,
collapseLabelOnMobile = false,
}: EmbeddedEditorAttachmentPopoverProps) => {
const { toast } = useToast();
const { _ } = useLingui();
@@ -90,7 +92,7 @@ export const EmbeddedEditorAttachmentPopover = ({
<Button variant="outline" className={cn('gap-2', buttonClassName)} size={buttonSize}>
<Paperclip className="h-4 w-4" />
<span>
<span className={cn(collapseLabelOnMobile && 'sr-only sm:not-sr-only')}>
<Trans>Attachments</Trans>
{attachments.length > 0 && <span className="ml-1">({attachments.length})</span>}
</span>
@@ -24,6 +24,7 @@ import {
UserIcon,
} from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
const MIN_HEIGHT_PX = 12;
const MIN_WIDTH_PX = 36;
@@ -88,11 +89,13 @@ export const fieldButtonList = [
type EnvelopeEditorFieldDragDropProps = {
selectedRecipientId: number | null;
selectedEnvelopeItemId: string | null;
onFieldSelect?: (fieldType: FieldType) => void;
};
export const EnvelopeEditorFieldDragDrop = ({
selectedRecipientId,
selectedEnvelopeItemId,
onFieldSelect,
}: EnvelopeEditorFieldDragDropProps) => {
const { envelope, editorFields, isTemplate, getRecipientColorKey } = useCurrentEnvelopeEditor();
@@ -245,7 +248,10 @@ export const EnvelopeEditorFieldDragDrop = ({
disabled={isFieldsDisabled}
key={field.type}
type="button"
onClick={() => setSelectedField(field.type)}
onClick={() => {
setSelectedField(field.type);
onFieldSelect?.(field.type);
}}
onMouseDown={() => setSelectedField(field.type)}
data-selected={selectedField === field.type ? true : undefined}
className={cn(
@@ -267,27 +273,35 @@ export const EnvelopeEditorFieldDragDrop = ({
))}
</div>
{selectedField && (
<div
className={cn(
'pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white font-noto text-muted-foreground ring-2 transition duration-200 [container-type:size] dark:text-muted',
selectedRecipientStyles.base,
selectedField === FieldType.SIGNATURE && 'font-signature',
{
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
'dark:text-black/60': isFieldWithinBounds,
},
)}
style={{
top: coords.y,
left: coords.x,
height: fieldBounds.current.height,
width: fieldBounds.current.width,
}}
>
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">{t(FRIENDLY_FIELD_TYPE[selectedField])}</span>
</div>
)}
{/*
* Portalled to the body because the fields panel ancestor carries a CSS
* transform (the slide-in overlay below `md`), which would otherwise
* become the containing block for this fixed-position preview and pin
* it inside the panel instead of the viewport.
*/}
{selectedField &&
createPortal(
<div
className={cn(
'pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white font-noto text-muted-foreground ring-2 transition duration-200 [container-type:size] dark:text-muted',
selectedRecipientStyles.base,
selectedField === FieldType.SIGNATURE && 'font-signature',
{
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
'dark:text-black/60': isFieldWithinBounds,
},
)}
style={{
top: coords.y,
left: coords.x,
height: fieldBounds.current.height,
width: fieldBounds.current.width,
}}
>
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">{t(FRIENDLY_FIELD_TYPE[selectedField])}</span>
</div>,
document.body,
)}
</>
);
};
@@ -30,7 +30,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
import { AlertTriangleIcon, FileTextIcon, PencilIcon, SparklesIcon } from 'lucide-react';
import { AlertTriangleIcon, FileTextIcon, MousePointerClickIcon, PencilIcon, SparklesIcon, XIcon } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useRevalidator, useSearchParams } from 'react-router';
import { isDeepEqual } from 'remeda';
@@ -89,6 +89,8 @@ export const EnvelopeEditorFieldsPage = () => {
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
const { revalidate } = useRevalidator();
const [isMobileFieldsPanelOpen, setIsMobileFieldsPanelOpen] = useState(false);
const envelopeItemPermissions = useMemo(
() => getEnvelopeItemPermissions(envelope, envelope.recipients),
[envelope, envelope.recipients],
@@ -187,6 +189,8 @@ export const EnvelopeEditorFieldsPage = () => {
}, []);
const onDetectClick = () => {
setIsMobileFieldsPanelOpen(false);
if (!team.preferences.aiFeaturesEnabled) {
setIsAiEnableDialogOpen(true);
return;
@@ -308,212 +312,259 @@ export const EnvelopeEditorFieldsPage = () => {
{/* Right Section - Form Fields Panel */}
{currentEnvelopeItem && envelope.recipients.length > 0 && (
<div className="sticky top-0 h-full w-80 flex-shrink-0 overflow-y-auto border-border border-l bg-background py-4">
{/* Recipient selector section. */}
<section className="px-4">
<h3 className="mb-2 font-semibold text-foreground text-sm">
<Trans>Selected Recipient</Trans>
</h3>
<>
{/* Floating trigger for the fields panel, visible below the `md` breakpoint. Offset to clear the step bar. */}
<Button
type="button"
className="fixed right-4 bottom-[4.5rem] z-30 shadow-lg md:hidden"
onClick={() => setIsMobileFieldsPanelOpen(true)}
>
<MousePointerClickIcon className="mr-2 h-4 w-4" />
<Trans>Fields</Trans>
</Button>
<EnvelopeRecipientSelector
selectedRecipient={editorFields.selectedRecipient}
onSelectedRecipientChange={(recipient) => editorFields.setSelectedRecipient(recipient.id)}
recipients={envelope.recipients}
fields={envelope.fields}
className="w-full"
align="end"
{isMobileFieldsPanelOpen && (
<div
role="presentation"
className="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm md:hidden"
onClick={() => setIsMobileFieldsPanelOpen(false)}
/>
)}
{editorFields.selectedRecipient &&
!canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields) && (
<Alert className="mt-4" variant="warning">
<AlertDescription>
<Trans>
This recipient can no longer be modified as they have signed a field, or completed the document.
</Trans>
</AlertDescription>
</Alert>
)}
</section>
<Separator className="my-4" />
{/* Add fields section. */}
<section className="px-4">
<h3 className="mb-2 font-semibold text-foreground text-sm">
<Trans>Add Fields</Trans>
</h3>
<EnvelopeEditorFieldDragDrop
selectedRecipientId={editorFields.selectedRecipient?.id ?? null}
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
/>
{editorConfig.fields?.allowAIDetection && (
<>
<Button
type="button"
variant="outline"
size="sm"
className="mt-4 w-full"
onClick={onDetectClick}
disabled={envelope.status !== DocumentStatus.DRAFT}
title={
envelope.status !== DocumentStatus.DRAFT
? _(msg`You can only detect fields in draft envelopes`)
: undefined
}
>
<SparklesIcon className="mr-2 -ml-1 h-4 w-4" />
<Trans>Detect with AI</Trans>
</Button>
<AiFieldDetectionDialog
open={isAiFieldDialogOpen}
onOpenChange={setIsAiFieldDialogOpen}
onComplete={onFieldDetectionComplete}
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
<AiFeaturesEnableDialog
open={isAiEnableDialogOpen}
onOpenChange={setIsAiEnableDialogOpen}
onEnabled={onAiFeaturesEnabled}
/>
</>
{/*
* Always mounted and toggled with CSS rather than a Sheet/Drawer: the field
* palette registers window-level mouse listeners that must survive the panel
* closing mid click-to-place, and unmounting would also double-mount the field
* settings forms and AI dialogs. `invisible` keeps the closed overlay out of
* the tab order and accessibility tree.
*/}
<div
className={cn(
'fixed inset-y-0 right-0 z-50 w-80 max-w-[85vw] overflow-y-auto border-border border-l bg-background py-4 transition-[transform,visibility] duration-200',
isMobileFieldsPanelOpen ? 'translate-x-0 shadow-xl' : 'invisible translate-x-full',
'md:visible md:sticky md:top-0 md:z-auto md:h-full md:w-80 md:max-w-none md:flex-shrink-0 md:transform-none md:shadow-none md:transition-none',
)}
</section>
>
<div className="mb-4 flex items-center justify-between px-4 md:hidden">
<h3 className="font-semibold text-foreground text-sm">
<Trans>Fields</Trans>
</h3>
{/* Field details section. */}
<AnimateGenericFadeInOut key={editorFields.selectedField?.formId}>
{selectedField && (
<section>
<Separator className="my-4" />
<Button variant="ghost" size="sm" onClick={() => setIsMobileFieldsPanelOpen(false)}>
<XIcon className="h-4 w-4" />
<span className="sr-only">
<Trans>Close</Trans>
</span>
</Button>
</div>
{searchParams.get('devmode') && (
<>
<div className="px-4">
<h3 className="mb-3 font-semibold text-foreground text-sm">
<Trans>Developer Mode</Trans>
</h3>
{/* Recipient selector section. */}
<section className="px-4">
<h3 className="mb-2 font-semibold text-foreground text-sm">
<Trans>Selected Recipient</Trans>
</h3>
<div className="space-y-2 rounded-md border border-border bg-muted/50 p-3 text-foreground text-sm">
{selectedField.id && (
<EnvelopeRecipientSelector
selectedRecipient={editorFields.selectedRecipient}
onSelectedRecipientChange={(recipient) => editorFields.setSelectedRecipient(recipient.id)}
recipients={envelope.recipients}
fields={envelope.fields}
className="w-full"
align="end"
/>
{editorFields.selectedRecipient &&
!canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields) && (
<Alert className="mt-4" variant="warning">
<AlertDescription>
<Trans>
This recipient can no longer be modified as they have signed a field, or completed the document.
</Trans>
</AlertDescription>
</Alert>
)}
</section>
<Separator className="my-4" />
{/* Add fields section. */}
<section className="px-4">
<h3 className="mb-2 font-semibold text-foreground text-sm">
<Trans>Add Fields</Trans>
</h3>
<EnvelopeEditorFieldDragDrop
selectedRecipientId={editorFields.selectedRecipient?.id ?? null}
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
onFieldSelect={() => setIsMobileFieldsPanelOpen(false)}
/>
{editorConfig.fields?.allowAIDetection && (
<>
<Button
type="button"
variant="outline"
size="sm"
className="mt-4 w-full"
onClick={onDetectClick}
disabled={envelope.status !== DocumentStatus.DRAFT}
title={
envelope.status !== DocumentStatus.DRAFT
? _(msg`You can only detect fields in draft envelopes`)
: undefined
}
>
<SparklesIcon className="mr-2 -ml-1 h-4 w-4" />
<Trans>Detect with AI</Trans>
</Button>
<AiFieldDetectionDialog
open={isAiFieldDialogOpen}
onOpenChange={setIsAiFieldDialogOpen}
onComplete={onFieldDetectionComplete}
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
<AiFeaturesEnableDialog
open={isAiEnableDialogOpen}
onOpenChange={setIsAiEnableDialogOpen}
onEnabled={onAiFeaturesEnabled}
/>
</>
)}
</section>
{/* Field details section. */}
<AnimateGenericFadeInOut key={editorFields.selectedField?.formId}>
{selectedField && (
<section>
<Separator className="my-4" />
{searchParams.get('devmode') && (
<>
<div className="px-4">
<h3 className="mb-3 font-semibold text-foreground text-sm">
<Trans>Developer Mode</Trans>
</h3>
<div className="space-y-2 rounded-md border border-border bg-muted/50 p-3 text-foreground text-sm">
{selectedField.id && (
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Field ID:</Trans>
</span>{' '}
{selectedField.id}
</p>
)}
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Field ID:</Trans>
<Trans>Recipient ID:</Trans>
</span>{' '}
{selectedField.id}
{selectedField.recipientId}
</p>
)}
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Recipient ID:</Trans>
</span>{' '}
{selectedField.recipientId}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Pos X:</Trans>
</span>{' '}
{selectedField.positionX.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Pos Y:</Trans>
</span>{' '}
{selectedField.positionY.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Width:</Trans>
</span>{' '}
{selectedField.width.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Height:</Trans>
</span>{' '}
{selectedField.height.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Pos X:</Trans>
</span>{' '}
{selectedField.positionX.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Pos Y:</Trans>
</span>{' '}
{selectedField.positionY.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Width:</Trans>
</span>{' '}
{selectedField.width.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Height:</Trans>
</span>{' '}
{selectedField.height.toFixed(2)}
</p>
</div>
</div>
</div>
<Separator className="my-4" />
</>
)}
<Separator className="my-4" />
</>
)}
<div className="px-4 [&_label]:text-foreground/70 [&_label]:text-xs">
<h3 className="font-semibold text-sm">{_(FieldSettingsTypeTranslations[selectedField.type])}</h3>
<div className="px-4 [&_label]:text-foreground/70 [&_label]:text-xs">
<h3 className="font-semibold text-sm">{_(FieldSettingsTypeTranslations[selectedField.type])}</h3>
{match(selectedField.type)
.with(FieldType.SIGNATURE, () => (
<EditorFieldSignatureForm
value={selectedField?.fieldMeta as TSignatureFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.CHECKBOX, () => (
<EditorFieldCheckboxForm
value={selectedField?.fieldMeta as TCheckboxFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.DATE, () => (
<EditorFieldDateForm
value={selectedField?.fieldMeta as TDateFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.DROPDOWN, () => (
<EditorFieldDropdownForm
value={selectedField?.fieldMeta as TDropdownFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.EMAIL, () => (
<EditorFieldEmailForm
value={selectedField?.fieldMeta as TEmailFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.INITIALS, () => (
<EditorFieldInitialsForm
value={selectedField?.fieldMeta as TInitialsFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.NAME, () => (
<EditorFieldNameForm
value={selectedField?.fieldMeta as TNameFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.NUMBER, () => (
<EditorFieldNumberForm
value={selectedField?.fieldMeta as TNumberFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.RADIO, () => (
<EditorFieldRadioForm
value={selectedField?.fieldMeta as TRadioFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.TEXT, () => (
<EditorFieldTextForm
value={selectedField?.fieldMeta as TTextFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.otherwise(() => null)}
</div>
</section>
)}
</AnimateGenericFadeInOut>
</div>
{match(selectedField.type)
.with(FieldType.SIGNATURE, () => (
<EditorFieldSignatureForm
value={selectedField?.fieldMeta as TSignatureFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.CHECKBOX, () => (
<EditorFieldCheckboxForm
value={selectedField?.fieldMeta as TCheckboxFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.DATE, () => (
<EditorFieldDateForm
value={selectedField?.fieldMeta as TDateFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.DROPDOWN, () => (
<EditorFieldDropdownForm
value={selectedField?.fieldMeta as TDropdownFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.EMAIL, () => (
<EditorFieldEmailForm
value={selectedField?.fieldMeta as TEmailFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.INITIALS, () => (
<EditorFieldInitialsForm
value={selectedField?.fieldMeta as TInitialsFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.NAME, () => (
<EditorFieldNameForm
value={selectedField?.fieldMeta as TNameFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.NUMBER, () => (
<EditorFieldNumberForm
value={selectedField?.fieldMeta as TNumberFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.RADIO, () => (
<EditorFieldRadioForm
value={selectedField?.fieldMeta as TRadioFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.TEXT, () => (
<EditorFieldTextForm
value={selectedField?.fieldMeta as TTextFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.otherwise(() => null)}
</div>
</section>
)}
</AnimateGenericFadeInOut>
</div>
</>
)}
</div>
);
@@ -70,17 +70,17 @@ export default function EnvelopeEditorHeader() {
return (
<nav className="w-full border-border border-b bg-background px-4 py-3 md:px-6">
<div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 flex-1 items-center space-x-4">
<div className="flex min-w-0 flex-1 items-center gap-2 sm:gap-4">
{editorConfig.embedded?.customBrandingLogo ? (
<img src={`/api/branding/logo/team/${envelope.teamId}`} alt="Logo" className="h-6 w-auto" />
<img src={`/api/branding/logo/team/${envelope.teamId}`} alt="Logo" className="hidden h-6 w-auto sm:block" />
) : (
<Link to="/">
<Link to="/" className="hidden sm:block">
<BrandingLogo className="h-6 w-auto" />
</Link>
)}
<Separator orientation="vertical" className="h-6 shrink-0" />
<Separator orientation="vertical" className="hidden h-6 shrink-0 sm:block" />
<div className="flex min-w-0 items-center space-x-2">
<div className="flex min-w-0 items-center gap-2">
<EnvelopeItemTitleInput
dataTestId="envelope-title-input"
disabled={!envelopeItemPermissions.canTitleBeChanged || !allowConfigureEnvelopeTitle}
@@ -98,19 +98,19 @@ export default function EnvelopeEditorHeader() {
{envelope.type === EnvelopeType.TEMPLATE && (
<>
{envelope.templateType === TemplateType.PRIVATE && (
<Badge variant="secondary" className="shrink-0">
<Badge variant="secondary" className="hidden shrink-0 sm:inline-flex">
<LockIcon className="mr-2 h-4 w-4 text-blue-600 dark:text-blue-300" />
<Trans>Private Template</Trans>
</Badge>
)}
{envelope.templateType === TemplateType.ORGANISATION && (
<Badge variant="orange" className="shrink-0">
<Badge variant="orange" className="hidden shrink-0 sm:inline-flex">
<Building2Icon className="mr-2 size-4" />
<Trans>Organisation Template</Trans>
</Badge>
)}
{envelope.templateType === TemplateType.PUBLIC && (
<Badge variant="default" className="shrink-0">
<Badge variant="default" className="hidden shrink-0 sm:inline-flex">
<Globe2Icon className="mr-2 h-4 w-4 text-green-500 dark:text-green-300" />
<Trans>Public Template</Trans>
</Badge>
@@ -118,7 +118,7 @@ export default function EnvelopeEditorHeader() {
{envelope.directLink?.token && (
<TemplateDirectLinkBadge
className="shrink-0 py-1"
className="hidden shrink-0 py-1 sm:flex"
token={envelope.directLink.token}
enabled={envelope.directLink.enabled}
/>
@@ -180,9 +180,9 @@ export default function EnvelopeEditorHeader() {
<div className="flex shrink-0 items-center space-x-2">
{allowAttachments &&
(isEmbedded ? (
<EmbeddedEditorAttachmentPopover buttonSize="sm" />
<EmbeddedEditorAttachmentPopover buttonSize="sm" collapseLabelOnMobile />
) : (
<DocumentAttachmentsPopover envelopeId={envelope.id} buttonSize="sm" />
<DocumentAttachmentsPopover envelopeId={envelope.id} buttonSize="sm" collapseLabelOnMobile />
))}
{editorConfig.settings && (
@@ -202,8 +202,10 @@ export default function EnvelopeEditorHeader() {
documentRootPath={relativePath.documentRootPath}
trigger={
<Button size="sm">
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Send Document</Trans>
<SendIcon className="h-4 w-4 sm:mr-2" />
<span className="sr-only sm:not-sr-only">
<Trans>Send Document</Trans>
</span>
</Button>
}
/>
@@ -212,8 +214,10 @@ export default function EnvelopeEditorHeader() {
envelope={envelope}
trigger={
<Button size="sm">
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Resend Document</Trans>
<SendIcon className="h-4 w-4 sm:mr-2" />
<span className="sr-only sm:not-sr-only">
<Trans>Resend Document</Trans>
</span>
</Button>
}
/>
@@ -0,0 +1,66 @@
import { Button } from '@documenso/ui/primitives/button';
import { Trans } from '@lingui/react/macro';
import { MonitorIcon, XIcon } from 'lucide-react';
import { useEffect, useState } from 'react';
const DISMISSED_STORAGE_KEY = 'envelopeEditorMobileNoticeDismissed';
// Storage access can throw when the browser blocks it (privacy settings, some
// embedded contexts). A cosmetic notice must never take the editor down, so
// failures are treated as "not dismissed" and the dismissal is not persisted.
const readIsDismissed = () => {
try {
return localStorage.getItem(DISMISSED_STORAGE_KEY) === 'true';
} catch {
return false;
}
};
const persistDismissed = () => {
try {
localStorage.setItem(DISMISSED_STORAGE_KEY, 'true');
} catch {
return;
}
};
/**
* Suggests using a larger screen for the editor. Only shown below the `md`
* breakpoint, and stays hidden once dismissed.
*/
export const EnvelopeEditorMobileNotice = () => {
// Hidden until mounted so the server and first client render agree, and so a
// previously dismissed notice never flashes.
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
setIsVisible(!readIsDismissed());
}, []);
const onDismiss = () => {
setIsVisible(false);
persistDismissed();
};
if (!isVisible) {
return null;
}
return (
<div className="flex shrink-0 items-center gap-3 border-border border-b bg-muted/50 px-4 py-2 text-muted-foreground text-sm md:hidden">
<MonitorIcon className="h-4 w-4 shrink-0" aria-hidden />
<p className="flex-1">
<Trans>For the best experience, use the editor on a desktop.</Trans>
</p>
<Button variant="ghost" size="sm" className="-mr-2 h-8 w-8 shrink-0 p-0" onClick={onDismiss}>
<XIcon className="h-4 w-4" />
<span className="sr-only">
<Trans>Close</Trans>
</span>
</Button>
</div>
);
};
@@ -0,0 +1,208 @@
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { Button } from '@documenso/ui/primitives/button';
import { Trans, useLingui } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import {
CopyPlusIcon,
DownloadCloudIcon,
FileOutputIcon,
LinkIcon,
type LucideIcon,
SendIcon,
SettingsIcon,
Trash2Icon,
} from 'lucide-react';
import type { ComponentProps, ReactNode } from 'react';
import { useNavigate } from 'react-router';
import { EnvelopeDeleteDialog } from '~/components/dialogs/envelope-delete-dialog';
import { EnvelopeDistributeDialog } from '~/components/dialogs/envelope-distribute-dialog';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
import { EnvelopeDuplicateDialog } from '~/components/dialogs/envelope-duplicate-dialog';
import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog';
import { EnvelopeSaveAsTemplateDialog } from '~/components/dialogs/envelope-save-as-template-dialog';
import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog';
import { EnvelopeEditorSettingsDialog } from './envelope-editor-settings-dialog';
const useEnvelopeEditorQuickActionVisibility = () => {
const { envelope, editorConfig, isDocument, isTemplate } = useCurrentEnvelopeEditor();
const { actions } = editorConfig;
return {
settings: Boolean(editorConfig.settings),
distribute: isDocument && actions.allowDistributing,
directLink: isTemplate && actions.allowDirectLink,
duplicate: actions.allowDuplication,
saveAsTemplate: isDocument && actions.allowSaveAsTemplate,
download: actions.allowDownloadPDF,
// Check envelope ID since it can be in embedded create mode.
delete: actions.allowDeletion && Boolean(envelope.id),
};
};
export const useHasEnvelopeEditorQuickActions = () =>
Object.values(useEnvelopeEditorQuickActionVisibility()).some(Boolean);
type QuickActionButtonProps = ComponentProps<typeof Button> & {
icon: LucideIcon;
label: ReactNode;
showLabel: boolean;
};
// Used as a Radix `asChild` trigger, so the injected props must reach the button.
const QuickActionButton = ({ icon: Icon, label, showLabel, ...props }: QuickActionButtonProps) => (
<Button variant="ghost" size="sm" className="w-full justify-start" {...props}>
<Icon className="h-4 w-4" />
{showLabel && <span className="ml-2">{label}</span>}
</Button>
);
export type EnvelopeEditorQuickActionsProps = {
showLabels: boolean;
};
export const EnvelopeEditorQuickActions = ({ showLabels }: EnvelopeEditorQuickActionsProps) => {
const { t } = useLingui();
const navigate = useNavigate();
const { envelope, isDocument, relativePath, syncEnvelope } = useCurrentEnvelopeEditor();
const visibility = useEnvelopeEditorQuickActionVisibility();
return (
<div className="space-y-3 [&_.lucide]:text-muted-foreground">
{visibility.settings && (
<EnvelopeEditorSettingsDialog
trigger={
<QuickActionButton
icon={SettingsIcon}
title={t`Settings`}
showLabel={showLabels}
label={isDocument ? <Trans>Document Settings</Trans> : <Trans>Template Settings</Trans>}
/>
}
/>
)}
{visibility.distribute && (
<>
<EnvelopeDistributeDialog
documentRootPath={relativePath.documentRootPath}
trigger={
<QuickActionButton
icon={SendIcon}
title={t`Send Envelope`}
showLabel={showLabels}
label={<Trans>Send Document</Trans>}
/>
}
/>
<EnvelopeRedistributeDialog
envelope={envelope}
trigger={
<QuickActionButton
icon={SendIcon}
title={t`Resend Envelope`}
showLabel={showLabels}
label={<Trans>Resend Document</Trans>}
/>
}
/>
</>
)}
{visibility.directLink && (
<TemplateDirectLinkDialog
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
directLink={envelope.directLink}
recipients={envelope.recipients}
onCreateSuccess={async () => await syncEnvelope()}
onDeleteSuccess={async () => await syncEnvelope()}
trigger={
<QuickActionButton
icon={LinkIcon}
title={t`Direct Link`}
showLabel={showLabels}
label={<Trans>Direct Link</Trans>}
/>
}
/>
)}
{visibility.duplicate && (
<EnvelopeDuplicateDialog
envelopeId={envelope.id}
envelopeType={envelope.type}
trigger={
<QuickActionButton
icon={CopyPlusIcon}
title={t`Duplicate Envelope`}
showLabel={showLabels}
label={isDocument ? <Trans>Duplicate Document</Trans> : <Trans>Duplicate Template</Trans>}
/>
}
/>
)}
{visibility.saveAsTemplate && (
<EnvelopeSaveAsTemplateDialog
envelopeId={envelope.id}
trigger={
<QuickActionButton
icon={FileOutputIcon}
title={t`Save as Template`}
showLabel={showLabels}
label={<Trans>Save as Template</Trans>}
/>
}
/>
)}
{visibility.download && (
<EnvelopeDownloadDialog
envelopeId={envelope.id}
envelopeStatus={envelope.status}
isLegacy={envelope.internalVersion === 1}
envelopeItems={envelope.envelopeItems}
trigger={
<QuickActionButton
icon={DownloadCloudIcon}
title={t`Download PDF`}
showLabel={showLabels}
label={<Trans>Download PDF</Trans>}
/>
}
/>
)}
{visibility.delete && (
<EnvelopeDeleteDialog
id={envelope.id}
type={envelope.type}
status={envelope.status}
title={envelope.title}
canManageDocument={true}
trigger={
<QuickActionButton
icon={Trash2Icon}
title={t`Delete Envelope`}
showLabel={showLabels}
label={isDocument ? <Trans>Delete Document</Trans> : <Trans>Delete Template</Trans>}
/>
}
onDelete={async () => {
await navigate(
envelope.type === EnvelopeType.DOCUMENT ? relativePath.documentRootPath : relativePath.templateRootPath,
);
}}
/>
)}
</div>
);
};
@@ -592,9 +592,15 @@ export const EnvelopeEditorRecipientForm = () => {
const recipientCountLimit = organisation.organisationClaim.recipientCount;
const isOverRecipientLimit = recipientCountLimit > 0 && signers.length > recipientCountLimit;
const aiDetectionLabel = team.preferences.aiFeaturesEnabled ? (
<Trans>Detect recipients with AI</Trans>
) : (
<Trans>Enable AI detection</Trans>
);
return (
<Card backdropBlur={false} className="border">
<CardHeader className="flex flex-row justify-between">
<CardHeader className="flex flex-col justify-between gap-4 space-y-0 p-4 sm:flex-row sm:p-6">
<div>
<CardTitle>
<Trans>Recipients</Trans>
@@ -604,7 +610,7 @@ export const EnvelopeEditorRecipientForm = () => {
</CardDescription>
</div>
<div className="flex flex-row items-center space-x-2">
<div className="flex flex-row flex-wrap items-center gap-2">
{editorConfig.recipients?.allowAIDetection && (
<Tooltip>
<TooltipTrigger asChild>
@@ -612,27 +618,23 @@ export const EnvelopeEditorRecipientForm = () => {
variant="outline"
type="button"
size="sm"
className="order-last w-full sm:order-none sm:w-auto"
disabled={isSubmitting}
onClick={onDetectRecipientsClick}
>
<SparklesIcon className="h-4 w-4" />
<span className="ml-2 sm:hidden">{aiDetectionLabel}</span>
</Button>
</TooltipTrigger>
<TooltipContent>
{team.preferences.aiFeaturesEnabled ? (
<Trans>Detect recipients with AI</Trans>
) : (
<Trans>Enable AI detection</Trans>
)}
</TooltipContent>
<TooltipContent>{aiDetectionLabel}</TooltipContent>
</Tooltip>
)}
{(!isEmbedded || hasCurrentEditorInfo) && (
<Button
variant="outline"
className="flex flex-row items-center"
className="flex flex-1 flex-row items-center sm:flex-none"
size="sm"
disabled={isSubmitting || isUserAlreadyARecipient}
onClick={() => onAddSelfSigner()}
@@ -655,7 +657,7 @@ export const EnvelopeEditorRecipientForm = () => {
</div>
</CardHeader>
<CardContent>
<CardContent className="p-4 pt-0 sm:p-6 sm:pt-0">
{isOverRecipientLimit && (
<Alert variant="destructive" className="mb-4">
<AlertDescription>
@@ -840,14 +842,17 @@ export const EnvelopeEditorRecipientForm = () => {
data-native-id={signer.id}
disabled={isSubmitting || !canRecipientBeModified(signer.id)}
className={cn('pb-2', {
'border-b pb-4': showAdvancedSettings && index !== signers.length - 1,
// Recipients that span several lines (stacked below `sm`, or with advanced
// settings shown) are separated by a divider.
'border-b pb-4': index !== signers.length - 1,
'sm:border-b-0 sm:pb-2': !showAdvancedSettings && index !== signers.length - 1,
'pt-2': showAdvancedSettings && index === 0,
'pr-3': isSigningOrderSequential,
'sm:pr-3': isSigningOrderSequential,
})}
>
<div className="flex flex-row items-center gap-x-2">
<div className="flex flex-row flex-wrap items-center gap-2 sm:flex-nowrap">
{isSigningOrderSequential && isCcRecipient(signer) && (
<div className="mt-auto h-10 w-[4.25rem] flex-shrink-0" />
<div className="mt-auto hidden h-10 w-[4.25rem] flex-shrink-0 sm:block" />
)}
{isSigningOrderSequential && !isCcRecipient(signer) && (
@@ -856,11 +861,14 @@ export const EnvelopeEditorRecipientForm = () => {
name={`signers.${index}.signingOrder`}
render={({ field }) => (
<FormItem
className={cn('mt-auto flex items-center gap-x-1 space-y-0', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.signingOrder,
})}
className={cn(
'order-1 mt-auto flex items-center gap-x-1 space-y-0 sm:order-none',
{
'sm:mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.signingOrder,
},
)}
>
<GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" />
<FormControl>
@@ -892,19 +900,23 @@ export const EnvelopeEditorRecipientForm = () => {
/>
)}
<span className="order-1 min-w-0 flex-1 truncate font-medium text-sm sm:hidden">
<Trans>Recipient {index + 1}</Trans>
</span>
<FormField
control={form.control}
name={`signers.${index}.email`}
render={({ field }) => (
<FormItem
className={cn('relative w-full', {
'mb-6':
className={cn('relative order-2 w-full sm:order-none', {
'sm:mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.email,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel>
{!showAdvancedSettings && (
<FormLabel className={cn(index !== 0 && 'sm:sr-only')}>
<Trans>Email</Trans>
</FormLabel>
)}
@@ -944,14 +956,14 @@ export const EnvelopeEditorRecipientForm = () => {
name={`signers.${index}.name`}
render={({ field }) => (
<FormItem
className={cn('w-full', {
'mb-6':
className={cn('order-3 w-full sm:order-none', {
'sm:mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.name,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel>
{!showAdvancedSettings && (
<FormLabel className={cn(index !== 0 && 'sm:sr-only')}>
<Trans>Name</Trans>
</FormLabel>
)}
@@ -990,8 +1002,8 @@ export const EnvelopeEditorRecipientForm = () => {
name={`signers.${index}.role`}
render={({ field }) => (
<FormItem
className={cn('mt-auto w-fit', {
'mb-6':
className={cn('order-1 mt-auto w-fit sm:order-none', {
'sm:mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.role,
})}
@@ -1021,8 +1033,8 @@ export const EnvelopeEditorRecipientForm = () => {
<Button
variant="ghost"
className={cn('mt-auto px-2', {
'mb-6': form.formState.errors.signers?.[index],
className={cn('order-1 mt-auto px-2 sm:order-none', {
'sm:mb-6': form.formState.errors.signers?.[index],
})}
data-testid="remove-signer-button"
disabled={
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,110 @@
import type { TEnvelopeEditorSettings } from '@documenso/lib/types/envelope-editor';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { BellRingIcon, type LucideIcon, MailIcon, SettingsIcon, ShieldIcon } from 'lucide-react';
type EnvelopeEditorSettingsConfig = NonNullable<TEnvelopeEditorSettings['settings']>;
export type EnvelopeEditorSettingsTabId = 'general' | 'reminders' | 'notifications' | 'security';
export type EnvelopeEditorSettingsTab = {
id: EnvelopeEditorSettingsTabId;
title: MessageDescriptor;
description: MessageDescriptor;
icon: LucideIcon;
isEnabled: (settings: EnvelopeEditorSettingsConfig) => boolean;
};
export const ENVELOPE_EDITOR_SETTINGS_TABS: EnvelopeEditorSettingsTab[] = [
{
id: 'general',
title: msg`General`,
description: msg`Configure document settings and options before sending.`,
icon: SettingsIcon,
isEnabled: () => true,
},
{
id: 'reminders',
title: msg`Reminders`,
description: msg`Configure signing reminder settings for the document.`,
icon: BellRingIcon,
isEnabled: (settings) => settings.allowConfigureReminders,
},
{
id: 'notifications',
title: msg`Notifications`,
description: msg`Configure notification settings for the document.`,
icon: MailIcon,
isEnabled: (settings) => settings.allowConfigureDistribution,
},
{
id: 'security',
title: msg`Security`,
description: msg`Configure security settings for the document.`,
icon: ShieldIcon,
isEnabled: () => true,
},
];
export type EnvelopeEditorSettingsTabsProps = {
settings: EnvelopeEditorSettingsConfig;
activeTab: EnvelopeEditorSettingsTabId;
onActiveTabChange: (tab: EnvelopeEditorSettingsTabId) => void;
};
export const EnvelopeEditorSettingsTabs = ({
settings,
activeTab,
onActiveTabChange,
}: EnvelopeEditorSettingsTabsProps) => {
const { t } = useLingui();
const visibleTabs = ENVELOPE_EDITOR_SETTINGS_TABS.filter((tab) => tab.isEnabled(settings));
return (
<>
<nav className="scrollbar-hidden flex overflow-x-auto px-4 md:hidden">
{visibleTabs.map((tab) => (
<button
key={tab.id}
type="button"
aria-current={activeTab === tab.id ? 'true' : undefined}
onClick={(event) => {
onActiveTabChange(tab.id);
event.currentTarget.scrollIntoView({ inline: 'nearest', block: 'nearest' });
}}
className={cn(
'shrink-0 whitespace-nowrap border-b-2 px-3 py-2.5 font-medium text-sm transition-colors',
activeTab === tab.id
? 'border-primary text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground',
)}
>
{t(tab.title)}
</button>
))}
</nav>
<nav className="mb-8 hidden w-full flex-col items-start gap-y-2 px-4 md:flex">
{visibleTabs.map((tab) => (
<Button
key={tab.id}
variant="ghost"
aria-current={activeTab === tab.id ? 'true' : undefined}
className={cn('w-full justify-start', {
'bg-secondary': activeTab === tab.id,
})}
onClick={() => onActiveTabChange(tab.id)}
>
<tab.icon className="mr-2 h-5 w-5" />
{t(tab.title)}
</Button>
))}
</nav>
</>
);
};
@@ -464,13 +464,13 @@ export const EnvelopeEditorUploadPage = () => {
};
return (
<div className="mx-auto max-w-4xl space-y-6 p-8">
<div className="mx-auto max-w-4xl space-y-6 p-4 md:p-8">
<input {...getReplaceInputProps()} />
<EnvelopeEditorInvalidDirectTemplateAlert className="max-w-none" />
<Card backdropBlur={false} className="border">
<CardHeader className="pb-3">
<CardHeader className="p-4 pb-3 sm:p-6 sm:pb-3">
<CardTitle>
<Trans>Documents</Trans>
</CardTitle>
@@ -479,13 +479,13 @@ export const EnvelopeEditorUploadPage = () => {
</CardDescription>
</CardHeader>
<CardContent>
<CardContent className="p-4 pt-0 sm:p-6 sm:pt-0">
{uploadConfig?.allowUpload && (
<DocumentDropzone
data-testid="envelope-item-dropzone"
onDrop={onFileDrop}
allowMultiple
className="pt-6 pb-4"
className="sm:pt-6 sm:pb-4"
disabled={dropzoneDisabledMessage !== null}
disabledMessage={dropzoneDisabledMessage || undefined}
disabledHeading={msg`Upload disabled`}
@@ -649,7 +649,7 @@ export const EnvelopeEditorUploadPage = () => {
<EnvelopeEditorRecipientForm />
{editorConfig.general.allowAddFieldsStep && (
<div className="flex justify-end">
<div className="hidden justify-end md:flex">
<Button type="button" onClick={() => void navigateToStep('addFields')}>
<Trans>Add Fields</Trans>
</Button>
@@ -1,50 +1,38 @@
import type { EnvelopeEditorStep } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@documenso/ui/primitives/sheet';
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { motion } from 'framer-motion';
import {
ArrowLeftIcon,
CopyPlusIcon,
DownloadCloudIcon,
EyeIcon,
FileOutputIcon,
LinkIcon,
type LucideIcon,
MoreHorizontalIcon,
MousePointerIcon,
SendIcon,
SettingsIcon,
Trash2Icon,
UploadIcon,
} from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router';
import { Link, useSearchParams } from 'react-router';
import { match } from 'ts-pattern';
import { EnvelopeDeleteDialog } from '~/components/dialogs/envelope-delete-dialog';
import { EnvelopeDistributeDialog } from '~/components/dialogs/envelope-distribute-dialog';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
import { EnvelopeDuplicateDialog } from '~/components/dialogs/envelope-duplicate-dialog';
import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog';
import { EnvelopeSaveAsTemplateDialog } from '~/components/dialogs/envelope-save-as-template-dialog';
import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog';
import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog';
import { EnvelopeEditorFieldsPage } from './envelope-editor-fields-page';
import EnvelopeEditorHeader from './envelope-editor-header';
import { EnvelopeEditorMobileNotice } from './envelope-editor-mobile-notice';
import { EnvelopeEditorPreviewPage } from './envelope-editor-preview-page';
import { EnvelopeEditorQuickActions, useHasEnvelopeEditorQuickActions } from './envelope-editor-quick-actions';
import { EnvelopeEditorUploadPage } from './envelope-editor-upload-page';
type EnvelopeEditorStepData = {
id: string;
title: MessageDescriptor;
/** Short label used where space is limited, such as the mobile step bar. */
shortTitle: MessageDescriptor;
icon: LucideIcon;
description: MessageDescriptor;
};
@@ -52,6 +40,7 @@ type EnvelopeEditorStepData = {
const UPLOAD_STEP = {
id: 'upload',
title: msg`Document & Recipients`,
shortTitle: msg`Documents`,
icon: UploadIcon,
description: msg`Upload documents and add recipients`,
};
@@ -59,6 +48,7 @@ const UPLOAD_STEP = {
const ADD_FIELDS_STEP = {
id: 'addFields',
title: msg`Add Fields`,
shortTitle: msg`Fields`,
icon: MousePointerIcon,
description: msg`Place and configure form fields in the document`,
};
@@ -66,6 +56,7 @@ const ADD_FIELDS_STEP = {
const PREVIEW_STEP = {
id: 'preview',
title: msg`Preview`,
shortTitle: msg`Preview`,
icon: EyeIcon,
description: msg`Preview the document before sending`,
};
@@ -73,32 +64,20 @@ const PREVIEW_STEP = {
export const EnvelopeEditor = () => {
const { t } = useLingui();
const navigate = useNavigate();
const { editorConfig, isDocument, relativePath, navigateToStep, flushAutosave, resetForms } =
useCurrentEnvelopeEditor();
const {
envelope,
editorConfig,
isDocument,
isTemplate,
relativePath,
navigateToStep,
syncEnvelope,
flushAutosave,
resetForms,
} = useCurrentEnvelopeEditor();
const [searchParams] = useSearchParams();
const [searchParams, setSearchParams] = useSearchParams();
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
// The mobile "More" sheet holds the quick actions and the return link, so it
// is only offered when at least one of them applies.
const hasQuickActions = useHasEnvelopeEditorQuickActions();
const hasMobileMenu = hasQuickActions || !editorConfig.embedded;
const {
general: { minimizeLeftSidebar, allowUploadAndRecipientStep, allowAddFieldsStep, allowPreviewStep },
actions: {
allowDistributing,
allowDirectLink,
allowDuplication,
allowSaveAsTemplate,
allowDownloadPDF,
allowDeletion,
},
} = editorConfig;
const envelopeEditorSteps = useMemo(() => {
@@ -175,16 +154,21 @@ export const EnvelopeEditor = () => {
const currentStepData = envelopeEditorSteps.find((step) => step.id === searchParamsStep) || envelopeEditorSteps[0];
return (
<div className="h-screen w-screen bg-envelope-editor-background">
<div className="flex h-[100dvh] w-full flex-col bg-envelope-editor-background">
<EnvelopeEditorHeader />
{!editorConfig.embedded && <EnvelopeEditorMobileNotice />}
{/* Main Content Area */}
<div className="flex h-[calc(100vh-4rem)] w-screen">
{/* Left Section - Step Navigation */}
<div className="flex min-h-0 w-full flex-1">
{/* Left Section - Step Navigation. Hidden below `md`, where the bottom bar takes over. */}
<div
className={cn('flex w-80 flex-shrink-0 flex-col overflow-y-auto border-border border-r bg-background py-4', {
'w-14': minimizeLeftSidebar,
})}
className={cn(
'hidden w-80 flex-shrink-0 flex-col overflow-y-auto border-border border-r bg-background py-4 md:flex',
{
'w-14': minimizeLeftSidebar,
},
)}
>
{/* Left section step selector. */}
{minimizeLeftSidebar ? (
@@ -318,7 +302,7 @@ export const EnvelopeEditor = () => {
{/* Quick Actions. */}
<div
className={cn('space-y-3 px-4 [&_.lucide]:text-muted-foreground', {
className={cn('space-y-3 px-4', {
'px-2': minimizeLeftSidebar,
})}
>
@@ -328,166 +312,7 @@ export const EnvelopeEditor = () => {
</h4>
)}
{editorConfig.settings && (
<EnvelopeEditorSettingsDialog
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Settings`)}>
<SettingsIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
{isDocument ? <Trans>Document Settings</Trans> : <Trans>Template Settings</Trans>}
</span>
)}
</Button>
}
/>
)}
{isDocument && allowDistributing && (
<>
<EnvelopeDistributeDialog
documentRootPath={relativePath.documentRootPath}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Send Envelope`)}>
<SendIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
<Trans>Send Document</Trans>
</span>
)}
</Button>
}
/>
<EnvelopeRedistributeDialog
envelope={envelope}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Resend Envelope`)}>
<SendIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
<Trans>Resend Document</Trans>
</span>
)}
</Button>
}
/>
</>
)}
{isTemplate && allowDirectLink && (
<TemplateDirectLinkDialog
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
directLink={envelope.directLink}
recipients={envelope.recipients}
onCreateSuccess={async () => await syncEnvelope()}
onDeleteSuccess={async () => await syncEnvelope()}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Direct Link`)}>
<LinkIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
<Trans>Direct Link</Trans>
</span>
)}
</Button>
}
/>
)}
{allowDuplication && (
<EnvelopeDuplicateDialog
envelopeId={envelope.id}
envelopeType={envelope.type}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Duplicate Envelope`)}>
<CopyPlusIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
{isDocument ? <Trans>Duplicate Document</Trans> : <Trans>Duplicate Template</Trans>}
</span>
)}
</Button>
}
/>
)}
{allowSaveAsTemplate && isDocument && (
<EnvelopeSaveAsTemplateDialog
envelopeId={envelope.id}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Save as Template`)}>
<FileOutputIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
<Trans>Save as Template</Trans>
</span>
)}
</Button>
}
/>
)}
{allowDownloadPDF && (
<EnvelopeDownloadDialog
envelopeId={envelope.id}
envelopeStatus={envelope.status}
isLegacy={envelope.internalVersion === 1}
envelopeItems={envelope.envelopeItems}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Download PDF`)}>
<DownloadCloudIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
<Trans>Download PDF</Trans>
</span>
)}
</Button>
}
/>
)}
{/* Check envelope ID since it can be in embedded create mode. */}
{allowDeletion && envelope.id && (
<EnvelopeDeleteDialog
id={envelope.id}
type={envelope.type}
status={envelope.status}
title={envelope.title}
canManageDocument={true}
trigger={
<Button
type="button"
variant="ghost"
size="sm"
className="w-full justify-start"
title={t(msg`Delete Envelope`)}
>
<Trash2Icon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
{isDocument ? <Trans>Delete Document</Trans> : <Trans>Delete Template</Trans>}
</span>
)}
</Button>
}
onDelete={async () => {
await navigate(
envelope.type === EnvelopeType.DOCUMENT
? relativePath.documentRootPath
: relativePath.templateRootPath,
);
}}
/>
)}
<EnvelopeEditorQuickActions showLabels={!minimizeLeftSidebar} />
</div>
{/* Footer of left sidebar. */}
@@ -518,21 +343,89 @@ export const EnvelopeEditor = () => {
)}
</div>
{/* Main Content - Changes based on current step */}
<div className="flex-1 overflow-y-auto">
{match({
pageToRender,
allowUploadAndRecipientStep,
allowAddFieldsStep,
allowPreviewStep,
})
.with({ pageToRender: 'loading' }, () => <SpinnerBox className="py-32" />)
.with({ pageToRender: 'upload', allowUploadAndRecipientStep: true }, () => <EnvelopeEditorUploadPage />)
.with({ pageToRender: 'addFields', allowAddFieldsStep: true }, () => <EnvelopeEditorFieldsPage />)
.with({ pageToRender: 'preview', allowPreviewStep: true }, () => <EnvelopeEditorPreviewPage />)
.otherwise(() => null)}
<div className="flex min-w-0 flex-1 flex-col">
{/* Main Content - Changes based on current step */}
<div className="min-h-0 flex-1 overflow-y-auto">
{match({
pageToRender,
allowUploadAndRecipientStep,
allowAddFieldsStep,
allowPreviewStep,
})
.with({ pageToRender: 'loading' }, () => <SpinnerBox className="py-32" />)
.with({ pageToRender: 'upload', allowUploadAndRecipientStep: true }, () => <EnvelopeEditorUploadPage />)
.with({ pageToRender: 'addFields', allowAddFieldsStep: true }, () => <EnvelopeEditorFieldsPage />)
.with({ pageToRender: 'preview', allowPreviewStep: true }, () => <EnvelopeEditorPreviewPage />)
.otherwise(() => null)}
</div>
{/* Mobile step bar. Replaces the sidebar below `md`. */}
<nav
className="flex h-14 shrink-0 items-stretch border-border border-t bg-background md:hidden"
aria-label={t`Editor steps`}
>
{envelopeEditorSteps.map((step) => {
const Icon = step.icon;
const isActive = searchParamsStep === step.id;
return (
<button
key={step.id}
data-testid={`envelope-editor-mobile-step-${step.id}`}
type="button"
aria-current={isActive ? 'step' : undefined}
className={cn(
'flex flex-1 flex-col items-center justify-center gap-1 font-medium text-[11px] transition-colors',
isActive ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground hover:text-foreground',
)}
onClick={() => void navigateToStep(step.id as EnvelopeEditorStep)}
>
<Icon className="h-5 w-5" />
{t(step.shortTitle)}
</button>
);
})}
{hasMobileMenu && (
<button
type="button"
className="flex flex-1 flex-col items-center justify-center gap-1 font-medium text-[11px] text-muted-foreground transition-colors hover:text-foreground"
onClick={() => setIsMobileMenuOpen(true)}
>
<MoreHorizontalIcon className="h-5 w-5" />
<Trans>More</Trans>
</button>
)}
</nav>
</div>
</div>
{/* Mobile quick actions, opened from the "More" item in the step bar. */}
{hasMobileMenu && (
<Sheet open={isMobileMenuOpen} onOpenChange={setIsMobileMenuOpen}>
<SheetContent className="flex w-full max-w-[350px] flex-col overflow-y-auto">
<SheetHeader className="text-left">
<SheetTitle>
<Trans>Quick Actions</Trans>
</SheetTitle>
</SheetHeader>
<EnvelopeEditorQuickActions showLabels />
{!editorConfig.embedded && (
<Button variant="ghost" className="mt-auto w-full justify-start" asChild>
<Link to={relativePath.basePath}>
<ArrowLeftIcon className="h-4 w-4 flex-shrink-0" />
<span className="ml-2">
{isDocument ? <Trans>Return to documents</Trans> : <Trans>Return to templates</Trans>}
</span>
</Link>
</Button>
)}
</SheetContent>
</Sheet>
)}
</div>
);
};
+9 -5
View File
@@ -87,10 +87,11 @@ export const DocumentDropzone = ({
{...getRootProps()}
{...props}
>
<CardContent className="flex flex-col items-center justify-center p-6 text-muted-foreground/40">
<CardContent className="flex flex-col items-center justify-center px-4 py-2 text-muted-foreground/40 sm:p-6">
{/* Below `sm` the illustration is hidden and the dropzone is sized like a regular button. */}
{disabled ? (
// Disabled State
<div className="flex">
<div className="hidden sm:flex">
<motion.div
className="a z-10 flex aspect-[3/4] w-24 origin-top-right -rotate-[22deg] flex-col gap-y-1 rounded-lg border border-muted-foreground/20 bg-background/80 px-2 py-4 backdrop-blur-sm group-hover:border-destructive/10 group-hover:bg-destructive/2"
variants={DocumentDropzoneDisabledCardLeftVariants}
@@ -119,7 +120,7 @@ export const DocumentDropzone = ({
</div>
) : (
// Non Disabled State
<div className="flex">
<div className="hidden sm:flex">
<motion.div
className="a z-10 flex aspect-[3/4] w-24 origin-top-right -rotate-[22deg] flex-col gap-y-1 rounded-lg border border-muted-foreground/20 bg-background/80 px-2 py-4 backdrop-blur-sm group-hover:border-documenso/80"
variants={DocumentDropzoneCardLeftVariants}
@@ -147,9 +148,12 @@ export const DocumentDropzone = ({
<input {...getInputProps()} />
<p className="mt-6 font-medium text-foreground">{_(heading[type])}</p>
<p className="flex items-center justify-center gap-2 text-center font-medium text-foreground text-sm sm:mt-6 sm:text-base">
{!disabled && <Plus className="h-4 w-4 sm:hidden" aria-hidden />}
{_(heading[type])}
</p>
<p className="mt-1 text-center text-muted-foreground/80 text-sm">
<p className={cn('mt-1 text-center text-muted-foreground/80 text-sm', !disabled && 'hidden sm:block')}>
{_(disabled ? disabledMessage : msg`Drag & drop your document here.`)}
</p>