mirror of
https://github.com/documenso/documenso.git
synced 2026-08-16 11:31:51 +10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01b02db201 |
@@ -81,7 +81,7 @@ services:
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?err}
|
||||
- POSTGRES_DB=${POSTGRES_DB:?err}
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}']
|
||||
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER}']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
@@ -8,7 +8,6 @@ import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { type TRecipientLite, ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
|
||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { DOCUMENT_TITLE_MAX_LENGTH } from '@documenso/trpc/server/document-router/schema';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
@@ -24,7 +23,6 @@ import {
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
||||
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
@@ -34,118 +32,33 @@ import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/client';
|
||||
import { FileTextIcon, InfoIcon, Plus, UploadCloudIcon, X } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
import * as z from 'zod';
|
||||
import { getTemplateUseErrorMessage } from '~/utils/toast-error-messages';
|
||||
|
||||
const DOCUMENT_NAME_SOURCE = {
|
||||
TEMPLATE: 'template',
|
||||
UPLOAD: 'upload',
|
||||
CUSTOM: 'custom',
|
||||
} as const;
|
||||
|
||||
type TDocumentNameSource = (typeof DOCUMENT_NAME_SOURCE)[keyof typeof DOCUMENT_NAME_SOURCE];
|
||||
|
||||
type TCustomDocumentData = {
|
||||
data?: File;
|
||||
uploadSequence?: number;
|
||||
};
|
||||
|
||||
const getUploadedDocumentTitle = (file: File) => {
|
||||
return file.name.replace(/\.[^/.]+$/, '').trim();
|
||||
};
|
||||
|
||||
const getLastUploadedFile = (customDocumentData?: TCustomDocumentData[]) => {
|
||||
const uploadedFiles = customDocumentData?.filter(
|
||||
(item): item is Required<TCustomDocumentData> => item.data !== undefined && item.uploadSequence !== undefined,
|
||||
);
|
||||
|
||||
if (!uploadedFiles || uploadedFiles.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return uploadedFiles.reduce((lastUploadedFile, uploadedFile) =>
|
||||
uploadedFile.uploadSequence > lastUploadedFile.uploadSequence ? uploadedFile : lastUploadedFile,
|
||||
).data;
|
||||
};
|
||||
|
||||
const getTemplateUseDocumentTitle = ({
|
||||
documentNameSource,
|
||||
customDocumentName,
|
||||
customDocumentData,
|
||||
}: {
|
||||
documentNameSource: TDocumentNameSource;
|
||||
customDocumentName: string;
|
||||
customDocumentData?: TCustomDocumentData[];
|
||||
}) =>
|
||||
match(documentNameSource)
|
||||
.with(DOCUMENT_NAME_SOURCE.UPLOAD, () => {
|
||||
const uploadedFile = getLastUploadedFile(customDocumentData);
|
||||
|
||||
return uploadedFile ? getUploadedDocumentTitle(uploadedFile) : undefined;
|
||||
})
|
||||
.with(DOCUMENT_NAME_SOURCE.CUSTOM, () => customDocumentName.trim())
|
||||
.with(DOCUMENT_NAME_SOURCE.TEMPLATE, () => undefined)
|
||||
.exhaustive();
|
||||
|
||||
const ZAddRecipientsForNewDocumentSchema = z
|
||||
.object({
|
||||
distributeDocument: z.boolean(),
|
||||
useCustomDocument: z.boolean().default(false),
|
||||
documentNameSource: z.enum([
|
||||
DOCUMENT_NAME_SOURCE.TEMPLATE,
|
||||
DOCUMENT_NAME_SOURCE.UPLOAD,
|
||||
DOCUMENT_NAME_SOURCE.CUSTOM,
|
||||
]),
|
||||
customDocumentName: z.string(),
|
||||
customDocumentData: z
|
||||
.array(
|
||||
z.object({
|
||||
title: z.string(),
|
||||
data: z.instanceof(File).optional(),
|
||||
uploadSequence: z.number().optional(),
|
||||
envelopeItemId: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
recipients: z.array(
|
||||
const ZAddRecipientsForNewDocumentSchema = z.object({
|
||||
distributeDocument: z.boolean(),
|
||||
useCustomDocument: z.boolean().default(false),
|
||||
customDocumentData: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
email: ZRecipientEmailSchema,
|
||||
name: z.string(),
|
||||
signingOrder: z.number().optional(),
|
||||
title: z.string(),
|
||||
data: z.instanceof(File).optional(),
|
||||
envelopeItemId: z.string(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.documentNameSource === DOCUMENT_NAME_SOURCE.TEMPLATE) {
|
||||
return;
|
||||
}
|
||||
|
||||
const title = getTemplateUseDocumentTitle(data);
|
||||
const path = data.documentNameSource === DOCUMENT_NAME_SOURCE.CUSTOM ? 'customDocumentName' : 'documentNameSource';
|
||||
|
||||
if (!title) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: msg`Document name is required`.id,
|
||||
path: [path],
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (title.length > DOCUMENT_TITLE_MAX_LENGTH) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: msg`Document name is too long`.id,
|
||||
path: [path],
|
||||
});
|
||||
}
|
||||
});
|
||||
)
|
||||
.optional(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
email: ZRecipientEmailSchema,
|
||||
name: z.string(),
|
||||
signingOrder: z.number().optional(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
|
||||
|
||||
@@ -174,7 +87,6 @@ export function TemplateUseDialog({
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const uploadSequenceRef = useRef(0);
|
||||
|
||||
const { data: response, isLoading: isLoadingEnvelopeItems } = trpc.envelope.item.getMany.useQuery(
|
||||
{
|
||||
@@ -194,8 +106,6 @@ export function TemplateUseDialog({
|
||||
return {
|
||||
distributeDocument: false,
|
||||
useCustomDocument: false,
|
||||
documentNameSource: DOCUMENT_NAME_SOURCE.TEMPLATE,
|
||||
customDocumentName: '',
|
||||
customDocumentData: envelopeItems.map((item) => ({
|
||||
title: item.title,
|
||||
data: undefined,
|
||||
@@ -232,10 +142,9 @@ export function TemplateUseDialog({
|
||||
|
||||
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
|
||||
try {
|
||||
const documentTitle = getTemplateUseDocumentTitle(data);
|
||||
|
||||
const customFilesToUpload = (data.customDocumentData ?? []).filter(
|
||||
(item): item is typeof item & { data: File } => item.data !== undefined,
|
||||
const customFilesToUpload = (data.customDocumentData || []).filter(
|
||||
(item): item is { data: File; envelopeItemId: string; title: string } =>
|
||||
item.data !== undefined && item.envelopeItemId !== undefined && item.title !== undefined,
|
||||
);
|
||||
|
||||
const customDocumentData = await Promise.all(
|
||||
@@ -254,7 +163,6 @@ export function TemplateUseDialog({
|
||||
recipients: data.recipients,
|
||||
distributeDocument: data.distributeDocument,
|
||||
customDocumentData,
|
||||
...(documentTitle ? { override: { title: documentTitle } } : {}),
|
||||
});
|
||||
|
||||
toast({
|
||||
@@ -287,12 +195,6 @@ export function TemplateUseDialog({
|
||||
name: 'recipients',
|
||||
});
|
||||
|
||||
const useCustomDocument = form.watch('useCustomDocument');
|
||||
const documentNameSource = form.watch('documentNameSource');
|
||||
const customDocumentData = form.watch('customDocumentData');
|
||||
const lastUploadedFile = useCustomDocument ? getLastUploadedFile(customDocumentData) : undefined;
|
||||
const canUseUploadedDocumentName = Boolean(lastUploadedFile);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.reset(generateDefaultFormValues());
|
||||
@@ -311,15 +213,6 @@ export function TemplateUseDialog({
|
||||
}
|
||||
}, [envelopeItems, form, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (documentNameSource !== DOCUMENT_NAME_SOURCE.UPLOAD || canUseUploadedDocumentName) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.setValue('documentNameSource', DOCUMENT_NAME_SOURCE.TEMPLATE);
|
||||
form.clearErrors('documentNameSource');
|
||||
}, [canUseUploadedDocumentName, documentNameSource, form]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||
<DialogTrigger asChild>
|
||||
@@ -345,9 +238,9 @@ export function TemplateUseDialog({
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="min-w-0" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<fieldset className="flex h-full min-w-0 flex-col" disabled={form.formState.isSubmitting}>
|
||||
<div className="custom-scrollbar -m-1 max-h-[60vh] w-full min-w-0 max-w-full space-y-4 overflow-y-auto overflow-x-hidden p-1">
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
||||
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
|
||||
{formRecipients.map((recipient, index) => (
|
||||
<div className="flex w-full flex-row space-x-4" key={recipient.id}>
|
||||
{templateSigningOrder === DocumentSigningOrder.SEQUENTIAL && (
|
||||
@@ -508,16 +401,7 @@ export function TemplateUseDialog({
|
||||
onCheckedChange={(checked) => {
|
||||
field.onChange(checked);
|
||||
if (!checked) {
|
||||
const customDocumentData = form.getValues('customDocumentData');
|
||||
|
||||
form.setValue(
|
||||
'customDocumentData',
|
||||
customDocumentData?.map((item) => ({
|
||||
...item,
|
||||
data: undefined,
|
||||
})),
|
||||
);
|
||||
form.clearErrors('customDocumentData');
|
||||
form.setValue('customDocumentData', undefined);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -544,7 +428,7 @@ export function TemplateUseDialog({
|
||||
)}
|
||||
/>
|
||||
|
||||
{useCustomDocument && (
|
||||
{form.watch('useCustomDocument') && (
|
||||
<div className="my-4 space-y-2">
|
||||
{isLoadingEnvelopeItems ? (
|
||||
<SpinnerBox className="py-16" />
|
||||
@@ -559,7 +443,7 @@ export function TemplateUseDialog({
|
||||
<FormControl>
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex w-full min-w-0 items-center gap-4 overflow-hidden rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent/10"
|
||||
className="flex items-center gap-4 rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent/10"
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
@@ -567,15 +451,13 @@ export function TemplateUseDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<h4 className="truncate font-medium text-foreground text-sm">
|
||||
{field.value ? getUploadedDocumentTitle(field.value) : item.title}
|
||||
</h4>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="truncate font-medium text-foreground text-sm">{item.title}</h4>
|
||||
<p className="mt-0.5 text-muted-foreground text-xs">
|
||||
{field.value ? (
|
||||
<span>
|
||||
<div>
|
||||
<Trans>Custom {(field.value.size / (1024 * 1024)).toFixed(2)} MB file</Trans>
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<Trans>Default file</Trans>
|
||||
)}
|
||||
@@ -635,7 +517,7 @@ export function TemplateUseDialog({
|
||||
}
|
||||
|
||||
if (file.type !== 'application/pdf') {
|
||||
form.setError(`customDocumentData.${i}.data`, {
|
||||
form.setError('customDocumentData', {
|
||||
type: 'manual',
|
||||
message: _(msg`Please select a PDF file`),
|
||||
});
|
||||
@@ -644,7 +526,7 @@ export function TemplateUseDialog({
|
||||
}
|
||||
|
||||
if (file.size > APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024) {
|
||||
form.setError(`customDocumentData.${i}.data`, {
|
||||
form.setError('customDocumentData', {
|
||||
type: 'manual',
|
||||
message: _(
|
||||
msg`File size exceeds the limit of ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT} MB`,
|
||||
@@ -655,11 +537,6 @@ export function TemplateUseDialog({
|
||||
}
|
||||
|
||||
field.onChange(file);
|
||||
form.setValue(
|
||||
`customDocumentData.${i}.uploadSequence`,
|
||||
++uploadSequenceRef.current,
|
||||
);
|
||||
form.clearErrors(`customDocumentData.${i}.data`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -673,112 +550,6 @@ export function TemplateUseDialog({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="documentNameSource"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Document name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
aria-label={_(msg`Document name`)}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
className="space-y-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem id="document-name-source-template" value={DOCUMENT_NAME_SOURCE.TEMPLATE} />
|
||||
<label className="text-sm" htmlFor="document-name-source-template">
|
||||
<Trans>Use template name</Trans>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<RadioGroupItem
|
||||
id="document-name-source-upload"
|
||||
value={DOCUMENT_NAME_SOURCE.UPLOAD}
|
||||
disabled={!canUseUploadedDocumentName}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
<label
|
||||
className={cn('text-sm', {
|
||||
'cursor-not-allowed text-muted-foreground': !canUseUploadedDocumentName,
|
||||
})}
|
||||
htmlFor="document-name-source-upload"
|
||||
>
|
||||
<Trans>Use uploaded file name</Trans>
|
||||
</label>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
type="button"
|
||||
aria-label={_(msg`About uploaded file naming`)}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="z-[99999] max-w-xs">
|
||||
<Trans>
|
||||
The document name will use the most recently uploaded file name without its
|
||||
extension.
|
||||
</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{lastUploadedFile && (
|
||||
<p
|
||||
className="max-w-sm truncate text-muted-foreground text-xs"
|
||||
title={lastUploadedFile.name}
|
||||
>
|
||||
{lastUploadedFile.name}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!canUseUploadedDocumentName && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<Trans>Upload a custom document to use its file name.</Trans>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem id="document-name-source-custom" value={DOCUMENT_NAME_SOURCE.CUSTOM} />
|
||||
<label className="text-sm" htmlFor="document-name-source-custom">
|
||||
<Trans>Enter custom document name</Trans>
|
||||
</label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{documentNameSource === DOCUMENT_NAME_SOURCE.CUSTOM && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="customDocumentName"
|
||||
render={({ field }) => (
|
||||
<FormItem className="ml-6">
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
aria-label={_(msg`Custom document name`)}
|
||||
placeholder={_(msg`Enter a document name`)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
|
||||
@@ -21,6 +21,12 @@ export type DocumentAttachmentsPopoverProps = {
|
||||
envelopeId: string;
|
||||
buttonClassName?: string;
|
||||
buttonSize?: 'sm' | 'default';
|
||||
|
||||
/**
|
||||
* Visually hide the button label below the `sm` breakpoint while keeping it
|
||||
* available to screen readers.
|
||||
*/
|
||||
collapseLabelOnMobile?: boolean;
|
||||
};
|
||||
|
||||
const ZAttachmentFormSchema = z.object({
|
||||
@@ -35,6 +41,7 @@ export const DocumentAttachmentsPopover = ({
|
||||
envelopeId,
|
||||
buttonClassName,
|
||||
buttonSize,
|
||||
collapseLabelOnMobile = false,
|
||||
}: DocumentAttachmentsPopoverProps) => {
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
@@ -129,7 +136,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,12 @@ import { z } from 'zod';
|
||||
export type EmbeddedEditorAttachmentPopoverProps = {
|
||||
buttonClassName?: string;
|
||||
buttonSize?: 'sm' | 'default';
|
||||
|
||||
/**
|
||||
* Visually hide the button label below the `sm` breakpoint while keeping it
|
||||
* available to screen readers.
|
||||
*/
|
||||
collapseLabelOnMobile?: boolean;
|
||||
};
|
||||
|
||||
const ZAttachmentFormSchema = z.object({
|
||||
@@ -32,6 +38,7 @@ type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>;
|
||||
export const EmbeddedEditorAttachmentPopover = ({
|
||||
buttonClassName,
|
||||
buttonSize,
|
||||
collapseLabelOnMobile = false,
|
||||
}: EmbeddedEditorAttachmentPopoverProps) => {
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
@@ -90,7 +97,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>
|
||||
|
||||
+41
-22
@@ -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,18 @@ export const fieldButtonList = [
|
||||
type EnvelopeEditorFieldDragDropProps = {
|
||||
selectedRecipientId: number | null;
|
||||
selectedEnvelopeItemId: string | null;
|
||||
|
||||
/**
|
||||
* Called when the user picks a field type from the palette, before the field
|
||||
* is placed on the document.
|
||||
*/
|
||||
onFieldSelect?: (fieldType: FieldType) => void;
|
||||
};
|
||||
|
||||
export const EnvelopeEditorFieldDragDrop = ({
|
||||
selectedRecipientId,
|
||||
selectedEnvelopeItemId,
|
||||
onFieldSelect,
|
||||
}: EnvelopeEditorFieldDragDropProps) => {
|
||||
const { envelope, editorFields, isTemplate, getRecipientColorKey } = useCurrentEnvelopeEditor();
|
||||
|
||||
@@ -245,7 +253,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 +278,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,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+252
-192
@@ -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,10 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
|
||||
const { revalidate } = useRevalidator();
|
||||
|
||||
// Whether the fields panel overlay is open. Only relevant below the `md`
|
||||
// breakpoint, where the panel is hidden behind a floating trigger button.
|
||||
const [isMobileFieldsPanelOpen, setIsMobileFieldsPanelOpen] = useState(false);
|
||||
|
||||
const envelopeItemPermissions = useMemo(
|
||||
() => getEnvelopeItemPermissions(envelope, envelope.recipients),
|
||||
[envelope, envelope.recipients],
|
||||
@@ -187,6 +191,10 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
}, []);
|
||||
|
||||
const onDetectClick = () => {
|
||||
// Close the panel overlay on small viewports so the document stays
|
||||
// visible while the AI dialogs are open.
|
||||
setIsMobileFieldsPanelOpen(false);
|
||||
|
||||
if (!team.preferences.aiFeaturesEnabled) {
|
||||
setIsAiEnableDialogOpen(true);
|
||||
return;
|
||||
@@ -308,212 +316,264 @@ 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. */}
|
||||
<Button
|
||||
type="button"
|
||||
className="fixed right-4 bottom-4 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}
|
||||
/>
|
||||
</>
|
||||
{/*
|
||||
* Below `md` the panel is a slide-in overlay, from `md` upwards it is a
|
||||
* static sidebar.
|
||||
*
|
||||
* This is deliberately one always-mounted element 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` (with the transition covering visibility) 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' : 'translate-x-full max-md:invisible',
|
||||
'md:sticky md:top-0 md:z-auto md:h-full md:w-80 md:max-w-none md:flex-shrink-0 md:translate-x-0 md:shadow-none md:transition-none',
|
||||
)}
|
||||
</section>
|
||||
>
|
||||
{/* Panel header with close button, visible below the `md` breakpoint. */}
|
||||
<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,18 @@ 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">
|
||||
{/* The logo and separator are hidden on small viewports to leave room for the title. */}
|
||||
{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 +99,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 +119,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 +181,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 +203,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 +215,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>
|
||||
}
|
||||
/>
|
||||
|
||||
+9
-8
@@ -592,7 +592,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
|
||||
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 sm:flex-row">
|
||||
<div>
|
||||
<CardTitle>
|
||||
<Trans>Recipients</Trans>
|
||||
@@ -602,7 +602,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>
|
||||
@@ -842,9 +842,9 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
'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) && (
|
||||
@@ -900,8 +900,9 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
!form.formState.errors.signers[index]?.email,
|
||||
})}
|
||||
>
|
||||
{!showAdvancedSettings && index === 0 && (
|
||||
<FormLabel>
|
||||
{/* Below `sm` the rows are stacked, so every row needs its own labels. */}
|
||||
{!showAdvancedSettings && (
|
||||
<FormLabel className={cn(index !== 0 && 'sm:hidden')}>
|
||||
<Trans>Email</Trans>
|
||||
</FormLabel>
|
||||
)}
|
||||
@@ -947,8 +948,8 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
!form.formState.errors.signers[index]?.name,
|
||||
})}
|
||||
>
|
||||
{!showAdvancedSettings && index === 0 && (
|
||||
<FormLabel>
|
||||
{!showAdvancedSettings && (
|
||||
<FormLabel className={cn(index !== 0 && 'sm:hidden')}>
|
||||
<Trans>Name</Trans>
|
||||
</FormLabel>
|
||||
)}
|
||||
|
||||
@@ -448,7 +448,7 @@ 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" />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useIsMobileViewport } from '@documenso/lib/client-only/hooks/use-media-query';
|
||||
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';
|
||||
@@ -89,6 +90,8 @@ export const EnvelopeEditor = () => {
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const isMobileViewport = useIsMobileViewport();
|
||||
|
||||
const {
|
||||
general: { minimizeLeftSidebar, allowUploadAndRecipientStep, allowAddFieldsStep, allowPreviewStep },
|
||||
actions: {
|
||||
@@ -101,6 +104,10 @@ export const EnvelopeEditor = () => {
|
||||
},
|
||||
} = editorConfig;
|
||||
|
||||
// Fall back to the icon-only sidebar on small viewports since the full
|
||||
// sidebar would consume most of the screen.
|
||||
const isSidebarMinimized = minimizeLeftSidebar || isMobileViewport;
|
||||
|
||||
const envelopeEditorSteps = useMemo(() => {
|
||||
const steps: EnvelopeEditorStepData[] = [];
|
||||
|
||||
@@ -175,19 +182,19 @@ 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="h-[100dvh] w-full bg-envelope-editor-background">
|
||||
<EnvelopeEditorHeader />
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex h-[calc(100vh-4rem)] w-screen">
|
||||
<div className="flex h-[calc(100dvh-4rem)] w-full">
|
||||
{/* Left Section - Step Navigation */}
|
||||
<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,
|
||||
'w-14': isSidebarMinimized,
|
||||
})}
|
||||
>
|
||||
{/* Left section step selector. */}
|
||||
{minimizeLeftSidebar ? (
|
||||
{isSidebarMinimized ? (
|
||||
<div className="flex justify-center px-4">
|
||||
<div className="relative flex h-10 w-10 items-center justify-center">
|
||||
<svg className="size-10 -rotate-90" viewBox="0 0 40 40" aria-hidden>
|
||||
@@ -254,8 +261,8 @@ export const EnvelopeEditor = () => {
|
||||
|
||||
<div
|
||||
className={cn('space-y-3', {
|
||||
'px-4': !minimizeLeftSidebar,
|
||||
'mt-4 flex flex-col items-center': minimizeLeftSidebar,
|
||||
'px-4': !isSidebarMinimized,
|
||||
'mt-4 flex flex-col items-center': isSidebarMinimized,
|
||||
})}
|
||||
>
|
||||
{envelopeEditorSteps.map((step) => {
|
||||
@@ -274,7 +281,7 @@ export const EnvelopeEditor = () => {
|
||||
: 'border border-gray-200 hover:bg-gray-50 dark:border-gray-400/20 dark:hover:bg-gray-400/10'
|
||||
}`,
|
||||
{
|
||||
'p-3': !minimizeLeftSidebar,
|
||||
'p-3': !isSidebarMinimized,
|
||||
},
|
||||
)}
|
||||
onClick={() => void navigateToStep(step.id as EnvelopeEditorStep)}
|
||||
@@ -290,7 +297,7 @@ export const EnvelopeEditor = () => {
|
||||
<Icon className={`h-4 w-4 ${isActive ? 'text-green-600' : 'text-gray-600'}`} />
|
||||
</div>
|
||||
|
||||
{!minimizeLeftSidebar && (
|
||||
{!isSidebarMinimized && (
|
||||
<div>
|
||||
<div
|
||||
className={`font-medium text-sm ${
|
||||
@@ -312,17 +319,17 @@ export const EnvelopeEditor = () => {
|
||||
|
||||
<Separator
|
||||
className={cn('my-6', {
|
||||
'mx-auto mb-4 w-4/5': minimizeLeftSidebar,
|
||||
'mx-auto mb-4 w-4/5': isSidebarMinimized,
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* Quick Actions. */}
|
||||
<div
|
||||
className={cn('space-y-3 px-4 [&_.lucide]:text-muted-foreground', {
|
||||
'px-2': minimizeLeftSidebar,
|
||||
'px-2': isSidebarMinimized,
|
||||
})}
|
||||
>
|
||||
{!minimizeLeftSidebar && (
|
||||
{!isSidebarMinimized && (
|
||||
<h4 className="font-semibold text-foreground text-sm">
|
||||
<Trans>Quick Actions</Trans>
|
||||
</h4>
|
||||
@@ -334,7 +341,7 @@ export const EnvelopeEditor = () => {
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Settings`)}>
|
||||
<SettingsIcon className="h-4 w-4" />
|
||||
|
||||
{!minimizeLeftSidebar && (
|
||||
{!isSidebarMinimized && (
|
||||
<span className="ml-2">
|
||||
{isDocument ? <Trans>Document Settings</Trans> : <Trans>Template Settings</Trans>}
|
||||
</span>
|
||||
@@ -352,7 +359,7 @@ export const EnvelopeEditor = () => {
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Send Envelope`)}>
|
||||
<SendIcon className="h-4 w-4" />
|
||||
|
||||
{!minimizeLeftSidebar && (
|
||||
{!isSidebarMinimized && (
|
||||
<span className="ml-2">
|
||||
<Trans>Send Document</Trans>
|
||||
</span>
|
||||
@@ -367,7 +374,7 @@ export const EnvelopeEditor = () => {
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Resend Envelope`)}>
|
||||
<SendIcon className="h-4 w-4" />
|
||||
|
||||
{!minimizeLeftSidebar && (
|
||||
{!isSidebarMinimized && (
|
||||
<span className="ml-2">
|
||||
<Trans>Resend Document</Trans>
|
||||
</span>
|
||||
@@ -389,7 +396,7 @@ export const EnvelopeEditor = () => {
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Direct Link`)}>
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
|
||||
{!minimizeLeftSidebar && (
|
||||
{!isSidebarMinimized && (
|
||||
<span className="ml-2">
|
||||
<Trans>Direct Link</Trans>
|
||||
</span>
|
||||
@@ -407,7 +414,7 @@ export const EnvelopeEditor = () => {
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Duplicate Envelope`)}>
|
||||
<CopyPlusIcon className="h-4 w-4" />
|
||||
|
||||
{!minimizeLeftSidebar && (
|
||||
{!isSidebarMinimized && (
|
||||
<span className="ml-2">
|
||||
{isDocument ? <Trans>Duplicate Document</Trans> : <Trans>Duplicate Template</Trans>}
|
||||
</span>
|
||||
@@ -424,7 +431,7 @@ export const EnvelopeEditor = () => {
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Save as Template`)}>
|
||||
<FileOutputIcon className="h-4 w-4" />
|
||||
|
||||
{!minimizeLeftSidebar && (
|
||||
{!isSidebarMinimized && (
|
||||
<span className="ml-2">
|
||||
<Trans>Save as Template</Trans>
|
||||
</span>
|
||||
@@ -444,7 +451,7 @@ export const EnvelopeEditor = () => {
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Download PDF`)}>
|
||||
<DownloadCloudIcon className="h-4 w-4" />
|
||||
|
||||
{!minimizeLeftSidebar && (
|
||||
{!isSidebarMinimized && (
|
||||
<span className="ml-2">
|
||||
<Trans>Download PDF</Trans>
|
||||
</span>
|
||||
@@ -472,7 +479,7 @@ export const EnvelopeEditor = () => {
|
||||
>
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
|
||||
{!minimizeLeftSidebar && (
|
||||
{!isSidebarMinimized && (
|
||||
<span className="ml-2">
|
||||
{isDocument ? <Trans>Delete Document</Trans> : <Trans>Delete Template</Trans>}
|
||||
</span>
|
||||
@@ -494,20 +501,20 @@ export const EnvelopeEditor = () => {
|
||||
{!editorConfig.embedded && (
|
||||
<div
|
||||
className={cn('mt-auto px-4', {
|
||||
'px-2': minimizeLeftSidebar,
|
||||
'px-2': isSidebarMinimized,
|
||||
})}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('w-full justify-start', {
|
||||
'flex items-center justify-center': minimizeLeftSidebar,
|
||||
'flex items-center justify-center': isSidebarMinimized,
|
||||
})}
|
||||
asChild
|
||||
>
|
||||
<Link to={relativePath.basePath}>
|
||||
<ArrowLeftIcon className="h-4 w-4 flex-shrink-0" />
|
||||
|
||||
{!minimizeLeftSidebar && (
|
||||
{!isSidebarMinimized && (
|
||||
<span className="ml-2">
|
||||
{isDocument ? <Trans>Return to documents</Trans> : <Trans>Return to templates</Trans>}
|
||||
</span>
|
||||
|
||||
@@ -7,7 +7,7 @@ services:
|
||||
volumes:
|
||||
- documenso_database:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB']
|
||||
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER}']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
@@ -8,7 +8,7 @@ services:
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?err}
|
||||
- POSTGRES_DB=${POSTGRES_DB:?err}
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}']
|
||||
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER}']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
@@ -8,7 +8,7 @@ services:
|
||||
- POSTGRES_PASSWORD=password
|
||||
- POSTGRES_DB=documenso
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB']
|
||||
test: ['CMD-SHELL', 'pg_isready -U documenso']
|
||||
interval: 1s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
@@ -156,8 +156,8 @@ test('[TEMPLATES]: use template', async ({ page }) => {
|
||||
// Get input with Email label placeholder.
|
||||
await page.getByLabel('Email').click();
|
||||
await page.getByLabel('Email').fill(teamMemberUser.email);
|
||||
await page.getByRole('textbox', { name: 'Name', exact: true }).click();
|
||||
await page.getByRole('textbox', { name: 'Name', exact: true }).fill('name');
|
||||
await page.getByLabel('Name').click();
|
||||
await page.getByLabel('Name').fill('name');
|
||||
|
||||
await page.getByRole('button', { name: 'Create as draft' }).click();
|
||||
await page.waitForURL(/\/t\/.+\/documents/);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useCallback, useSyncExternalStore } from 'react';
|
||||
|
||||
/**
|
||||
* Tracks whether the given CSS media query currently matches.
|
||||
*
|
||||
* Returns `false` on the server since the viewport is unknown until hydration.
|
||||
*/
|
||||
export const useMediaQuery = (query: string) => {
|
||||
const subscribe = useCallback(
|
||||
(onStoreChange: () => void) => {
|
||||
const mediaQueryList = window.matchMedia(query);
|
||||
|
||||
mediaQueryList.addEventListener('change', onStoreChange);
|
||||
|
||||
return () => mediaQueryList.removeEventListener('change', onStoreChange);
|
||||
},
|
||||
[query],
|
||||
);
|
||||
|
||||
return useSyncExternalStore(
|
||||
subscribe,
|
||||
() => window.matchMedia(query).matches,
|
||||
// The server snapshot: the viewport is unknown during SSR, so we assume
|
||||
// the query does not match rather than throwing. React re-renders with
|
||||
// the real value after hydration, so the only cost is a brief first
|
||||
// paint with the non-matching variant.
|
||||
() => false,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the viewport is below the Tailwind `md` breakpoint (768px).
|
||||
*/
|
||||
export const useIsMobileViewport = () => useMediaQuery('(max-width: 767px)');
|
||||
+147
-147
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user