mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +10:00
fix: add tests
This commit is contained in:
@@ -6,7 +6,7 @@ import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
|
||||
import { FileTextIcon, SparklesIcon } from 'lucide-react';
|
||||
import { Link, useRevalidator, useSearchParams } from 'react-router';
|
||||
import { useRevalidator, useSearchParams } from 'react-router';
|
||||
import { isDeepEqual } from 'remeda';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
@@ -75,7 +75,7 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
|
||||
const scrollableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { envelope, editorFields, relativePath, editorConfig } = useCurrentEnvelopeEditor();
|
||||
const { envelope, editorFields, navigateToStep, editorConfig } = useCurrentEnvelopeEditor();
|
||||
|
||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
|
||||
@@ -172,10 +172,8 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<Button asChild variant="outline">
|
||||
<Link to={`${relativePath.editorPath}`}>
|
||||
<Trans>Add Recipients</Trans>
|
||||
</Link>
|
||||
<Button variant="outline" onClick={() => void navigateToStep('upload')}>
|
||||
<Trans>Add Recipients</Trans>
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -141,6 +141,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
},
|
||||
{
|
||||
enabled: debouncedRecipientSearchQuery.length > 1,
|
||||
retry: false,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
+9
-7
@@ -175,7 +175,7 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
const { t, i18n } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { envelope, updateEnvelopeAsync, editorConfig } = useCurrentEnvelopeEditor();
|
||||
const { envelope, updateEnvelopeAsync, editorConfig, isEmbedded } = useCurrentEnvelopeEditor();
|
||||
|
||||
const { settings } = editorConfig;
|
||||
|
||||
@@ -286,11 +286,13 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
|
||||
setOpen(false);
|
||||
|
||||
toast({
|
||||
title: t`Success`,
|
||||
description: t`Envelope updated`,
|
||||
duration: 5000,
|
||||
});
|
||||
if (!isEmbedded) {
|
||||
toast({
|
||||
title: t`Success`,
|
||||
description: t`Envelope updated`,
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
@@ -348,7 +350,7 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
<DialogContent className="flex w-full !max-w-5xl flex-row gap-0 p-0">
|
||||
{/* Sidebar. */}
|
||||
<div className="flex w-80 flex-col border-r bg-accent/20">
|
||||
<DialogHeader className="p-6 pb-4">
|
||||
<DialogHeader className="p-6 pb-4" data-testid="envelope-editor-settings-dialog-header">
|
||||
<DialogTitle>
|
||||
<Trans>Document Settings</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
@@ -8,7 +8,6 @@ import { DocumentStatus } from '@prisma/client';
|
||||
import { FileWarningIcon, GripVerticalIcon, Loader2 } from 'lucide-react';
|
||||
import { X } from 'lucide-react';
|
||||
import { ErrorCode as DropzoneErrorCode, type FileRejection } from 'react-dropzone';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import {
|
||||
@@ -17,6 +16,7 @@ import {
|
||||
} from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||
import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { PRESIGNED_ENVELOPE_ITEM_ID_PREFIX } from '@documenso/lib/utils/embed-config';
|
||||
import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope';
|
||||
@@ -53,7 +53,7 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
const { maximumEnvelopeItemCount, remaining } = useLimits();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { envelope, setLocalEnvelope, relativePath, editorFields, editorConfig, isEmbedded } =
|
||||
const { envelope, setLocalEnvelope, editorFields, editorConfig, isEmbedded, navigateToStep } =
|
||||
useCurrentEnvelopeEditor();
|
||||
|
||||
const { envelopeItems: uploadConfig } = editorConfig;
|
||||
@@ -108,21 +108,23 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
);
|
||||
|
||||
const onFileDrop = async (files: File[]) => {
|
||||
const newUploadingFiles: (LocalFile & { file: File; data: Uint8Array<ArrayBuffer> | null })[] =
|
||||
await Promise.all(
|
||||
files.map(async (file) => {
|
||||
return {
|
||||
id: nanoid(),
|
||||
envelopeItemId: isEmbedded ? `${PRESIGNED_ENVELOPE_ITEM_ID_PREFIX}${nanoid()}` : null,
|
||||
title: file.name,
|
||||
file,
|
||||
isUploading: isEmbedded ? false : true,
|
||||
// Clone the buffer so it can be read multiple times (File.arrayBuffer() consumes the stream once)
|
||||
data: isEmbedded ? new Uint8Array((await file.arrayBuffer()).slice(0)) : null,
|
||||
isError: false,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const newUploadingFiles: (LocalFile & {
|
||||
file: File;
|
||||
data: TEditorEnvelope['envelopeItems'][number]['data'] | null;
|
||||
})[] = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
return {
|
||||
id: nanoid(),
|
||||
envelopeItemId: isEmbedded ? `${PRESIGNED_ENVELOPE_ITEM_ID_PREFIX}${nanoid()}` : null,
|
||||
title: file.name,
|
||||
file,
|
||||
isUploading: isEmbedded ? false : true,
|
||||
// Clone the buffer so it can be read multiple times (File.arrayBuffer() consumes the stream once)
|
||||
data: isEmbedded ? new Uint8Array((await file.arrayBuffer()).slice(0)) : null,
|
||||
isError: false,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
setLocalFiles((prev) => [...prev, ...newUploadingFiles]);
|
||||
|
||||
@@ -194,7 +196,9 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
* Hide the envelope item from the list on deletion.
|
||||
*/
|
||||
const onFileDelete = (envelopeItemId: string) => {
|
||||
setLocalFiles((prev) => prev.filter((uploadingFile) => uploadingFile.id !== envelopeItemId));
|
||||
setLocalFiles((prev) =>
|
||||
prev.filter((uploadingFile) => uploadingFile.envelopeItemId !== envelopeItemId),
|
||||
);
|
||||
|
||||
const fieldsWithoutDeletedItem = envelope.fields.filter(
|
||||
(field) => field.envelopeItemId !== envelopeItemId,
|
||||
@@ -476,13 +480,10 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
|
||||
{/* Recipients Section */}
|
||||
<EnvelopeEditorRecipientForm />
|
||||
|
||||
{editorConfig.general.allowAddFieldsStep && (
|
||||
<div className="flex justify-end">
|
||||
<Button asChild>
|
||||
<Link to={`${relativePath.editorPath}?step=addFields`}>
|
||||
<Trans>Add Fields</Trans>
|
||||
</Link>
|
||||
<Button type="button" onClick={() => void navigateToStep('addFields')}>
|
||||
<Trans>Add Fields</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useNavigate, useSearchParams } from 'react-router';
|
||||
import { Link } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
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 { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
@@ -43,8 +44,6 @@ import EnvelopeEditorHeader from './envelope-editor-header';
|
||||
import { EnvelopeEditorPreviewPage } from './envelope-editor-preview-page';
|
||||
import { EnvelopeEditorUploadPage } from './envelope-editor-upload-page';
|
||||
|
||||
type EnvelopeEditorStep = 'upload' | 'addFields' | 'preview';
|
||||
|
||||
type EnvelopeEditorStepData = {
|
||||
id: string;
|
||||
title: MessageDescriptor;
|
||||
@@ -83,14 +82,12 @@ export const EnvelopeEditor = () => {
|
||||
editorConfig,
|
||||
isDocument,
|
||||
isTemplate,
|
||||
isAutosaving,
|
||||
flushAutosave,
|
||||
relativePath,
|
||||
syncEnvelope,
|
||||
navigateToStep,
|
||||
} = useCurrentEnvelopeEditor();
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [isStepLoading, setIsStepLoading] = useState(false);
|
||||
|
||||
const {
|
||||
general: {
|
||||
@@ -130,47 +127,25 @@ export const EnvelopeEditor = () => {
|
||||
}));
|
||||
}, [editorConfig]);
|
||||
|
||||
const [currentStep, setCurrentStep] = useState<EnvelopeEditorStep>(() => {
|
||||
const searchParamStep = searchParams.get('step') as EnvelopeEditorStep | undefined;
|
||||
const [currentStep, setCurrentStep] = useState<{ step: EnvelopeEditorStep; isLoading: boolean }>(
|
||||
() => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const searchParamStep = searchParams.get('step') as EnvelopeEditorStep | undefined;
|
||||
|
||||
// Empty URL param equals upload, otherwise use the step URL param
|
||||
if (!searchParamStep) {
|
||||
return 'upload';
|
||||
}
|
||||
// Empty URL param equals upload, otherwise use the step URL param
|
||||
if (!searchParamStep) {
|
||||
return { step: 'upload', isLoading: false };
|
||||
}
|
||||
|
||||
const validSteps: EnvelopeEditorStep[] = ['upload', 'addFields', 'preview'];
|
||||
const validSteps: EnvelopeEditorStep[] = ['upload', 'addFields', 'preview'];
|
||||
|
||||
if (validSteps.includes(searchParamStep)) {
|
||||
return searchParamStep;
|
||||
}
|
||||
if (validSteps.includes(searchParamStep)) {
|
||||
return { step: searchParamStep, isLoading: false };
|
||||
}
|
||||
|
||||
return 'upload';
|
||||
});
|
||||
|
||||
const navigateToStep = (step: EnvelopeEditorStep) => {
|
||||
setCurrentStep(step);
|
||||
|
||||
void flushAutosave();
|
||||
|
||||
if (!isStepLoading && isAutosaving) {
|
||||
setIsStepLoading(true);
|
||||
}
|
||||
|
||||
// Update URL params: empty for upload, otherwise set the step
|
||||
if (step === 'upload') {
|
||||
setSearchParams((prev) => {
|
||||
const newParams = new URLSearchParams(prev);
|
||||
newParams.delete('step');
|
||||
return newParams;
|
||||
});
|
||||
} else {
|
||||
setSearchParams((prev) => {
|
||||
const newParams = new URLSearchParams(prev);
|
||||
newParams.set('step', step);
|
||||
return newParams;
|
||||
});
|
||||
}
|
||||
};
|
||||
return { step: 'upload', isLoading: false };
|
||||
},
|
||||
);
|
||||
|
||||
// Watch the URL params and setStep if the step changes.
|
||||
useEffect(() => {
|
||||
@@ -178,20 +153,19 @@ export const EnvelopeEditor = () => {
|
||||
|
||||
const foundStep = envelopeEditorSteps.find((step) => step.id === stepParam);
|
||||
|
||||
if (foundStep && foundStep.id !== currentStep) {
|
||||
if (foundStep && foundStep.id !== currentStep.step) {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
navigateToStep(foundStep.id as EnvelopeEditorStep);
|
||||
void navigateToStep(foundStep.id as EnvelopeEditorStep).then(() => {
|
||||
setCurrentStep({
|
||||
step: foundStep.id as EnvelopeEditorStep,
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAutosaving) {
|
||||
setIsStepLoading(false);
|
||||
}
|
||||
}, [isAutosaving]);
|
||||
|
||||
const currentStepData =
|
||||
envelopeEditorSteps.find((step) => step.id === currentStep) || envelopeEditorSteps[0];
|
||||
envelopeEditorSteps.find((step) => step.id === currentStep.step) || envelopeEditorSteps[0];
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen bg-gray-50 dark:bg-background">
|
||||
@@ -285,11 +259,12 @@ export const EnvelopeEditor = () => {
|
||||
>
|
||||
{envelopeEditorSteps.map((step) => {
|
||||
const Icon = step.icon;
|
||||
const isActive = currentStep === step.id;
|
||||
const isActive = currentStep.step === step.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={step.id}
|
||||
data-testid={`envelope-editor-step-${step.id}`}
|
||||
type="button"
|
||||
className={cn(
|
||||
`cursor-pointer rounded-lg text-left transition-colors ${
|
||||
@@ -301,7 +276,7 @@ export const EnvelopeEditor = () => {
|
||||
'p-3': !minimizeLeftSidebar,
|
||||
},
|
||||
)}
|
||||
onClick={() => navigateToStep(step.id as EnvelopeEditorStep)}
|
||||
onClick={() => void navigateToStep(step.id as EnvelopeEditorStep)}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div
|
||||
@@ -574,10 +549,13 @@ export const EnvelopeEditor = () => {
|
||||
</div>
|
||||
|
||||
{/* Main Content - Changes based on current step */}
|
||||
<AnimateGenericFadeInOut className="flex-1 overflow-y-auto" key={currentStep}>
|
||||
<AnimateGenericFadeInOut
|
||||
className="flex-1 overflow-y-auto"
|
||||
key={currentStep.isLoading ? `loading-${currentStep.step}` : currentStep.step}
|
||||
>
|
||||
{match({
|
||||
currentStep,
|
||||
isStepLoading,
|
||||
isStepLoading: currentStep.isLoading,
|
||||
currentStep: currentStep.step,
|
||||
allowUploadAndRecipientStep,
|
||||
allowAddFieldsStep,
|
||||
allowPreviewStep,
|
||||
|
||||
@@ -0,0 +1,647 @@
|
||||
/**
|
||||
* This is an internal test page for the embedding system.
|
||||
*
|
||||
* We use this to test embeds for E2E testing.
|
||||
*
|
||||
* No translations required.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useNavigate, useSearchParams } from 'react-router';
|
||||
|
||||
export const loader = () => {
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
throw new Error('This page is only available in development mode.');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Dummy embed test page.
|
||||
*
|
||||
* Simulates an embedding parent that renders the V2 authoring iframe
|
||||
* with configurable features, externalId, and mode.
|
||||
*
|
||||
* Navigate to /embed/dummy to use.
|
||||
*/
|
||||
export default function EmbedDummyPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [token, setToken] = useState(() => searchParams.get('token') || '');
|
||||
const [externalId, setExternalId] = useState(() => searchParams.get('externalId') || '');
|
||||
const [mode, setMode] = useState<'create' | 'edit'>(
|
||||
() => (searchParams.get('mode') as 'create' | 'edit') || 'create',
|
||||
);
|
||||
const [envelopeId, setEnvelopeId] = useState(() => searchParams.get('envelopeId') || '');
|
||||
const [envelopeType, setEnvelopeType] = useState<'DOCUMENT' | 'TEMPLATE'>(
|
||||
() => (searchParams.get('envelopeType') as 'DOCUMENT' | 'TEMPLATE') || 'DOCUMENT',
|
||||
);
|
||||
|
||||
// Auto-launch if query params are present on mount
|
||||
const [iframeSrc, setIframeSrc] = useState<string | null>(null);
|
||||
const [iframeKey, setIframeKey] = useState(0);
|
||||
const [messages, setMessages] = useState<string[]>([]);
|
||||
|
||||
// Feature flags state -- grouped by section
|
||||
const [generalFeatures, setGeneralFeatures] = useState({
|
||||
allowConfigureEnvelopeTitle: true,
|
||||
allowUploadAndRecipientStep: true,
|
||||
allowAddFieldsStep: true,
|
||||
allowPreviewStep: true,
|
||||
minimizeLeftSidebar: true,
|
||||
});
|
||||
|
||||
const [settingsFeatures, setSettingsFeatures] = useState({
|
||||
allowConfigureSignatureTypes: true,
|
||||
allowConfigureLanguage: true,
|
||||
allowConfigureDateFormat: true,
|
||||
allowConfigureTimezone: true,
|
||||
allowConfigureRedirectUrl: true,
|
||||
allowConfigureDistribution: true,
|
||||
});
|
||||
|
||||
const [actionsFeatures, setActionsFeatures] = useState({
|
||||
allowAttachments: true,
|
||||
allowDistributing: false,
|
||||
allowDirectLink: false,
|
||||
allowDuplication: false,
|
||||
allowDownloadPDF: false,
|
||||
allowDeletion: false,
|
||||
allowReturnToPreviousPage: false,
|
||||
});
|
||||
|
||||
const [envelopeItemsFeatures, setEnvelopeItemsFeatures] = useState({
|
||||
allowConfigureTitle: true,
|
||||
allowConfigureOrder: true,
|
||||
allowUpload: true,
|
||||
allowDelete: true,
|
||||
});
|
||||
|
||||
const [recipientsFeatures, setRecipientsFeatures] = useState({
|
||||
allowAIDetection: true,
|
||||
allowConfigureSigningOrder: true,
|
||||
allowConfigureDictateNextSigner: true,
|
||||
allowApproverRole: true,
|
||||
allowViewerRole: true,
|
||||
allowCCerRole: true,
|
||||
allowAssistantRole: true,
|
||||
});
|
||||
|
||||
const [fieldsFeatures, setFieldsFeatures] = useState({
|
||||
allowAIDetection: true,
|
||||
});
|
||||
|
||||
// CSS theming state
|
||||
const [darkModeDisabled, setDarkModeDisabled] = useState(false);
|
||||
const [rawCss, setRawCss] = useState('');
|
||||
const [cssVars, setCssVars] = useState<Record<string, string>>({
|
||||
background: '',
|
||||
foreground: '',
|
||||
muted: '',
|
||||
mutedForeground: '',
|
||||
popover: '',
|
||||
popoverForeground: '',
|
||||
card: '',
|
||||
cardBorder: '',
|
||||
cardBorderTint: '',
|
||||
cardForeground: '',
|
||||
fieldCard: '',
|
||||
fieldCardBorder: '',
|
||||
fieldCardForeground: '',
|
||||
widget: '',
|
||||
widgetForeground: '',
|
||||
border: '',
|
||||
input: '',
|
||||
primary: '',
|
||||
primaryForeground: '',
|
||||
secondary: '',
|
||||
secondaryForeground: '',
|
||||
accent: '',
|
||||
accentForeground: '',
|
||||
destructive: '',
|
||||
destructiveForeground: '',
|
||||
ring: '',
|
||||
radius: '',
|
||||
warning: '',
|
||||
});
|
||||
|
||||
const [isResolvingToken, setIsResolvingToken] = useState(false);
|
||||
const [tokenError, setTokenError] = useState<string | null>(null);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const hasAutoLaunched = useRef(false);
|
||||
|
||||
/**
|
||||
* If the token starts with "api_", exchange it for a presign token
|
||||
* via the embedding presign endpoint. Otherwise return as-is.
|
||||
*/
|
||||
const resolveToken = async (inputToken: string): Promise<string> => {
|
||||
if (!inputToken.startsWith('api_')) {
|
||||
return inputToken;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/v2/embedding/create-presign-token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${inputToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Failed to exchange API token (${response.status}): ${text}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const presignToken = data?.token;
|
||||
|
||||
if (!presignToken || typeof presignToken !== 'string') {
|
||||
throw new Error(`Unexpected response shape: ${JSON.stringify(data)}`);
|
||||
}
|
||||
|
||||
return presignToken;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
const timestamp = new Date().toISOString().slice(11, 19);
|
||||
setMessages((prev) => [...prev, `[${timestamp}] ${JSON.stringify(event.data, null, 2)}`]);
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => window.removeEventListener('message', handleMessage);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
// Auto-launch on mount if token is present in query params
|
||||
useEffect(() => {
|
||||
if (hasAutoLaunched.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const initialToken = searchParams.get('token');
|
||||
|
||||
if (initialToken) {
|
||||
hasAutoLaunched.current = true;
|
||||
void launchEmbed(initialToken);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateQueryParams = (params: {
|
||||
token: string;
|
||||
externalId: string;
|
||||
mode: string;
|
||||
envelopeId: string;
|
||||
envelopeType: string;
|
||||
}) => {
|
||||
const newParams = new URLSearchParams();
|
||||
|
||||
if (params.token) {
|
||||
newParams.set('token', params.token);
|
||||
}
|
||||
|
||||
if (params.externalId) {
|
||||
newParams.set('externalId', params.externalId);
|
||||
}
|
||||
|
||||
if (params.mode && params.mode !== 'create') {
|
||||
newParams.set('mode', params.mode);
|
||||
}
|
||||
|
||||
if (params.envelopeId) {
|
||||
newParams.set('envelopeId', params.envelopeId);
|
||||
}
|
||||
|
||||
if (params.envelopeType && params.envelopeType !== 'DOCUMENT') {
|
||||
newParams.set('envelopeType', params.envelopeType);
|
||||
}
|
||||
|
||||
const qs = newParams.toString();
|
||||
|
||||
void navigate(qs ? `?${qs}` : '.', { replace: true });
|
||||
};
|
||||
|
||||
const launchEmbed = async (overrideToken?: string) => {
|
||||
const inputToken = overrideToken ?? token;
|
||||
|
||||
if (!inputToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTokenError(null);
|
||||
setIsResolvingToken(true);
|
||||
|
||||
let presignToken: string;
|
||||
|
||||
try {
|
||||
presignToken = await resolveToken(inputToken);
|
||||
} catch (err) {
|
||||
setTokenError(err instanceof Error ? err.message : String(err));
|
||||
setIsResolvingToken(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsResolvingToken(false);
|
||||
|
||||
// Filter out empty cssVars entries
|
||||
const filteredCssVars: Record<string, string> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(cssVars)) {
|
||||
if (value) {
|
||||
filteredCssVars[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const hashData = {
|
||||
externalId: externalId || undefined,
|
||||
type: mode === 'create' ? envelopeType : undefined,
|
||||
darkModeDisabled: darkModeDisabled || undefined,
|
||||
css: rawCss || undefined,
|
||||
cssVars: Object.keys(filteredCssVars).length > 0 ? filteredCssVars : undefined,
|
||||
features: {
|
||||
general: generalFeatures,
|
||||
settings: settingsFeatures,
|
||||
actions: actionsFeatures,
|
||||
envelopeItems: envelopeItemsFeatures,
|
||||
recipients: recipientsFeatures,
|
||||
fields: fieldsFeatures,
|
||||
},
|
||||
};
|
||||
|
||||
const hash = btoa(encodeURIComponent(JSON.stringify(hashData)));
|
||||
|
||||
const basePath =
|
||||
mode === 'create'
|
||||
? '/embed/v2/authoring/envelope/create'
|
||||
: `/embed/v2/authoring/envelope/edit/${envelopeId}`;
|
||||
|
||||
setIframeSrc(`${basePath}?token=${presignToken}#${hash}`);
|
||||
setIframeKey((prev) => prev + 1);
|
||||
|
||||
updateQueryParams({ token: inputToken, externalId, mode, envelopeId, envelopeType });
|
||||
};
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
void launchEmbed();
|
||||
},
|
||||
[
|
||||
token,
|
||||
externalId,
|
||||
mode,
|
||||
envelopeId,
|
||||
envelopeType,
|
||||
generalFeatures,
|
||||
settingsFeatures,
|
||||
actionsFeatures,
|
||||
envelopeItemsFeatures,
|
||||
recipientsFeatures,
|
||||
fieldsFeatures,
|
||||
darkModeDisabled,
|
||||
rawCss,
|
||||
cssVars,
|
||||
],
|
||||
);
|
||||
|
||||
const handleClear = () => {
|
||||
setToken('');
|
||||
setExternalId('');
|
||||
setMode('create');
|
||||
setEnvelopeId('');
|
||||
setEnvelopeType('DOCUMENT');
|
||||
setIframeSrc(null);
|
||||
setMessages([]);
|
||||
setTokenError(null);
|
||||
setDarkModeDisabled(false);
|
||||
setRawCss('');
|
||||
setCssVars((prev) => {
|
||||
const cleared: Record<string, string> = {};
|
||||
|
||||
for (const key of Object.keys(prev)) {
|
||||
cleared[key] = '';
|
||||
}
|
||||
|
||||
return cleared;
|
||||
});
|
||||
void navigate('.', { replace: true });
|
||||
};
|
||||
|
||||
const renderCheckboxGroup = <T extends Record<string, boolean>>(
|
||||
label: string,
|
||||
state: T,
|
||||
setState: React.Dispatch<React.SetStateAction<T>>,
|
||||
) => (
|
||||
<fieldset
|
||||
style={{ border: '1px solid #ccc', padding: '8px', marginBottom: '8px', borderRadius: '4px' }}
|
||||
>
|
||||
<legend style={{ fontWeight: 'bold', fontSize: '13px' }}>{label}</legend>
|
||||
{Object.entries(state).map(([key, value]) => (
|
||||
<label key={key} style={{ display: 'block', fontSize: '12px', marginBottom: '2px' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value}
|
||||
onChange={(e) => setState((prev) => ({ ...prev, [key]: e.target.checked }))}
|
||||
style={{ marginRight: '4px' }}
|
||||
/>
|
||||
{key}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', height: '100vh', fontFamily: 'monospace' }}>
|
||||
{/* Left panel: controls */}
|
||||
<div
|
||||
style={{
|
||||
width: '320px',
|
||||
padding: '12px',
|
||||
borderRight: '1px solid #ccc',
|
||||
overflowY: 'auto',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<h2 style={{ margin: '0 0 12px', fontSize: '16px' }}>Embed Test</h2>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<label style={{ display: 'block', fontSize: '12px', fontWeight: 'bold' }}>
|
||||
API or Embedded Token (Required)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
style={{ width: '100%', padding: '4px', fontSize: '12px' }}
|
||||
placeholder="api_... or presign token"
|
||||
required
|
||||
/>
|
||||
{tokenError && (
|
||||
<div style={{ color: 'red', fontSize: '11px', marginTop: '4px' }}>{tokenError}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<label style={{ display: 'block', fontSize: '12px', fontWeight: 'bold' }}>
|
||||
External ID (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={externalId}
|
||||
onChange={(e) => setExternalId(e.target.value)}
|
||||
style={{ width: '100%', padding: '4px', fontSize: '12px' }}
|
||||
placeholder="your-correlation-id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<label style={{ display: 'block', fontSize: '12px', fontWeight: 'bold' }}>Mode</label>
|
||||
<select
|
||||
value={mode}
|
||||
onChange={(e) => setMode(e.target.value as 'create' | 'edit')}
|
||||
style={{ width: '100%', padding: '4px', fontSize: '12px' }}
|
||||
>
|
||||
<option value="create">Create</option>
|
||||
<option value="edit">Edit</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{mode === 'create' && (
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<label style={{ display: 'block', fontSize: '12px', fontWeight: 'bold' }}>
|
||||
Envelope Type
|
||||
</label>
|
||||
<select
|
||||
value={envelopeType}
|
||||
onChange={(e) => setEnvelopeType(e.target.value as 'DOCUMENT' | 'TEMPLATE')}
|
||||
style={{ width: '100%', padding: '4px', fontSize: '12px' }}
|
||||
>
|
||||
<option value="DOCUMENT">Document</option>
|
||||
<option value="TEMPLATE">Template</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'edit' && (
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<label style={{ display: 'block', fontSize: '12px', fontWeight: 'bold' }}>
|
||||
Envelope ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={envelopeId}
|
||||
onChange={(e) => setEnvelopeId(e.target.value)}
|
||||
style={{ width: '100%', padding: '4px', fontSize: '12px' }}
|
||||
placeholder="envelope_..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3 style={{ fontSize: '14px', margin: '12px 0 4px' }}>Feature Flags</h3>
|
||||
|
||||
{renderCheckboxGroup('General', generalFeatures, setGeneralFeatures)}
|
||||
{renderCheckboxGroup('Settings', settingsFeatures, setSettingsFeatures)}
|
||||
{renderCheckboxGroup('Actions', actionsFeatures, setActionsFeatures)}
|
||||
{renderCheckboxGroup('Envelope Items', envelopeItemsFeatures, setEnvelopeItemsFeatures)}
|
||||
{renderCheckboxGroup('Recipients', recipientsFeatures, setRecipientsFeatures)}
|
||||
{renderCheckboxGroup('Fields', fieldsFeatures, setFieldsFeatures)}
|
||||
|
||||
<h3 style={{ fontSize: '14px', margin: '12px 0 4px' }}>CSS Theming</h3>
|
||||
|
||||
<label style={{ display: 'block', fontSize: '12px', marginBottom: '8px' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={darkModeDisabled}
|
||||
onChange={(e) => setDarkModeDisabled(e.target.checked)}
|
||||
style={{ marginRight: '4px' }}
|
||||
/>
|
||||
darkModeDisabled
|
||||
</label>
|
||||
|
||||
<fieldset
|
||||
style={{
|
||||
border: '1px solid #ccc',
|
||||
padding: '8px',
|
||||
marginBottom: '8px',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
>
|
||||
<legend style={{ fontWeight: 'bold', fontSize: '13px' }}>CSS Variables</legend>
|
||||
<div
|
||||
style={{
|
||||
maxHeight: '200px',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{Object.entries(cssVars).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
marginBottom: '2px',
|
||||
}}
|
||||
>
|
||||
<label style={{ fontSize: '11px', width: '140px', flexShrink: 0 }}>{key}</label>
|
||||
{key !== 'radius' && (
|
||||
<input
|
||||
type="color"
|
||||
value={value || '#000000'}
|
||||
onChange={(e) => setCssVars((prev) => ({ ...prev, [key]: e.target.value }))}
|
||||
style={{ width: '24px', height: '20px', padding: 0, border: 'none' }}
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => setCssVars((prev) => ({ ...prev, [key]: e.target.value }))}
|
||||
style={{ flex: 1, padding: '2px 4px', fontSize: '11px' }}
|
||||
placeholder={key === 'radius' ? '0.5rem' : '#hex or color'}
|
||||
/>
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCssVars((prev) => ({ ...prev, [key]: '' }))}
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
cursor: 'pointer',
|
||||
padding: '0 4px',
|
||||
lineHeight: '18px',
|
||||
}}
|
||||
>
|
||||
x
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset
|
||||
style={{
|
||||
border: '1px solid #ccc',
|
||||
padding: '8px',
|
||||
marginBottom: '8px',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
>
|
||||
<legend style={{ fontWeight: 'bold', fontSize: '13px' }}>Raw CSS</legend>
|
||||
<textarea
|
||||
value={rawCss}
|
||||
onChange={(e) => setRawCss(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '80px',
|
||||
padding: '4px',
|
||||
fontSize: '11px',
|
||||
fontFamily: 'monospace',
|
||||
resize: 'vertical',
|
||||
}}
|
||||
placeholder=".my-class { color: red; }"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isResolvingToken}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 'bold',
|
||||
cursor: isResolvingToken ? 'not-allowed' : 'pointer',
|
||||
opacity: isResolvingToken ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{isResolvingToken ? 'Resolving Token...' : 'Launch Embed'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
fontSize: '13px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Message log */}
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<h3 style={{ fontSize: '14px', margin: '0 0 4px' }}>
|
||||
PostMessage Log
|
||||
{messages.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMessages([])}
|
||||
style={{ marginLeft: '8px', fontSize: '10px', cursor: 'pointer' }}
|
||||
>
|
||||
clear
|
||||
</button>
|
||||
)}
|
||||
</h3>
|
||||
<div
|
||||
style={{
|
||||
height: '200px',
|
||||
overflowY: 'auto',
|
||||
border: '1px solid #ccc',
|
||||
padding: '4px',
|
||||
fontSize: '11px',
|
||||
backgroundColor: '#f9f9f9',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
}}
|
||||
>
|
||||
{messages.length === 0 && (
|
||||
<span style={{ color: '#999' }}>Waiting for messages...</span>
|
||||
)}
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i} style={{ borderBottom: '1px solid #eee', padding: '2px 0' }}>
|
||||
{msg}
|
||||
</div>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel: iframe */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
{iframeSrc ? (
|
||||
<iframe
|
||||
key={iframeKey}
|
||||
src={iframeSrc}
|
||||
style={{ flex: 1, border: 'none', width: '100%', height: '100%' }}
|
||||
title="Embedded Authoring"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#999',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Enter a token and click "Launch Embed" to start
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client
|
||||
import { OrganisationProvider } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
|
||||
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
|
||||
import { TrpcProvider } from '@documenso/trpc/react';
|
||||
import type { OrganisationSession } from '@documenso/trpc/server/organisation-router/get-organisation-session.types';
|
||||
|
||||
@@ -38,16 +39,24 @@ export const loader = async ({ request }: Route.LoaderArgs) => {
|
||||
teamId: result.teamId,
|
||||
});
|
||||
|
||||
const teamSettings = await getTeamSettings({
|
||||
userId: result.userId,
|
||||
teamId: result.teamId,
|
||||
});
|
||||
|
||||
return {
|
||||
token,
|
||||
userId: result.userId,
|
||||
teamId: result.teamId,
|
||||
organisationClaim,
|
||||
preferences: {
|
||||
aiFeaturesEnabled: teamSettings.aiFeaturesEnabled,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default function AuthoringLayout() {
|
||||
const { token, teamId, organisationClaim } = useLoaderData<typeof loader>();
|
||||
const { token, teamId, organisationClaim, preferences } = useLoaderData<typeof loader>();
|
||||
|
||||
const allowEmbedAuthoringWhiteLabel = organisationClaim.flags.embedAuthoringWhiteLabel ?? false;
|
||||
|
||||
@@ -88,9 +97,9 @@ export default function AuthoringLayout() {
|
||||
createdAt: new Date(),
|
||||
avatarImageId: null,
|
||||
organisationId: '',
|
||||
currentTeamRole: TeamMemberRole.MEMBER,
|
||||
currentTeamRole: TeamMemberRole.ADMIN,
|
||||
preferences: {
|
||||
aiFeaturesEnabled: true, // Todo: Embed
|
||||
aiFeaturesEnabled: preferences.aiFeaturesEnabled,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -114,10 +123,6 @@ export default function AuthoringLayout() {
|
||||
};
|
||||
|
||||
return (
|
||||
// Todo: Embed
|
||||
// Session provider? fuck me
|
||||
// If someone ever uses useSession it's going to break
|
||||
// We need E2E Tests
|
||||
<OrganisationProvider organisation={organisation}>
|
||||
<TeamProvider team={team}>
|
||||
<TrpcProvider
|
||||
|
||||
@@ -46,7 +46,7 @@ export const loader = async ({ request, params }: Route.LoaderArgs) => {
|
||||
}
|
||||
|
||||
// We also know that the token is valid, but we need the userId + teamId
|
||||
const result = await verifyEmbeddingPresignToken({ token, scope: `envelope:${id}` }).catch(
|
||||
const result = await verifyEmbeddingPresignToken({ token, scope: `envelopeId:${id}` }).catch(
|
||||
() => null,
|
||||
);
|
||||
|
||||
@@ -169,7 +169,7 @@ const EnvelopeEditPage = ({ embedAuthoringOptions }: EnvelopeEditPageProps) => {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
order: item.order,
|
||||
index: item.data ? files.length - 1 : undefined, // Todo: Embed why not use the ID instead?
|
||||
index: item.data ? files.length - 1 : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
-180
@@ -1,180 +0,0 @@
|
||||
# V2 Envelope Embedding System — Overview
|
||||
|
||||
## Architecture
|
||||
|
||||
The V2 embedding system replaces the V1 iframe-based document/template authoring system with a unified **"envelope"**-based model. Instead of 4 separate components (`EmbedCreateDocumentV1`, `EmbedCreateTemplateV1`, `EmbedUpdateDocumentV1`, `EmbedUpdateTemplateV1`), V2 unifies everything around an "envelope" that can contain multiple PDF items, recipients, and fields.
|
||||
|
||||
### Key Concepts
|
||||
|
||||
- **Envelope**: A container holding one or more PDF documents, recipients, and fields. Replaces the V1 single-document model.
|
||||
- **Presign Token**: Short-lived token exchanged from an API key, scoped to specific operations (e.g., `envelope:{id}`). Used for iframe authentication instead of session cookies.
|
||||
- **Feature Flags (Hash Config)**: JSON config passed via URL hash fragment to control which UI elements are visible/editable in the embedded editor. 5 categories with ~25 flags.
|
||||
- **Local Mode**: In embedded create mode, all mutations (add recipient, add field, etc.) happen locally with auto-incrementing negative IDs — no network calls until the final "create" action.
|
||||
- **EditorConfig**: Runtime config object built from feature flags that controls the editor's behavior (which steps to show, which actions to allow, which settings to render).
|
||||
|
||||
---
|
||||
|
||||
## Files Changed (~40 files)
|
||||
|
||||
### New Routes (`apps/remix/app/routes/embed+/`)
|
||||
|
||||
| File | Purpose |
|
||||
| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `v2+/authoring+/_layout.tsx` | Layout/auth wrapper. Verifies presign tokens, sets up fake team/org providers (hardcoded team ID=1, org ID='123'), injects CSS/cssVars for white-labeling, wraps children in `TrpcProvider`, `LimitsProvider` (with `bypassLimits=true`), `OrganisationProvider`, `TeamProvider`. |
|
||||
| `v2+/authoring+/envelope.create._index.tsx` | Create envelope page. Loads team settings for defaults, builds blank `TEditorEnvelope`, parses feature flags from URL hash, handles `createEmbeddingEnvelope` mutation (FormData with payload + files), posts `envelope-created` message to parent window. |
|
||||
| `v2+/authoring+/envelope.edit.$id.tsx` | Edit envelope page. Loads existing envelope by ID, verifies presign token with scope `envelope:{id}`. **Currently broken — won't compile** (see Issues section). |
|
||||
| `v2+/authoring_.completed.create.tsx` | Completion page. Marked as "not being used" via Todo comment. |
|
||||
| `v2+/multisign+/_index.tsx` | Multi-document signing view. Lists multiple documents for a recipient to sign sequentially. Handles `document-completed`, `document-rejected`, `document-error`, `document-ready`, `all-documents-completed` postMessage events. |
|
||||
| `dummy.tsx` | Internal test/dev page (606 lines). Full control panel with all feature flags, CSS theming, token exchange (api\_ -> presign token), iframe rendering, and postMessage monitoring. |
|
||||
|
||||
### New Types & Utils (`packages/lib/`)
|
||||
|
||||
| File | Purpose |
|
||||
| --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `types/envelope-editor.ts` | Zod schemas: `ZBaseEmbedAuthoringFeaturesSchema` (5 feature categories), `ZBaseEmbedAuthoringSchema`, `ZBaseEmbedAuthoringEditSchema` (adds `onlyEditFields`), `ZEditorEnvelopeSchema`. |
|
||||
| `utils/embed-config.ts` | `buildEditorConfigFromFeatures()` — maps parsed feature flags to `EnvelopeEditorConfig` with sensible defaults for embedded mode (e.g., `minimizeLeftSidebar: true`, most actions disabled). |
|
||||
| `client-only/hooks/use-editor-query.ts` | Dual-mode hook (`trpc` vs `local`). Local mode simulates mutations with auto-incrementing negative IDs, no network calls. **Currently unused** — provider still uses direct trpc mutations with `isEmbedded` checks. |
|
||||
|
||||
### Modified Providers (`packages/lib/client-only/providers/`)
|
||||
|
||||
| File | Changes |
|
||||
| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `envelope-editor-provider.tsx` | Added `EnvelopeEditorConfig` type (5 config sections + `embeded` config). Added `isEmbedded` flag, local-only mapping functions (`mapLocalRecipientsToRecipients`, `mapLocalFieldsToFields`), embedded path handling, `flushAutosave()`. When embedded, skips trpc mutations and maps locally. |
|
||||
| `envelope-render-provider.tsx` | Added `presignToken` prop, local file rendering via `pdfToImagesClientSide` when envelope ID is empty (embedded create mode), handling of `PRESIGNED_` prefixed items. |
|
||||
|
||||
### TRPC Changes (`packages/trpc/server/embedding-router/`)
|
||||
|
||||
| File | Purpose |
|
||||
| ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `_router.ts` | Added `createEmbeddingEnvelope` route registration. |
|
||||
| `create-embedding-envelope.ts` | New mutation. Accepts FormData (payload JSON + files), verifies presign token, normalizes PDFs, extracts placeholders, uploads to storage, creates envelope. Duplicated from `create-envelope.ts` with presign auth instead of session auth. |
|
||||
| `create-embedding-envelope.types.ts` | Reuses `ZCreateEnvelopePayloadSchema`, wraps in `zodFormData`. |
|
||||
| `create-embedding-presign-token.types.ts` | Added `scope` field to presign token request (e.g., `envelope:envelope_123`). |
|
||||
|
||||
### Modified Components (`apps/remix/app/components/`)
|
||||
|
||||
| File | Changes |
|
||||
| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `envelope-editor.tsx` | Reads `editorConfig` for which steps/actions to show/hide. Respects `minimizeLeftSidebar`, conditional rendering of quick actions. |
|
||||
| `envelope-editor-header.tsx` | Added embedded create/update buttons that call `flushAutosave()` then `embeded.onCreate/onUpdate`. Conditionally shows branding, attachments, settings based on config. |
|
||||
| `envelope-editor-upload-page.tsx` | When `isEmbedded`, files are stored locally as `Uint8Array` on `envelopeItems` with `PRESIGNED_` prefix IDs instead of being uploaded immediately. |
|
||||
| `envelope-editor-recipient-form.tsx` | Reads `editorConfig.recipients` to hide/show role options (approver, viewer, CC, assistant), signing order, dictate next signer. |
|
||||
| `envelope-editor-settings-dialog.tsx` | Reads `editorConfig.settings` to conditionally render each setting section. |
|
||||
| `envelope-editor-renderer-provider-wrapper.tsx` | **New.** Wrapper that passes `presignedToken` to `EnvelopeRenderProvider`. |
|
||||
| `envelope-delete-dialog.tsx` | **New.** Delete dialog for envelopes. |
|
||||
| `envelope-distribute-dialog.tsx` | Minor changes to use `useCurrentEnvelopeEditor`. |
|
||||
| `document-attachments-popover.tsx` | Changed to use string envelope IDs. |
|
||||
| `recipient-role-select.tsx` | Added `hideAssistantRole`, `hideCCerRole`, `hideViewerRole`, `hideApproverRole` props. |
|
||||
| `add-template-placeholder-recipients.tsx` | Added role hiding props passthrough. |
|
||||
| `limits/provider/client.tsx` | Added `bypassLimits` prop to skip limit fetching for embedded mode. |
|
||||
|
||||
### Feature Flag Schema
|
||||
|
||||
5 categories with ~25 flags controlling embedded editor behavior:
|
||||
|
||||
```
|
||||
general:
|
||||
allowConfigureSigningOrder # Show signing order controls
|
||||
allowPersonalizeRecipient # Allow recipient customization
|
||||
|
||||
settings:
|
||||
showGeneralSettings # General settings section
|
||||
showSigningSettings # Signing-specific settings
|
||||
showAdvancedSettings # Advanced settings section
|
||||
showNotificationSettings # Notification preferences
|
||||
showAccessSettings # Access/permission settings
|
||||
|
||||
actions:
|
||||
allowDistributeAction # Show distribute button
|
||||
allowDirectLinkAction # Show direct link button
|
||||
allowDuplicateAction # Show duplicate button
|
||||
allowDownloadAction # Show download button
|
||||
allowDeleteAction # Show delete button
|
||||
allowReturnAction # Show return/back button
|
||||
allowAttachments # Show attachments popover
|
||||
|
||||
envelopeItems:
|
||||
allowAdd # Add new PDFs
|
||||
allowDelete # Remove PDFs
|
||||
allowConfigureTitle # Edit PDF titles
|
||||
allowConfigureOrder # Reorder PDFs
|
||||
|
||||
recipients:
|
||||
showApproverRole # Show approver role option
|
||||
showViewerRole # Show viewer role option
|
||||
showCCRole # Show CC role option
|
||||
showAssistantRole # Show assistant role option
|
||||
showSigningOrder # Show signing order controls
|
||||
showDictateNextSigner # Show dictate next signer
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Issues & Missing Pieces
|
||||
|
||||
### Critical — Won't Compile
|
||||
|
||||
**`envelope.edit.$id.tsx`** has the following problems:
|
||||
|
||||
1. References `TUpdateEnvelopePayload` and `TUpdateEmbeddingDocumentPayload` — **types don't exist**.
|
||||
2. Uses `trpc.embeddingPresign.updateEmbeddingDocument.useMutation()` — **mutation doesn't exist**. There is no `update-embedding-envelope.ts` in the embedding router.
|
||||
3. Missing imports: `EnvelopeType`, `Trans`.
|
||||
4. `buildUpdateEnvelopeRequest` returns `{ payload: TUpdateEnvelopePayload, files: File[] }` but `TUpdateEnvelopePayload` is undefined.
|
||||
|
||||
**Fix required:** Create `update-embedding-envelope.ts` + types in the trpc embedding router, register in `_router.ts`, fix all imports.
|
||||
|
||||
### Explicit TODOs in Code
|
||||
|
||||
| Location | Todo |
|
||||
| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `_layout.tsx` | Session provider missing — any `useSession()` call will crash. Need E2E tests. |
|
||||
| `envelope.create._index.tsx` | Presign token scope handling for create mode. |
|
||||
| `envelope.edit.$id.tsx` | Handle `onlyEditFields` (skip to fields step). Check externalId handling on postMessage. |
|
||||
| `authoring_.completed.create.tsx` | Not being used — dead code. |
|
||||
| `envelope-editor-provider.tsx` | `setEnvelope` wrapper ("WTf"). Embedded path handling needs more thought. `authOptions: null` not mapped in local mode. `Prisma.Decimal` used client-side may have bundle/compat issues. |
|
||||
| `envelope-render-provider.tsx` | `PRESIGNED_` items with data logged but not handled. |
|
||||
| `envelope-editor-header.tsx` | Logo link and white-label not properly handled. |
|
||||
| `envelope-editor.tsx` | Delete action navigation in embedded mode unclear. |
|
||||
|
||||
### Missing Features
|
||||
|
||||
| Feature | Status |
|
||||
| --------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| `updateEmbeddingEnvelope` mutation | Not created — edit flow has no backend endpoint |
|
||||
| `use-editor-query.ts` hook integration | Created but unused — provider still manually branches on `isEmbedded` |
|
||||
| E2E tests | None exist |
|
||||
| V2 React SDK components (`@documenso/embed-react`) | V1 had wrapper components; no V2 equivalents |
|
||||
| Template support | Code references `EnvelopeType.TEMPLATE` but routes hardcode `DOCUMENT` |
|
||||
| Error route (`/embed/v2/authoring/error/not-found`) | Does not exist |
|
||||
| `onlyEditFields` | Parsed but not implemented |
|
||||
| Session provider in embed layout | Missing — `useSession()` calls will crash |
|
||||
| White-label branding | Logo/link hardcoded in header |
|
||||
| Attachments in create mode | `DocumentAttachmentsPopover` queries with empty envelope ID |
|
||||
| postMessage origin security | All calls use `'*'` as target origin |
|
||||
| Documentation | V1 docs at `apps/documentation/pages/developers/embedded-authoring.mdx` not updated for V2 |
|
||||
|
||||
### Feature Flags Not Fully Enforced
|
||||
|
||||
| Flag | Issue |
|
||||
| ----------------------------------- | --------------------------------------------------------------------------------------- |
|
||||
| `envelopeItems.allowConfigureTitle` | Upload page has title input but doesn't check this flag |
|
||||
| `envelopeItems.allowConfigureOrder` | Drag reordering doesn't check this flag |
|
||||
| `envelopeItems.allowDelete` | Delete button uses `canItemsBeModified` from envelope status, not this flag |
|
||||
| `actions.allowAttachments` | Checked in header but attachments popover won't work without envelope ID in create mode |
|
||||
|
||||
---
|
||||
|
||||
## Priority Order for Next Steps
|
||||
|
||||
1. **Fix edit flow** — Create `update-embedding-envelope` mutation + types, fix imports in `envelope.edit.$id.tsx`
|
||||
2. **Wire up `use-editor-query.ts`** — Replace manual `isEmbedded` branching in `envelope-editor-provider.tsx`
|
||||
3. **Handle session provider** — Add fake session provider or ensure all components use `useOptionalSession()`
|
||||
4. **Implement `onlyEditFields`** — Skip to fields step when flag is set
|
||||
5. **Make envelope type configurable** — Support `TEMPLATE` via hash params or presign token scope
|
||||
6. **Create error route** — `/embed/v2/authoring/error/not-found`
|
||||
7. **Enforce all feature flags** — Wire remaining flags into upload page components
|
||||
8. **Fix authOptions mapping** — Map recipient auth options in local mode
|
||||
9. **Create V2 React SDK components** — `@documenso/embed-react` wrappers for iframe + postMessage
|
||||
10. **Add E2E tests** — Cover create, edit, multi-sign flows
|
||||
11. **Update documentation** — Reflect V2 API in developer docs
|
||||
12. **PostMessage security** — Replace `'*'` with proper target origin
|
||||
@@ -0,0 +1,223 @@
|
||||
import { type Page, expect, test } from '@playwright/test';
|
||||
import { FieldType } from '@prisma/client';
|
||||
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import {
|
||||
type TEnvelopeEditorSurface,
|
||||
addEnvelopeItemPdf,
|
||||
clickAddMyselfButton,
|
||||
clickEnvelopeEditorStep,
|
||||
getEnvelopeEditorSettingsTrigger,
|
||||
getRecipientEmailInputs,
|
||||
openDocumentEnvelopeEditor,
|
||||
openEmbeddedEnvelopeEditor,
|
||||
openTemplateEnvelopeEditor,
|
||||
persistEmbeddedEnvelope,
|
||||
setRecipientEmail,
|
||||
setRecipientName,
|
||||
} from '../fixtures/envelope-editor';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
|
||||
type TFieldFlowResult = {
|
||||
externalId: string;
|
||||
recipientEmail: string;
|
||||
};
|
||||
|
||||
const TEST_FIELD_VALUES = {
|
||||
embeddedRecipient: {
|
||||
email: 'embedded-field-recipient@documenso.com',
|
||||
name: 'Embedded Field Recipient',
|
||||
},
|
||||
};
|
||||
|
||||
const openSettingsDialog = async (root: Page) => {
|
||||
await getEnvelopeEditorSettingsTrigger(root).click();
|
||||
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
|
||||
};
|
||||
|
||||
const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: string) => {
|
||||
await openSettingsDialog(surface.root);
|
||||
await surface.root.locator('input[name="externalId"]').fill(externalId);
|
||||
await surface.root.getByRole('button', { name: 'Update' }).click();
|
||||
|
||||
if (!surface.isEmbedded) {
|
||||
await expectToastTextToBeVisible(surface.root, 'Envelope updated');
|
||||
}
|
||||
};
|
||||
|
||||
const setupRecipientsForFieldPlacement = async (surface: TEnvelopeEditorSurface) => {
|
||||
if (surface.isEmbedded) {
|
||||
await expect(surface.root.getByRole('button', { name: 'Add Myself' })).toHaveCount(0);
|
||||
await setRecipientEmail(surface.root, 0, TEST_FIELD_VALUES.embeddedRecipient.email);
|
||||
await setRecipientName(surface.root, 0, TEST_FIELD_VALUES.embeddedRecipient.name);
|
||||
|
||||
return TEST_FIELD_VALUES.embeddedRecipient.email;
|
||||
}
|
||||
|
||||
await expect(surface.root.getByRole('button', { name: 'Add Myself' })).toBeVisible();
|
||||
await clickAddMyselfButton(surface.root);
|
||||
await expect(getRecipientEmailInputs(surface.root).first()).toHaveValue(surface.userEmail);
|
||||
|
||||
return surface.userEmail;
|
||||
};
|
||||
|
||||
const placeFieldOnPdf = async (
|
||||
root: Page,
|
||||
fieldName: 'Signature' | 'Text',
|
||||
position: { x: number; y: number },
|
||||
) => {
|
||||
await root.getByRole('button', { name: fieldName, exact: true }).click();
|
||||
|
||||
const canvas = root.locator('.konva-container canvas').first();
|
||||
await expect(canvas).toBeVisible();
|
||||
await canvas.click({ position });
|
||||
};
|
||||
|
||||
const runFieldFlow = async (surface: TEnvelopeEditorSurface): Promise<TFieldFlowResult> => {
|
||||
const externalId = `e2e-fields-${nanoid()}`;
|
||||
|
||||
if (surface.isEmbedded && !surface.envelopeId) {
|
||||
await addEnvelopeItemPdf(surface.root, 'embedded-fields.pdf');
|
||||
}
|
||||
|
||||
await updateExternalId(surface, externalId);
|
||||
const recipientEmail = await setupRecipientsForFieldPlacement(surface);
|
||||
|
||||
await clickEnvelopeEditorStep(surface.root, 'addFields');
|
||||
await expect(surface.root.getByText('Selected Recipient')).toBeVisible();
|
||||
await expect(surface.root.locator('.konva-container canvas').first()).toBeVisible();
|
||||
|
||||
await placeFieldOnPdf(surface.root, 'Signature', { x: 120, y: 140 });
|
||||
await expect(surface.root.getByText('1 Field')).toBeVisible();
|
||||
|
||||
await placeFieldOnPdf(surface.root, 'Text', { x: 220, y: 240 });
|
||||
await expect(surface.root.getByText('2 Fields')).toBeVisible();
|
||||
|
||||
await clickEnvelopeEditorStep(surface.root, 'upload');
|
||||
await expect(surface.root.getByRole('heading', { name: 'Recipients' })).toBeVisible();
|
||||
|
||||
await clickEnvelopeEditorStep(surface.root, 'addFields');
|
||||
await expect(surface.root.getByText('Selected Recipient')).toBeVisible();
|
||||
await expect(surface.root.getByText('2 Fields')).toBeVisible();
|
||||
|
||||
return {
|
||||
externalId,
|
||||
recipientEmail,
|
||||
};
|
||||
};
|
||||
|
||||
const getFieldMetaType = (fieldMeta: unknown) => {
|
||||
if (!isRecord(fieldMeta)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return typeof fieldMeta.type === 'string' ? fieldMeta.type : null;
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null;
|
||||
|
||||
const assertFieldsPersistedInDatabase = async ({
|
||||
surface,
|
||||
externalId,
|
||||
recipientEmail,
|
||||
}: {
|
||||
surface: TEnvelopeEditorSurface;
|
||||
externalId: string;
|
||||
recipientEmail: string;
|
||||
}) => {
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
externalId,
|
||||
userId: surface.userId,
|
||||
teamId: surface.teamId,
|
||||
type: surface.envelopeType,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
include: {
|
||||
fields: true,
|
||||
recipients: true,
|
||||
},
|
||||
});
|
||||
|
||||
const recipient = envelope.recipients.find(
|
||||
(currentRecipient) => currentRecipient.email === recipientEmail,
|
||||
);
|
||||
|
||||
expect(recipient).toBeDefined();
|
||||
|
||||
const fieldTypes = envelope.fields.map((field) => field.type).sort();
|
||||
const expectedFieldTypes = [FieldType.SIGNATURE, FieldType.TEXT].sort();
|
||||
|
||||
expect(envelope.fields).toHaveLength(2);
|
||||
expect(fieldTypes).toEqual(expectedFieldTypes);
|
||||
expect(new Set(envelope.fields.map((field) => field.envelopeItemId)).size).toBe(1);
|
||||
expect(envelope.fields.every((field) => field.recipientId === recipient?.id)).toBe(true);
|
||||
|
||||
const signatureField = envelope.fields.find((field) => field.type === FieldType.SIGNATURE);
|
||||
const textField = envelope.fields.find((field) => field.type === FieldType.TEXT);
|
||||
|
||||
expect(getFieldMetaType(signatureField?.fieldMeta)).toBe('signature');
|
||||
expect(getFieldMetaType(textField?.fieldMeta)).toBe('text');
|
||||
};
|
||||
|
||||
test.describe('Envelope Editor V2 - Fields', () => {
|
||||
test('documents/<id>: add and persist signature/text fields', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const result = await runFieldFlow(surface);
|
||||
|
||||
await assertFieldsPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('templates/<id>: add and persist signature/text fields', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
const result = await runFieldFlow(surface);
|
||||
|
||||
await assertFieldsPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('/embed/v2/authoring/envelope/create DOCUMENT: add and persist signature/text fields', async ({
|
||||
page,
|
||||
}) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'DOCUMENT',
|
||||
tokenNamePrefix: 'e2e-embed-fields',
|
||||
});
|
||||
const result = await runFieldFlow(surface);
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertFieldsPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('/embed/v2/authoring/envelope/edit/<id> TEMPLATE: add and persist signature/text fields', async ({
|
||||
page,
|
||||
}) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'TEMPLATE',
|
||||
mode: 'edit',
|
||||
tokenNamePrefix: 'e2e-embed-fields',
|
||||
});
|
||||
const result = await runFieldFlow(surface);
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertFieldsPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import { type Page, expect, test } from '@playwright/test';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
type TEnvelopeEditorSurface,
|
||||
getEnvelopeItemDragHandles,
|
||||
getEnvelopeItemDropzoneInput,
|
||||
getEnvelopeItemRemoveButtons,
|
||||
getEnvelopeItemTitleInputs,
|
||||
openDocumentEnvelopeEditor,
|
||||
openEmbeddedEnvelopeEditor,
|
||||
openTemplateEnvelopeEditor,
|
||||
} from '../fixtures/envelope-editor';
|
||||
|
||||
test.use({
|
||||
storageState: {
|
||||
cookies: [],
|
||||
origins: [],
|
||||
},
|
||||
});
|
||||
|
||||
type TestFilePayload = {
|
||||
name: string;
|
||||
mimeType: string;
|
||||
buffer: Buffer;
|
||||
};
|
||||
|
||||
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
|
||||
|
||||
const createPdfPayload = (name: string): TestFilePayload => ({
|
||||
name,
|
||||
mimeType: 'application/pdf',
|
||||
buffer: examplePdfBuffer,
|
||||
});
|
||||
|
||||
const getCurrentTitles = async (root: Page) => {
|
||||
const titleInputs = getEnvelopeItemTitleInputs(root);
|
||||
const count = await titleInputs.count();
|
||||
|
||||
return await Promise.all(
|
||||
Array.from({ length: count }, async (_, index) => await titleInputs.nth(index).inputValue()),
|
||||
);
|
||||
};
|
||||
|
||||
const uploadFiles = async (root: Page, files: TestFilePayload[]) => {
|
||||
const input = getEnvelopeItemDropzoneInput(root);
|
||||
|
||||
await input.setInputFiles(files);
|
||||
};
|
||||
|
||||
const dragEnvelopeItemByHandle = async ({
|
||||
root,
|
||||
sourceIndex,
|
||||
targetIndex,
|
||||
}: {
|
||||
root: Page;
|
||||
sourceIndex: number;
|
||||
targetIndex: number;
|
||||
}) => {
|
||||
const sourceHandle = getEnvelopeItemDragHandles(root).nth(sourceIndex);
|
||||
const targetHandle = getEnvelopeItemDragHandles(root).nth(targetIndex);
|
||||
|
||||
await expect(sourceHandle).toBeVisible();
|
||||
await expect(targetHandle).toBeVisible();
|
||||
|
||||
const sourceBox = await sourceHandle.boundingBox();
|
||||
const targetBox = await targetHandle.boundingBox();
|
||||
|
||||
if (!sourceBox || !targetBox) {
|
||||
throw new Error('Could not resolve drag handle bounding boxes');
|
||||
}
|
||||
|
||||
const sourceX = sourceBox.x + sourceBox.width / 2;
|
||||
const sourceY = sourceBox.y + sourceBox.height / 2;
|
||||
const targetX = targetBox.x + targetBox.width / 2;
|
||||
const targetY = targetBox.y + targetBox.height / 2;
|
||||
|
||||
await root.mouse.move(sourceX, sourceY);
|
||||
await root.mouse.down();
|
||||
await root.mouse.move(targetX, targetY, { steps: 20 });
|
||||
await root.mouse.up();
|
||||
};
|
||||
|
||||
const runEnvelopeItemCrudFlow = async ({
|
||||
root,
|
||||
isEmbedded,
|
||||
initialCount,
|
||||
filesToUpload,
|
||||
}: TEnvelopeEditorSurface & {
|
||||
initialCount: number;
|
||||
filesToUpload: TestFilePayload[];
|
||||
}) => {
|
||||
await expect(root.getByRole('heading', { name: 'Documents' })).toBeVisible();
|
||||
|
||||
await expect(getEnvelopeItemTitleInputs(root)).toHaveCount(initialCount);
|
||||
|
||||
await uploadFiles(root, filesToUpload);
|
||||
|
||||
const expectedCountAfterUpload = initialCount + filesToUpload.length;
|
||||
|
||||
await expect(getEnvelopeItemTitleInputs(root)).toHaveCount(expectedCountAfterUpload);
|
||||
|
||||
await getEnvelopeItemTitleInputs(root).nth(0).fill('Envelope Item A');
|
||||
await getEnvelopeItemTitleInputs(root).nth(1).fill('Envelope Item B');
|
||||
|
||||
await expect(getEnvelopeItemTitleInputs(root).nth(0)).toHaveValue('Envelope Item A');
|
||||
await expect(getEnvelopeItemTitleInputs(root).nth(1)).toHaveValue('Envelope Item B');
|
||||
|
||||
await dragEnvelopeItemByHandle({
|
||||
root,
|
||||
sourceIndex: 0,
|
||||
targetIndex: 1,
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(async () => await getCurrentTitles(root))
|
||||
.toEqual(['Envelope Item B', 'Envelope Item A']);
|
||||
|
||||
await getEnvelopeItemRemoveButtons(root).first().click();
|
||||
|
||||
if (!isEmbedded) {
|
||||
await root.getByRole('button', { name: 'Delete' }).click();
|
||||
}
|
||||
|
||||
await expect(getEnvelopeItemTitleInputs(root)).toHaveCount(expectedCountAfterUpload - 1);
|
||||
};
|
||||
|
||||
test.describe('Envelope Editor V2 - Envelope item CRUD', () => {
|
||||
test('documents/<id>: add, remove, reorder and retitle items', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
|
||||
await runEnvelopeItemCrudFlow({
|
||||
...surface,
|
||||
initialCount: 1,
|
||||
filesToUpload: [createPdfPayload('document-item-added.pdf')],
|
||||
});
|
||||
});
|
||||
|
||||
test('templates/<id>: add, remove, reorder and retitle items', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
|
||||
await runEnvelopeItemCrudFlow({
|
||||
...surface,
|
||||
initialCount: 1,
|
||||
filesToUpload: [createPdfPayload('template-item-added.pdf')],
|
||||
});
|
||||
});
|
||||
|
||||
test('/embed/v2/authoring/envelope/create DOCUMENT: add, remove, reorder and retitle items', async ({
|
||||
page,
|
||||
}) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'DOCUMENT',
|
||||
});
|
||||
|
||||
await runEnvelopeItemCrudFlow({
|
||||
...surface,
|
||||
initialCount: 0,
|
||||
filesToUpload: [
|
||||
createPdfPayload('embedded-document-item-a.pdf'),
|
||||
createPdfPayload('embedded-document-item-b.pdf'),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('/embed/v2/authoring/envelope/edit/<id> TEMPLATE: add, remove, reorder and retitle items', async ({
|
||||
page,
|
||||
}) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'TEMPLATE',
|
||||
mode: 'edit',
|
||||
tokenNamePrefix: 'e2e-embed-items',
|
||||
});
|
||||
|
||||
await runEnvelopeItemCrudFlow({
|
||||
...surface,
|
||||
initialCount: 1,
|
||||
filesToUpload: [createPdfPayload('embedded-template-item-updated.pdf')],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,279 @@
|
||||
import { type Page, expect, test } from '@playwright/test';
|
||||
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
|
||||
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import {
|
||||
type TEnvelopeEditorSurface,
|
||||
addEnvelopeItemPdf,
|
||||
assertRecipientRole,
|
||||
clickAddMyselfButton,
|
||||
clickAddSignerButton,
|
||||
clickEnvelopeEditorStep,
|
||||
getEnvelopeEditorSettingsTrigger,
|
||||
getRecipientEmailInputs,
|
||||
getRecipientNameInputs,
|
||||
getRecipientRemoveButtons,
|
||||
getSigningOrderInputs,
|
||||
openDocumentEnvelopeEditor,
|
||||
openEmbeddedEnvelopeEditor,
|
||||
openTemplateEnvelopeEditor,
|
||||
persistEmbeddedEnvelope,
|
||||
setRecipientEmail,
|
||||
setRecipientName,
|
||||
setRecipientRole,
|
||||
setSigningOrderValue,
|
||||
toggleAllowDictateSigners,
|
||||
toggleSigningOrder,
|
||||
} from '../fixtures/envelope-editor';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
|
||||
type RecipientFlowResult = {
|
||||
externalId: string;
|
||||
expectedRecipientsBySigningOrder: Array<{
|
||||
email: string;
|
||||
name: string;
|
||||
role: RecipientRole;
|
||||
signingOrder: number;
|
||||
}>;
|
||||
removedRecipientEmail: string;
|
||||
};
|
||||
|
||||
const TEST_RECIPIENT_VALUES = {
|
||||
secondRecipient: {
|
||||
email: 'recipient-two@example.com',
|
||||
name: 'Recipient Two',
|
||||
},
|
||||
thirdRecipient: {
|
||||
email: 'recipient-three@example.com',
|
||||
name: 'Recipient Three',
|
||||
},
|
||||
embeddedPrimaryRecipient: {
|
||||
email: 'embedded-primary@example.com',
|
||||
name: 'Embedded Primary',
|
||||
},
|
||||
};
|
||||
|
||||
const openSettingsDialog = async (root: Page) => {
|
||||
await getEnvelopeEditorSettingsTrigger(root).click();
|
||||
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
|
||||
};
|
||||
|
||||
const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: string) => {
|
||||
await openSettingsDialog(surface.root);
|
||||
await surface.root.locator('input[name="externalId"]').fill(externalId);
|
||||
await surface.root.getByRole('button', { name: 'Update' }).click();
|
||||
|
||||
if (!surface.isEmbedded) {
|
||||
await expectToastTextToBeVisible(surface.root, 'Envelope updated');
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToAddFieldsAndBack = async (root: Page) => {
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
await expect(root.getByText('Selected Recipient')).toBeVisible();
|
||||
|
||||
await clickEnvelopeEditorStep(root, 'upload');
|
||||
await expect(root.getByRole('heading', { name: 'Recipients' })).toBeVisible();
|
||||
};
|
||||
|
||||
const runRecipientFlow = async (surface: TEnvelopeEditorSurface): Promise<RecipientFlowResult> => {
|
||||
const externalId = `e2e-recipients-${nanoid()}`;
|
||||
|
||||
await updateExternalId(surface, externalId);
|
||||
|
||||
let primaryRecipient = TEST_RECIPIENT_VALUES.embeddedPrimaryRecipient;
|
||||
|
||||
if (surface.isEmbedded) {
|
||||
await expect(surface.root.getByRole('button', { name: 'Add Myself' })).toHaveCount(0);
|
||||
await setRecipientEmail(surface.root, 0, primaryRecipient.email);
|
||||
await setRecipientName(surface.root, 0, primaryRecipient.name);
|
||||
} else {
|
||||
await expect(surface.root.getByRole('button', { name: 'Add Myself' })).toBeVisible();
|
||||
await clickAddMyselfButton(surface.root);
|
||||
|
||||
primaryRecipient = {
|
||||
email: surface.userEmail,
|
||||
name: surface.userName,
|
||||
};
|
||||
|
||||
await expect(getRecipientEmailInputs(surface.root).nth(0)).toHaveValue(surface.userEmail);
|
||||
}
|
||||
|
||||
await clickAddSignerButton(surface.root);
|
||||
await clickAddSignerButton(surface.root);
|
||||
|
||||
await setRecipientEmail(surface.root, 1, TEST_RECIPIENT_VALUES.secondRecipient.email);
|
||||
await setRecipientName(surface.root, 1, TEST_RECIPIENT_VALUES.secondRecipient.name);
|
||||
|
||||
await setRecipientEmail(surface.root, 2, TEST_RECIPIENT_VALUES.thirdRecipient.email);
|
||||
await setRecipientName(surface.root, 2, TEST_RECIPIENT_VALUES.thirdRecipient.name);
|
||||
|
||||
await setRecipientRole(surface.root, 1, 'Needs to approve');
|
||||
await setRecipientRole(surface.root, 2, 'Receives copy');
|
||||
|
||||
await getRecipientRemoveButtons(surface.root).nth(2).click();
|
||||
await expect(getRecipientEmailInputs(surface.root)).toHaveCount(2);
|
||||
|
||||
await toggleSigningOrder(surface.root, true);
|
||||
await expect(getSigningOrderInputs(surface.root)).toHaveCount(2);
|
||||
await setSigningOrderValue(surface.root, 0, 2);
|
||||
|
||||
await toggleAllowDictateSigners(surface.root, true);
|
||||
|
||||
await navigateToAddFieldsAndBack(surface.root);
|
||||
|
||||
await expect(getRecipientEmailInputs(surface.root)).toHaveCount(2);
|
||||
await expect(getRecipientEmailInputs(surface.root).nth(0)).toHaveValue(
|
||||
TEST_RECIPIENT_VALUES.secondRecipient.email,
|
||||
);
|
||||
await expect(getRecipientEmailInputs(surface.root).nth(1)).toHaveValue(primaryRecipient.email);
|
||||
|
||||
await expect(getRecipientNameInputs(surface.root).nth(0)).toHaveValue(
|
||||
TEST_RECIPIENT_VALUES.secondRecipient.name,
|
||||
);
|
||||
await expect(getRecipientNameInputs(surface.root).nth(1)).toHaveValue(primaryRecipient.name);
|
||||
|
||||
await assertRecipientRole(surface.root, 0, 'Needs to approve');
|
||||
await assertRecipientRole(surface.root, 1, 'Needs to sign');
|
||||
|
||||
await expect(surface.root.locator('#signingOrder')).toHaveAttribute('aria-checked', 'true');
|
||||
await expect(surface.root.locator('#allowDictateNextSigner')).toHaveAttribute(
|
||||
'aria-checked',
|
||||
'true',
|
||||
);
|
||||
await expect(getSigningOrderInputs(surface.root).nth(0)).toHaveValue('1');
|
||||
await expect(getSigningOrderInputs(surface.root).nth(1)).toHaveValue('2');
|
||||
|
||||
return {
|
||||
externalId,
|
||||
removedRecipientEmail: TEST_RECIPIENT_VALUES.thirdRecipient.email,
|
||||
expectedRecipientsBySigningOrder: [
|
||||
{
|
||||
email: TEST_RECIPIENT_VALUES.secondRecipient.email,
|
||||
name: TEST_RECIPIENT_VALUES.secondRecipient.name,
|
||||
role: RecipientRole.APPROVER,
|
||||
signingOrder: 1,
|
||||
},
|
||||
{
|
||||
email: primaryRecipient.email,
|
||||
name: primaryRecipient.name,
|
||||
role: RecipientRole.SIGNER,
|
||||
signingOrder: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const assertRecipientsPersistedInDatabase = async ({
|
||||
surface,
|
||||
externalId,
|
||||
expectedRecipientsBySigningOrder,
|
||||
removedRecipientEmail,
|
||||
}: {
|
||||
surface: TEnvelopeEditorSurface;
|
||||
externalId: string;
|
||||
expectedRecipientsBySigningOrder: RecipientFlowResult['expectedRecipientsBySigningOrder'];
|
||||
removedRecipientEmail: string;
|
||||
}) => {
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
externalId,
|
||||
userId: surface.userId,
|
||||
teamId: surface.teamId,
|
||||
type: surface.envelopeType,
|
||||
},
|
||||
include: {
|
||||
documentMeta: true,
|
||||
recipients: {
|
||||
orderBy: {
|
||||
signingOrder: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
expect(envelope.recipients).toHaveLength(expectedRecipientsBySigningOrder.length);
|
||||
expect(envelope.documentMeta.signingOrder).toBe(DocumentSigningOrder.SEQUENTIAL);
|
||||
expect(envelope.documentMeta.allowDictateNextSigner).toBe(true);
|
||||
|
||||
expectedRecipientsBySigningOrder.forEach((expectedRecipient, index) => {
|
||||
const recipient = envelope.recipients[index];
|
||||
|
||||
expect(recipient.email).toBe(expectedRecipient.email);
|
||||
expect(recipient.name).toBe(expectedRecipient.name);
|
||||
expect(recipient.role).toBe(expectedRecipient.role);
|
||||
expect(recipient.signingOrder).toBe(expectedRecipient.signingOrder);
|
||||
});
|
||||
|
||||
expect(envelope.recipients.some((recipient) => recipient.email === removedRecipientEmail)).toBe(
|
||||
false,
|
||||
);
|
||||
};
|
||||
|
||||
test.describe('Envelope Editor V2 - Recipients', () => {
|
||||
test('documents/<id>: add myself, CRUD, roles, signing order and dictate signers', async ({
|
||||
page,
|
||||
}) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const result = await runRecipientFlow(surface);
|
||||
|
||||
await assertRecipientsPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('templates/<id>: add myself, CRUD, roles, signing order and dictate signers', async ({
|
||||
page,
|
||||
}) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
const result = await runRecipientFlow(surface);
|
||||
|
||||
await assertRecipientsPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('/embed/v2/authoring/envelope/create DOCUMENT: recipients settings persist after create', async ({
|
||||
page,
|
||||
}) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'DOCUMENT',
|
||||
tokenNamePrefix: 'e2e-embed-recipients',
|
||||
});
|
||||
|
||||
await addEnvelopeItemPdf(surface.root, 'embedded-document-recipients.pdf');
|
||||
|
||||
const result = await runRecipientFlow(surface);
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertRecipientsPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('/embed/v2/authoring/envelope/edit/<id> TEMPLATE: recipients settings persist after update', async ({
|
||||
page,
|
||||
}) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'TEMPLATE',
|
||||
mode: 'edit',
|
||||
tokenNamePrefix: 'e2e-embed-recipients',
|
||||
});
|
||||
|
||||
const result = await runRecipientFlow(surface);
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertRecipientsPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,359 @@
|
||||
import { type Page, expect, test } from '@playwright/test';
|
||||
import { DocumentDistributionMethod, DocumentVisibility } from '@prisma/client';
|
||||
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import {
|
||||
type TEnvelopeEditorSurface,
|
||||
getEnvelopeEditorSettingsTrigger,
|
||||
openDocumentEnvelopeEditor,
|
||||
openEmbeddedEnvelopeEditor,
|
||||
openTemplateEnvelopeEditor,
|
||||
persistEmbeddedEnvelope,
|
||||
} from '../fixtures/envelope-editor';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
|
||||
type SettingsFlowData = {
|
||||
externalId: string;
|
||||
isEmbedded: boolean;
|
||||
};
|
||||
|
||||
const TEST_SETTINGS_VALUES = {
|
||||
replyTo: 'e2e-settings@example.com',
|
||||
redirectUrl: 'https://example.com/e2e-settings-complete',
|
||||
subject: 'E2E settings subject',
|
||||
message: 'E2E settings message',
|
||||
language: 'French',
|
||||
dateFormat: 'DD/MM/YYYY',
|
||||
timezone: 'Europe/London',
|
||||
distributionMethod: 'None',
|
||||
accessAuth: 'Require account',
|
||||
actionAuth: 'Require password',
|
||||
visibility: 'Managers and above',
|
||||
};
|
||||
|
||||
const DB_EXPECTED_VALUES = {
|
||||
language: 'fr',
|
||||
dateFormat: 'dd/MM/yyyy',
|
||||
timezone: 'Europe/London',
|
||||
distributionMethod: DocumentDistributionMethod.NONE,
|
||||
visibility: DocumentVisibility.MANAGER_AND_ABOVE,
|
||||
globalAccessAuth: ['ACCOUNT'],
|
||||
globalActionAuth: ['PASSWORD'],
|
||||
emailSettings: {
|
||||
recipientSigned: false,
|
||||
recipientSigningRequest: false,
|
||||
recipientRemoved: false,
|
||||
documentPending: false,
|
||||
documentCompleted: false,
|
||||
documentDeleted: false,
|
||||
ownerDocumentCompleted: false,
|
||||
},
|
||||
};
|
||||
|
||||
const openSettingsDialog = async (root: Page) => {
|
||||
await getEnvelopeEditorSettingsTrigger(root).click();
|
||||
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
|
||||
};
|
||||
|
||||
const clickSettingsDialogHeader = async (root: Page) => {
|
||||
await root.locator('[data-testid="envelope-editor-settings-dialog-header"]').click();
|
||||
};
|
||||
|
||||
const getComboboxByLabel = (root: Page, label: string) =>
|
||||
root
|
||||
.locator(`label:has-text("${label}")`)
|
||||
.locator('xpath=..')
|
||||
.locator('[role="combobox"]')
|
||||
.first();
|
||||
|
||||
const selectMultiSelectOption = async (
|
||||
root: Page,
|
||||
dataTestId: 'documentAccessSelectValue' | 'documentActionSelectValue',
|
||||
optionLabel: string,
|
||||
) => {
|
||||
const select = root.locator(`[data-testid="${dataTestId}"]`);
|
||||
|
||||
await select.click();
|
||||
await root.locator('[cmdk-item]').filter({ hasText: optionLabel }).first().click();
|
||||
await clickSettingsDialogHeader(root);
|
||||
};
|
||||
|
||||
const runSettingsFlow = async (
|
||||
{ root }: TEnvelopeEditorSurface,
|
||||
{ externalId, isEmbedded }: SettingsFlowData,
|
||||
) => {
|
||||
await openSettingsDialog(root);
|
||||
|
||||
await getComboboxByLabel(root, 'Language').click();
|
||||
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.language }).click();
|
||||
await clickSettingsDialogHeader(root);
|
||||
|
||||
const signatureTypesCombobox = getComboboxByLabel(root, 'Allowed Signature Types');
|
||||
|
||||
await signatureTypesCombobox.click();
|
||||
await root.getByRole('option', { name: 'Upload' }).click();
|
||||
await clickSettingsDialogHeader(root);
|
||||
|
||||
await getComboboxByLabel(root, 'Date Format').click();
|
||||
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.dateFormat, exact: true }).click();
|
||||
await clickSettingsDialogHeader(root);
|
||||
|
||||
await getComboboxByLabel(root, 'Time Zone').click();
|
||||
await root.locator('[cmdk-input]').last().fill(TEST_SETTINGS_VALUES.timezone);
|
||||
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.timezone }).click();
|
||||
await clickSettingsDialogHeader(root);
|
||||
|
||||
await root.locator('input[name="externalId"]').fill(externalId);
|
||||
await root.locator('input[name="meta.redirectUrl"]').fill(TEST_SETTINGS_VALUES.redirectUrl);
|
||||
|
||||
await root.locator('[data-testid="documentDistributionMethodSelectValue"]').click();
|
||||
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.distributionMethod }).click();
|
||||
await clickSettingsDialogHeader(root);
|
||||
|
||||
await root.getByRole('button', { name: 'Email' }).click();
|
||||
await root.locator('#recipientSigned').click();
|
||||
await root.locator('#recipientSigningRequest').click();
|
||||
await root.locator('#recipientRemoved').click();
|
||||
await root.locator('#documentPending').click();
|
||||
await root.locator('#documentCompleted').click();
|
||||
await root.locator('#documentDeleted').click();
|
||||
await root.locator('#ownerDocumentCompleted').click();
|
||||
await root.locator('input[name="meta.emailReplyTo"]').fill(TEST_SETTINGS_VALUES.replyTo);
|
||||
await root.locator('input[name="meta.subject"]').fill(TEST_SETTINGS_VALUES.subject);
|
||||
await root.locator('textarea[name="meta.message"]').fill(TEST_SETTINGS_VALUES.message);
|
||||
|
||||
await root.getByRole('button', { name: 'Security' }).click();
|
||||
await selectMultiSelectOption(root, 'documentAccessSelectValue', TEST_SETTINGS_VALUES.accessAuth);
|
||||
|
||||
const actionAuthSelect = root.locator('[data-testid="documentActionSelectValue"]');
|
||||
const hasActionAuthSelect = (await actionAuthSelect.count()) > 0;
|
||||
|
||||
if (hasActionAuthSelect) {
|
||||
await selectMultiSelectOption(
|
||||
root,
|
||||
'documentActionSelectValue',
|
||||
TEST_SETTINGS_VALUES.actionAuth,
|
||||
);
|
||||
}
|
||||
|
||||
await root.locator('[data-testid="documentVisibilitySelectValue"]').click();
|
||||
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.visibility }).click();
|
||||
await clickSettingsDialogHeader(root);
|
||||
|
||||
await root.getByRole('button', { name: 'Update' }).click();
|
||||
|
||||
if (!isEmbedded) {
|
||||
await expectToastTextToBeVisible(root, 'Envelope updated');
|
||||
}
|
||||
|
||||
await openSettingsDialog(root);
|
||||
|
||||
await expect(root.locator('input[name="externalId"]')).toHaveValue(externalId);
|
||||
await expect(root.locator('input[name="meta.redirectUrl"]')).toHaveValue(
|
||||
TEST_SETTINGS_VALUES.redirectUrl,
|
||||
);
|
||||
await expect(getComboboxByLabel(root, 'Language')).toContainText(TEST_SETTINGS_VALUES.language);
|
||||
await expect(getComboboxByLabel(root, 'Allowed Signature Types')).not.toContainText('Upload');
|
||||
await expect(getComboboxByLabel(root, 'Date Format')).toContainText(
|
||||
TEST_SETTINGS_VALUES.dateFormat,
|
||||
);
|
||||
await expect(getComboboxByLabel(root, 'Time Zone')).toContainText(TEST_SETTINGS_VALUES.timezone);
|
||||
await expect(root.locator('[data-testid="documentDistributionMethodSelectValue"]')).toContainText(
|
||||
TEST_SETTINGS_VALUES.distributionMethod,
|
||||
);
|
||||
|
||||
await root.getByRole('button', { name: 'Email' }).click();
|
||||
await expect(root.locator('#recipientSigned')).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(root.locator('#recipientSigningRequest')).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(root.locator('#recipientRemoved')).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(root.locator('#documentPending')).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(root.locator('#documentCompleted')).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(root.locator('#documentDeleted')).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(root.locator('#ownerDocumentCompleted')).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(root.locator('input[name="meta.emailReplyTo"]')).toHaveValue(
|
||||
TEST_SETTINGS_VALUES.replyTo,
|
||||
);
|
||||
await expect(root.locator('input[name="meta.subject"]')).toHaveValue(
|
||||
TEST_SETTINGS_VALUES.subject,
|
||||
);
|
||||
await expect(root.locator('textarea[name="meta.message"]')).toHaveValue(
|
||||
TEST_SETTINGS_VALUES.message,
|
||||
);
|
||||
|
||||
await root.getByRole('button', { name: 'Security' }).click();
|
||||
await expect(root.locator('[data-testid="documentAccessSelectValue"]')).toContainText(
|
||||
TEST_SETTINGS_VALUES.accessAuth,
|
||||
);
|
||||
|
||||
if (hasActionAuthSelect) {
|
||||
await expect(root.locator('[data-testid="documentActionSelectValue"]')).toContainText(
|
||||
TEST_SETTINGS_VALUES.actionAuth,
|
||||
);
|
||||
}
|
||||
|
||||
await expect(root.locator('[data-testid="documentVisibilitySelectValue"]')).toContainText(
|
||||
TEST_SETTINGS_VALUES.visibility,
|
||||
);
|
||||
|
||||
await root.getByRole('button', { name: 'Update' }).click();
|
||||
|
||||
if (!isEmbedded) {
|
||||
await expectToastTextToBeVisible(root, 'Envelope updated');
|
||||
}
|
||||
|
||||
return {
|
||||
hasActionAuthSelect,
|
||||
};
|
||||
};
|
||||
|
||||
const assertEnvelopeSettingsPersistedInDatabase = async ({
|
||||
externalId,
|
||||
surface,
|
||||
hasActionAuthSelect,
|
||||
}: {
|
||||
externalId: string;
|
||||
surface: TEnvelopeEditorSurface;
|
||||
hasActionAuthSelect: boolean;
|
||||
}) => {
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
externalId,
|
||||
userId: surface.userId,
|
||||
teamId: surface.teamId,
|
||||
type: surface.envelopeType,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
documentMeta: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(envelope.externalId).toBe(externalId);
|
||||
expect(envelope.visibility).toBe(DB_EXPECTED_VALUES.visibility);
|
||||
expect(envelope.documentMeta.language).toBe(DB_EXPECTED_VALUES.language);
|
||||
expect(envelope.documentMeta.dateFormat).toBe(DB_EXPECTED_VALUES.dateFormat);
|
||||
expect(envelope.documentMeta.timezone).toBe(DB_EXPECTED_VALUES.timezone);
|
||||
expect(envelope.documentMeta.distributionMethod).toBe(DB_EXPECTED_VALUES.distributionMethod);
|
||||
expect(envelope.documentMeta.redirectUrl).toBe(TEST_SETTINGS_VALUES.redirectUrl);
|
||||
expect(envelope.documentMeta.emailReplyTo).toBe(TEST_SETTINGS_VALUES.replyTo);
|
||||
expect(envelope.documentMeta.subject).toBe(TEST_SETTINGS_VALUES.subject);
|
||||
expect(envelope.documentMeta.message).toBe(TEST_SETTINGS_VALUES.message);
|
||||
expect(envelope.documentMeta.drawSignatureEnabled).toBe(true);
|
||||
expect(envelope.documentMeta.typedSignatureEnabled).toBe(true);
|
||||
expect(envelope.documentMeta.uploadSignatureEnabled).toBe(false);
|
||||
expect(envelope.documentMeta.emailSettings).toMatchObject(DB_EXPECTED_VALUES.emailSettings);
|
||||
|
||||
const authOptions = parseAuthOptions(envelope.authOptions);
|
||||
|
||||
expect(authOptions.globalAccessAuth ?? []).toEqual(DB_EXPECTED_VALUES.globalAccessAuth);
|
||||
|
||||
if (hasActionAuthSelect) {
|
||||
expect(authOptions.globalActionAuth ?? []).toEqual(DB_EXPECTED_VALUES.globalActionAuth);
|
||||
}
|
||||
};
|
||||
|
||||
const parseAuthOptions = (
|
||||
authOptions: unknown,
|
||||
): { globalAccessAuth: string[]; globalActionAuth: string[] } => {
|
||||
if (!isRecord(authOptions)) {
|
||||
return {
|
||||
globalAccessAuth: [],
|
||||
globalActionAuth: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
globalAccessAuth: Array.isArray(authOptions.globalAccessAuth)
|
||||
? authOptions.globalAccessAuth.filter((entry): entry is string => typeof entry === 'string')
|
||||
: [],
|
||||
globalActionAuth: Array.isArray(authOptions.globalActionAuth)
|
||||
? authOptions.globalActionAuth.filter((entry): entry is string => typeof entry === 'string')
|
||||
: [],
|
||||
};
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null;
|
||||
|
||||
test.describe('Envelope Editor V2 - Envelope settings dialog', () => {
|
||||
test('documents/<id>: update and persist settings', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const externalId = `e2e-settings-${nanoid()}`;
|
||||
|
||||
const { hasActionAuthSelect } = await runSettingsFlow(surface, {
|
||||
externalId,
|
||||
isEmbedded: false,
|
||||
});
|
||||
|
||||
await assertEnvelopeSettingsPersistedInDatabase({
|
||||
externalId,
|
||||
surface,
|
||||
hasActionAuthSelect,
|
||||
});
|
||||
});
|
||||
|
||||
test('templates/<id>: update and persist settings', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
const externalId = `e2e-settings-${nanoid()}`;
|
||||
|
||||
const { hasActionAuthSelect } = await runSettingsFlow(surface, {
|
||||
externalId,
|
||||
isEmbedded: false,
|
||||
});
|
||||
|
||||
await assertEnvelopeSettingsPersistedInDatabase({
|
||||
externalId,
|
||||
surface,
|
||||
hasActionAuthSelect,
|
||||
});
|
||||
});
|
||||
|
||||
test('/embed/v2/authoring/envelope/create DOCUMENT: update and persist settings', async ({
|
||||
page,
|
||||
}) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'DOCUMENT',
|
||||
tokenNamePrefix: 'e2e-embed-settings',
|
||||
});
|
||||
const externalId = `e2e-settings-${nanoid()}`;
|
||||
|
||||
const { hasActionAuthSelect } = await runSettingsFlow(surface, {
|
||||
externalId,
|
||||
isEmbedded: true,
|
||||
});
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertEnvelopeSettingsPersistedInDatabase({
|
||||
externalId,
|
||||
surface,
|
||||
hasActionAuthSelect,
|
||||
});
|
||||
});
|
||||
|
||||
test('/embed/v2/authoring/envelope/edit/<id> TEMPLATE: update and persist settings', async ({
|
||||
page,
|
||||
}) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'TEMPLATE',
|
||||
mode: 'edit',
|
||||
tokenNamePrefix: 'e2e-embed-settings',
|
||||
});
|
||||
const externalId = `e2e-settings-${nanoid()}`;
|
||||
|
||||
const { hasActionAuthSelect } = await runSettingsFlow(surface, {
|
||||
externalId,
|
||||
isEmbedded: true,
|
||||
});
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertEnvelopeSettingsPersistedInDatabase({
|
||||
externalId,
|
||||
surface,
|
||||
hasActionAuthSelect,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,429 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect } from '@playwright/test';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { DEFAULT_EMBEDDED_EDITOR_CONFIG } from '@documenso/lib/types/envelope-editor';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from './authentication';
|
||||
|
||||
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
|
||||
|
||||
export type TEnvelopeEditorSurface = {
|
||||
root: Page;
|
||||
isEmbedded: boolean;
|
||||
envelopeId?: string;
|
||||
envelopeType: TEnvelopeEditorType;
|
||||
userId: number;
|
||||
userEmail: string;
|
||||
userName: string;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export type TEnvelopeEditorType = 'DOCUMENT' | 'TEMPLATE';
|
||||
|
||||
type TEmbeddedHashCommonOptions = {
|
||||
externalId?: string;
|
||||
features?: typeof DEFAULT_EMBEDDED_EDITOR_CONFIG;
|
||||
css?: string;
|
||||
cssVars?: Record<string, string>;
|
||||
darkModeDisabled?: boolean;
|
||||
};
|
||||
|
||||
const encodeEmbeddedOptions = (options: Record<string, unknown>) => {
|
||||
const encodedPayload = encodeURIComponent(JSON.stringify(options));
|
||||
|
||||
if (typeof btoa === 'function') {
|
||||
return btoa(encodedPayload);
|
||||
}
|
||||
|
||||
return Buffer.from(encodedPayload, 'utf8').toString('base64');
|
||||
};
|
||||
|
||||
export const createEmbeddedEnvelopeCreateHash = ({
|
||||
envelopeType,
|
||||
externalId,
|
||||
features = DEFAULT_EMBEDDED_EDITOR_CONFIG,
|
||||
css,
|
||||
cssVars,
|
||||
darkModeDisabled,
|
||||
}: { envelopeType: TEnvelopeEditorType } & TEmbeddedHashCommonOptions) => {
|
||||
return encodeEmbeddedOptions({
|
||||
externalId,
|
||||
type: envelopeType,
|
||||
features,
|
||||
css,
|
||||
cssVars,
|
||||
darkModeDisabled,
|
||||
});
|
||||
};
|
||||
|
||||
export const createEmbeddedEnvelopeEditHash = ({
|
||||
externalId,
|
||||
features = DEFAULT_EMBEDDED_EDITOR_CONFIG,
|
||||
css,
|
||||
cssVars,
|
||||
darkModeDisabled,
|
||||
}: TEmbeddedHashCommonOptions) => {
|
||||
return encodeEmbeddedOptions({
|
||||
externalId,
|
||||
features,
|
||||
css,
|
||||
cssVars,
|
||||
darkModeDisabled,
|
||||
});
|
||||
};
|
||||
|
||||
export const openDocumentEnvelopeEditor = async (page: Page): Promise<TEnvelopeEditorSurface> => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const document = await seedBlankDocument(user, team.id, {
|
||||
internalVersion: 2,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents/${document.id}/edit?step=uploadAndRecipients`,
|
||||
});
|
||||
|
||||
return {
|
||||
root: page,
|
||||
isEmbedded: false,
|
||||
envelopeId: document.id,
|
||||
envelopeType: 'DOCUMENT',
|
||||
userId: user.id,
|
||||
userEmail: user.email,
|
||||
userName: user.name ?? '',
|
||||
teamId: team.id,
|
||||
};
|
||||
};
|
||||
|
||||
export const openTemplateEnvelopeEditor = async (page: Page): Promise<TEnvelopeEditorSurface> => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const template = await seedBlankTemplate(user, team.id, {
|
||||
createTemplateOptions: {
|
||||
title: `E2E Template ${Date.now()}`,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
internalVersion: 2,
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/templates/${template.id}/edit?step=uploadAndRecipients`,
|
||||
});
|
||||
|
||||
return {
|
||||
root: page,
|
||||
isEmbedded: false,
|
||||
envelopeId: template.id,
|
||||
envelopeType: 'TEMPLATE',
|
||||
userId: user.id,
|
||||
userEmail: user.email,
|
||||
userName: user.name ?? '',
|
||||
teamId: team.id,
|
||||
};
|
||||
};
|
||||
|
||||
type OpenEmbeddedEnvelopeEditorOptions = {
|
||||
envelopeType: TEnvelopeEditorType;
|
||||
mode?: 'create' | 'edit';
|
||||
tokenNamePrefix?: string;
|
||||
externalId?: string;
|
||||
features?: typeof DEFAULT_EMBEDDED_EDITOR_CONFIG;
|
||||
css?: string;
|
||||
cssVars?: Record<string, string>;
|
||||
darkModeDisabled?: boolean;
|
||||
};
|
||||
|
||||
export const openEmbeddedEnvelopeEditor = async (
|
||||
page: Page,
|
||||
{
|
||||
envelopeType,
|
||||
mode = 'create',
|
||||
tokenNamePrefix = 'e2e-embed',
|
||||
externalId,
|
||||
features,
|
||||
css,
|
||||
cssVars,
|
||||
darkModeDisabled,
|
||||
}: OpenEmbeddedEnvelopeEditorOptions,
|
||||
): Promise<TEnvelopeEditorSurface> => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const envelopeToEdit =
|
||||
mode === 'edit'
|
||||
? envelopeType === 'DOCUMENT'
|
||||
? await seedBlankDocument(user, team.id, {
|
||||
internalVersion: 2,
|
||||
})
|
||||
: await seedBlankTemplate(user, team.id, {
|
||||
createTemplateOptions: {
|
||||
title: `E2E Template ${Date.now()}`,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
internalVersion: 2,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: `${tokenNamePrefix}-${envelopeType.toLowerCase()}`,
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const embeddedToken = await resolveEmbeddingToken(
|
||||
page,
|
||||
token,
|
||||
envelopeToEdit ? `envelopeId:${envelopeToEdit.id}` : undefined,
|
||||
);
|
||||
|
||||
if (envelopeToEdit) {
|
||||
const hash = createEmbeddedEnvelopeEditHash({
|
||||
externalId,
|
||||
features: features ?? DEFAULT_EMBEDDED_EDITOR_CONFIG,
|
||||
css,
|
||||
cssVars,
|
||||
darkModeDisabled,
|
||||
});
|
||||
|
||||
await page.goto(
|
||||
`/embed/v2/authoring/envelope/edit/${envelopeToEdit.id}?token=${encodeURIComponent(embeddedToken)}#${hash}`,
|
||||
);
|
||||
} else {
|
||||
const hash = createEmbeddedEnvelopeCreateHash({
|
||||
envelopeType,
|
||||
externalId,
|
||||
features,
|
||||
css,
|
||||
cssVars,
|
||||
darkModeDisabled,
|
||||
});
|
||||
|
||||
await page.goto(
|
||||
`/embed/v2/authoring/envelope/create?token=${encodeURIComponent(embeddedToken)}#${hash}`,
|
||||
);
|
||||
}
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
|
||||
|
||||
return {
|
||||
root: page,
|
||||
isEmbedded: true,
|
||||
envelopeId: envelopeToEdit?.id,
|
||||
envelopeType,
|
||||
userId: user.id,
|
||||
userEmail: user.email,
|
||||
userName: user.name ?? '',
|
||||
teamId: team.id,
|
||||
};
|
||||
};
|
||||
|
||||
export const getEnvelopeEditorSettingsTrigger = (root: Page) =>
|
||||
root.locator('button[title="Settings"]');
|
||||
|
||||
export const getEnvelopeItemTitleInputs = (root: Page) =>
|
||||
root.locator('[data-testid^="envelope-item-title-input-"]');
|
||||
|
||||
export const getEnvelopeItemDragHandles = (root: Page) =>
|
||||
root.locator('[data-testid^="envelope-item-drag-handle-"]');
|
||||
|
||||
export const getEnvelopeItemRemoveButtons = (root: Page) =>
|
||||
root.locator('[data-testid^="envelope-item-remove-button-"]');
|
||||
|
||||
export const getEnvelopeItemDropzoneInput = (root: Page) =>
|
||||
root.locator('[data-testid="envelope-item-dropzone"] input[type="file"]');
|
||||
|
||||
export const addEnvelopeItemPdf = async (root: Page, fileName = 'embedded-envelope-item.pdf') => {
|
||||
await getEnvelopeItemDropzoneInput(root).setInputFiles({
|
||||
name: fileName,
|
||||
mimeType: 'application/pdf',
|
||||
buffer: examplePdfBuffer,
|
||||
});
|
||||
};
|
||||
|
||||
export const getRecipientEmailInputs = (root: Page) =>
|
||||
root.locator('[data-testid="signer-email-input"]');
|
||||
|
||||
export const getRecipientNameInputs = (root: Page) =>
|
||||
root.locator('input[placeholder^="Recipient "]');
|
||||
|
||||
export const getRecipientRows = (root: Page) =>
|
||||
root.locator('[data-testid="signer-email-input"]').locator('xpath=ancestor::fieldset[1]');
|
||||
|
||||
export const getRecipientRemoveButtons = (root: Page) =>
|
||||
root.locator('[data-testid="remove-signer-button"]');
|
||||
|
||||
export const getSigningOrderInputs = (root: Page) =>
|
||||
root.locator('[data-testid="signing-order-input"]');
|
||||
|
||||
export const clickEnvelopeEditorStep = async (
|
||||
root: Page,
|
||||
stepId: 'upload' | 'addFields' | 'preview',
|
||||
) => {
|
||||
await root.waitForTimeout(200);
|
||||
await root.locator(`[data-testid="envelope-editor-step-${stepId}"]`).first().click();
|
||||
};
|
||||
|
||||
export const clickAddMyselfButton = async (root: Page) => {
|
||||
await root.getByRole('button', { name: 'Add Myself' }).click();
|
||||
};
|
||||
|
||||
export const clickAddSignerButton = async (root: Page) => {
|
||||
await root.getByRole('button', { name: 'Add Signer' }).click();
|
||||
};
|
||||
|
||||
export const setRecipientEmail = async (root: Page, index: number, email: string) => {
|
||||
await getRecipientEmailInputs(root).nth(index).fill(email);
|
||||
};
|
||||
|
||||
export const setRecipientName = async (root: Page, index: number, name: string) => {
|
||||
await getRecipientNameInputs(root).nth(index).fill(name);
|
||||
};
|
||||
|
||||
export const setRecipientRole = async (
|
||||
root: Page,
|
||||
index: number,
|
||||
roleLabel:
|
||||
| 'Needs to sign'
|
||||
| 'Needs to approve'
|
||||
| 'Needs to view'
|
||||
| 'Receives copy'
|
||||
| 'Can prepare',
|
||||
) => {
|
||||
const row = getRecipientRows(root).nth(index);
|
||||
|
||||
await row.locator('button[role="combobox"]').first().click();
|
||||
await root.getByRole('option', { name: roleLabel }).click();
|
||||
};
|
||||
|
||||
export const assertRecipientRole = async (
|
||||
root: Page,
|
||||
index: number,
|
||||
roleLabel:
|
||||
| 'Needs to sign'
|
||||
| 'Needs to approve'
|
||||
| 'Needs to view'
|
||||
| 'Receives copy'
|
||||
| 'Can prepare',
|
||||
) => {
|
||||
const row = getRecipientRows(root).nth(index);
|
||||
const roleValueByLabel: Record<typeof roleLabel, string> = {
|
||||
'Needs to sign': 'SIGNER',
|
||||
'Needs to approve': 'APPROVER',
|
||||
'Needs to view': 'VIEWER',
|
||||
'Receives copy': 'CC',
|
||||
'Can prepare': 'ASSISTANT',
|
||||
};
|
||||
|
||||
await expect(row.locator('button[role="combobox"]').first()).toHaveAttribute(
|
||||
'title',
|
||||
roleValueByLabel[roleLabel],
|
||||
);
|
||||
};
|
||||
|
||||
export const toggleSigningOrder = async (root: Page, enabled: boolean) => {
|
||||
const checkbox = root.locator('#signingOrder');
|
||||
const currentState = await checkbox.getAttribute('aria-checked');
|
||||
const isEnabled = currentState === 'true';
|
||||
|
||||
if (isEnabled !== enabled) {
|
||||
await checkbox.click();
|
||||
}
|
||||
};
|
||||
|
||||
export const toggleAllowDictateSigners = async (root: Page, enabled: boolean) => {
|
||||
const checkbox = root.locator('#allowDictateNextSigner');
|
||||
const currentState = await checkbox.getAttribute('aria-checked');
|
||||
const isEnabled = currentState === 'true';
|
||||
|
||||
if (isEnabled !== enabled) {
|
||||
await checkbox.click();
|
||||
}
|
||||
};
|
||||
|
||||
export const setSigningOrderValue = async (root: Page, index: number, value: number) => {
|
||||
const input = getSigningOrderInputs(root).nth(index);
|
||||
await input.fill(value.toString());
|
||||
await input.blur();
|
||||
};
|
||||
|
||||
export const persistEmbeddedEnvelope = async (surface: TEnvelopeEditorSurface) => {
|
||||
if (!surface.isEmbedded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isUpdateFlow =
|
||||
(await surface.root.getByRole('button', { name: 'Update Document' }).count()) > 0 ||
|
||||
(await surface.root.getByRole('button', { name: 'Update Template' }).count()) > 0;
|
||||
|
||||
const actionButtonName = isUpdateFlow
|
||||
? surface.envelopeType === 'DOCUMENT'
|
||||
? 'Update Document'
|
||||
: 'Update Template'
|
||||
: surface.envelopeType === 'DOCUMENT'
|
||||
? 'Create Document'
|
||||
: 'Create Template';
|
||||
|
||||
await surface.root.getByRole('button', { name: actionButtonName }).click();
|
||||
|
||||
const completionHeading = isUpdateFlow
|
||||
? surface.envelopeType === 'DOCUMENT'
|
||||
? 'Document Updated'
|
||||
: 'Template Updated'
|
||||
: surface.envelopeType === 'DOCUMENT'
|
||||
? 'Document Created'
|
||||
: 'Template Created';
|
||||
|
||||
await expect(surface.root.getByRole('heading', { name: completionHeading })).toBeVisible();
|
||||
};
|
||||
|
||||
const resolveEmbeddingToken = async (
|
||||
page: Page,
|
||||
inputToken: string,
|
||||
scope?: string,
|
||||
): Promise<string> => {
|
||||
if (!inputToken.startsWith('api_')) {
|
||||
return inputToken;
|
||||
}
|
||||
|
||||
const response = await page
|
||||
.context()
|
||||
.request.post(`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2/embedding/create-presign-token`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${inputToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: scope ? { scope } : {},
|
||||
});
|
||||
|
||||
if (!response.ok()) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Failed to exchange API token (${response.status()}): ${text}`);
|
||||
}
|
||||
|
||||
const data: unknown = await response.json();
|
||||
|
||||
if (typeof data !== 'object' || data === null || !('token' in data)) {
|
||||
throw new Error(`Unexpected response shape: ${JSON.stringify(data)}`);
|
||||
}
|
||||
|
||||
const token = data.token;
|
||||
|
||||
if (typeof token !== 'string' || token.length === 0) {
|
||||
throw new Error(`Unexpected response shape: ${JSON.stringify(data)}`);
|
||||
}
|
||||
|
||||
return token;
|
||||
};
|
||||
@@ -1,9 +1,17 @@
|
||||
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import { EnvelopeType, Prisma, ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import {
|
||||
DEFAULT_EDITOR_CONFIG,
|
||||
type EnvelopeEditorConfig,
|
||||
type TEditorEnvelope,
|
||||
} from '@documenso/lib/types/envelope-editor';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TSetEnvelopeFieldsResponse } from '@documenso/trpc/server/envelope-router/set-envelope-fields.types';
|
||||
import type { TSetEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/set-envelope-recipients.types';
|
||||
import type { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types';
|
||||
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
|
||||
@@ -11,7 +19,6 @@ import { AVAILABLE_RECIPIENT_COLORS } from '@documenso/ui/lib/recipient-colors';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import type { TDocumentEmailSettings } from '../../types/document-email';
|
||||
import type { TEnvelope } from '../../types/envelope';
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '../../utils/teams';
|
||||
import { useEditorFields } from '../hooks/use-editor-fields';
|
||||
import type { TLocalField } from '../hooks/use-editor-fields';
|
||||
@@ -38,14 +45,20 @@ export const useDebounceFunction = <Args extends unknown[]>(
|
||||
);
|
||||
};
|
||||
|
||||
export type EnvelopeEditorStep = 'upload' | 'addFields' | 'preview';
|
||||
|
||||
type UpdateEnvelopePayload = Pick<TUpdateEnvelopeRequest, 'data' | 'meta'>;
|
||||
|
||||
type EnvelopeEditorProviderValue = {
|
||||
envelope: TEnvelope;
|
||||
editorConfig: EnvelopeEditorConfig;
|
||||
|
||||
envelope: TEditorEnvelope;
|
||||
|
||||
isEmbedded: boolean;
|
||||
isDocument: boolean;
|
||||
isTemplate: boolean;
|
||||
setLocalEnvelope: (localEnvelope: Partial<TEnvelope>) => void;
|
||||
|
||||
setLocalEnvelope: (localEnvelope: Partial<TEditorEnvelope>) => void;
|
||||
updateEnvelope: (envelopeUpdates: UpdateEnvelopePayload) => void;
|
||||
updateEnvelopeAsync: (envelopeUpdates: UpdateEnvelopePayload) => Promise<void>;
|
||||
setRecipientsDebounced: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => void;
|
||||
@@ -57,7 +70,7 @@ type EnvelopeEditorProviderValue = {
|
||||
editorRecipients: ReturnType<typeof useEditorRecipients>;
|
||||
|
||||
isAutosaving: boolean;
|
||||
flushAutosave: () => Promise<void>;
|
||||
flushAutosave: () => Promise<TEditorEnvelope>;
|
||||
autosaveError: boolean;
|
||||
|
||||
relativePath: {
|
||||
@@ -68,12 +81,14 @@ type EnvelopeEditorProviderValue = {
|
||||
templateRootPath: string;
|
||||
};
|
||||
|
||||
navigateToStep: (step: EnvelopeEditorStep) => Promise<void>;
|
||||
syncEnvelope: () => Promise<void>;
|
||||
};
|
||||
|
||||
interface EnvelopeEditorProviderProps {
|
||||
children: React.ReactNode;
|
||||
initialEnvelope: TEnvelope;
|
||||
editorConfig?: EnvelopeEditorConfig;
|
||||
initialEnvelope: TEditorEnvelope;
|
||||
}
|
||||
|
||||
const EnvelopeEditorContext = createContext<EnvelopeEditorProviderValue | null>(null);
|
||||
@@ -90,14 +105,29 @@ export const useCurrentEnvelopeEditor = () => {
|
||||
|
||||
export const EnvelopeEditorProvider = ({
|
||||
children,
|
||||
editorConfig = DEFAULT_EDITOR_CONFIG,
|
||||
initialEnvelope,
|
||||
}: EnvelopeEditorProviderProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [envelope, setEnvelope] = useState(initialEnvelope);
|
||||
const [_searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [envelope, _setEnvelope] = useState(initialEnvelope);
|
||||
const [autosaveError, setAutosaveError] = useState<boolean>(false);
|
||||
|
||||
const envelopeRef = useRef(initialEnvelope);
|
||||
|
||||
const setEnvelope: typeof _setEnvelope = (action) => {
|
||||
_setEnvelope((prev) => {
|
||||
const next = typeof action === 'function' ? action(prev) : action;
|
||||
envelopeRef.current = next;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const isEmbedded = editorConfig.embeded !== undefined;
|
||||
|
||||
const editorFields = useEditorFields({
|
||||
envelope,
|
||||
handleFieldsUpdate: (fields) => setFieldsDebounced(fields),
|
||||
@@ -107,61 +137,35 @@ export const EnvelopeEditorProvider = ({
|
||||
envelope,
|
||||
});
|
||||
|
||||
const envelopeUpdateMutationQuery = trpc.envelope.update.useMutation({
|
||||
onSuccess: (response, input) => {
|
||||
setEnvelope({
|
||||
...envelope,
|
||||
...response,
|
||||
documentMeta: {
|
||||
...envelope.documentMeta,
|
||||
...input.meta,
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
emailSettings: (input.meta?.emailSettings ||
|
||||
null) as unknown as TDocumentEmailSettings | null,
|
||||
},
|
||||
});
|
||||
const setRecipientsMutation = trpc.envelope.recipient.set.useMutation();
|
||||
const setFieldsMutation = trpc.envelope.field.set.useMutation();
|
||||
const updateEnvelopeMutation = trpc.envelope.update.useMutation();
|
||||
|
||||
setAutosaveError(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error(err);
|
||||
/**
|
||||
* Handles debouncing the recipients updates to the server.
|
||||
*
|
||||
* Will set the local envelope recipients and fields after the update is complete.
|
||||
*/
|
||||
const {
|
||||
triggerSave: setRecipientsDebounced,
|
||||
flush: flushSetRecipients,
|
||||
isPending: isRecipientsMutationPending,
|
||||
} = useEnvelopeAutosave(async (localRecipients: TSetEnvelopeRecipientsRequest['recipients']) => {
|
||||
try {
|
||||
let recipients: TEditorEnvelope['recipients'] = [];
|
||||
|
||||
setAutosaveError(true);
|
||||
if (!isEmbedded) {
|
||||
const response = await setRecipientsMutation.mutateAsync({
|
||||
envelopeId: envelope.id,
|
||||
envelopeType: envelope.type,
|
||||
recipients: localRecipients,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Save failed`,
|
||||
description: t`We encountered an error while attempting to save your changes. Your changes cannot be saved at this time.`,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
},
|
||||
});
|
||||
recipients = response.data;
|
||||
} else {
|
||||
recipients = mapLocalRecipientsToRecipients({ envelope, localRecipients });
|
||||
}
|
||||
|
||||
const envelopeFieldSetMutationQuery = trpc.envelope.field.set.useMutation({
|
||||
onSuccess: ({ data: fields }) => {
|
||||
setEnvelope((prev) => ({
|
||||
...prev,
|
||||
fields,
|
||||
}));
|
||||
|
||||
setAutosaveError(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error(err);
|
||||
|
||||
setAutosaveError(true);
|
||||
|
||||
toast({
|
||||
title: t`Save failed`,
|
||||
description: t`We encountered an error while attempting to save your changes. Your changes cannot be saved at this time.`,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const envelopeRecipientSetMutationQuery = trpc.envelope.recipient.set.useMutation({
|
||||
onSuccess: ({ data: recipients }) => {
|
||||
setEnvelope((prev) => ({
|
||||
...prev,
|
||||
recipients,
|
||||
@@ -178,8 +182,7 @@ export const EnvelopeEditorProvider = ({
|
||||
);
|
||||
|
||||
setAutosaveError(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
setAutosaveError(true);
|
||||
@@ -190,58 +193,137 @@ export const EnvelopeEditorProvider = ({
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
triggerSave: setRecipientsDebounced,
|
||||
flush: setRecipientsAsync,
|
||||
isPending: isRecipientsMutationPending,
|
||||
} = useEnvelopeAutosave(async (recipients: TSetEnvelopeRecipientsRequest['recipients']) => {
|
||||
await envelopeRecipientSetMutationQuery.mutateAsync({
|
||||
envelopeId: envelope.id,
|
||||
envelopeType: envelope.type,
|
||||
recipients,
|
||||
});
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
const setRecipientsAsync = async (
|
||||
localRecipients: TSetEnvelopeRecipientsRequest['recipients'],
|
||||
) => {
|
||||
setRecipientsDebounced(localRecipients);
|
||||
await flushSetRecipients();
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles debouncing the fields updates to the server.
|
||||
*
|
||||
* Will set the local envelope fields after the update is complete.
|
||||
*/
|
||||
const {
|
||||
triggerSave: setFieldsDebounced,
|
||||
flush: setFieldsAsync,
|
||||
flush: flushSetFields,
|
||||
isPending: isFieldsMutationPending,
|
||||
} = useEnvelopeAutosave(async (localFields: TLocalField[]) => {
|
||||
const envelopeFields = await envelopeFieldSetMutationQuery.mutateAsync({
|
||||
envelopeId: envelope.id,
|
||||
envelopeType: envelope.type,
|
||||
fields: localFields,
|
||||
});
|
||||
try {
|
||||
let fields: TSetEnvelopeFieldsResponse['data'] = [];
|
||||
|
||||
// Insert the IDs into the local fields.
|
||||
envelopeFields.data.forEach((field) => {
|
||||
const localField = localFields.find((localField) => localField.formId === field.formId);
|
||||
if (!isEmbedded) {
|
||||
const response = await setFieldsMutation.mutateAsync({
|
||||
envelopeId: envelope.id,
|
||||
envelopeType: envelope.type,
|
||||
fields: localFields,
|
||||
});
|
||||
|
||||
if (localField && !localField.id) {
|
||||
localField.id = field.id;
|
||||
|
||||
editorFields.setFieldId(localField.formId, field.id);
|
||||
fields = response.data;
|
||||
} else {
|
||||
fields = mapLocalFieldsToFields({ envelope, localFields });
|
||||
}
|
||||
});
|
||||
|
||||
setEnvelope((prev) => ({
|
||||
...prev,
|
||||
fields,
|
||||
}));
|
||||
|
||||
setAutosaveError(false);
|
||||
|
||||
// Insert the IDs into the local fields.
|
||||
fields.forEach((field) => {
|
||||
const localField = localFields.find((localField) => localField.formId === field.formId);
|
||||
|
||||
if (localField && !localField.id) {
|
||||
localField.id = field.id;
|
||||
|
||||
editorFields.setFieldId(localField.formId, field.id);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
setAutosaveError(true);
|
||||
|
||||
toast({
|
||||
title: t`Save failed`,
|
||||
description: t`We encountered an error while attempting to save your changes. Your changes cannot be saved at this time.`,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
const setFieldsAsync = async (localFields: TLocalField[]) => {
|
||||
setFieldsDebounced(localFields);
|
||||
await flushSetFields();
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles debouncing the envelope updates to the server.
|
||||
*
|
||||
* Will set the local envelope after the update is complete.
|
||||
*/
|
||||
const {
|
||||
triggerSave: setEnvelopeDebounced,
|
||||
flush: setEnvelopeAsync,
|
||||
triggerSave: updateEnvelopeDebounced,
|
||||
flush: flushUpdateEnvelope,
|
||||
isPending: isEnvelopeMutationPending,
|
||||
} = useEnvelopeAutosave(async (envelopeUpdates: UpdateEnvelopePayload) => {
|
||||
await envelopeUpdateMutationQuery.mutateAsync({
|
||||
envelopeId: envelope.id,
|
||||
data: envelopeUpdates.data,
|
||||
meta: envelopeUpdates.meta,
|
||||
});
|
||||
} = useEnvelopeAutosave(async ({ data, meta }: UpdateEnvelopePayload) => {
|
||||
try {
|
||||
const response = !isEmbedded
|
||||
? await updateEnvelopeMutation.mutateAsync({
|
||||
envelopeId: envelope.id,
|
||||
data,
|
||||
meta,
|
||||
})
|
||||
: {};
|
||||
|
||||
setEnvelope((prev) => ({
|
||||
...prev,
|
||||
...data,
|
||||
authOptions: {
|
||||
globalAccessAuth: data?.globalAccessAuth || [],
|
||||
globalActionAuth: data?.globalActionAuth || [],
|
||||
},
|
||||
...response,
|
||||
documentMeta: {
|
||||
...prev.documentMeta,
|
||||
...meta,
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
emailSettings: (meta?.emailSettings || null) as unknown as TDocumentEmailSettings | null,
|
||||
},
|
||||
}));
|
||||
|
||||
setAutosaveError(false);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
setAutosaveError(true);
|
||||
|
||||
toast({
|
||||
title: t`Save failed`,
|
||||
description: t`We encountered an error while attempting to save your changes. Your changes cannot be saved at this time.`,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
const updateEnvelopeAsync = async (envelopeUpdates: UpdateEnvelopePayload) => {
|
||||
updateEnvelopeDebounced(envelopeUpdates);
|
||||
await flushUpdateEnvelope();
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the local envelope and debounces the update to the server.
|
||||
*
|
||||
* Use this when you want to update the local envelope immediately while debouncing
|
||||
* the actual update to the server.
|
||||
*/
|
||||
const updateEnvelope = (envelopeUpdates: UpdateEnvelopePayload) => {
|
||||
setEnvelope((prev) => ({
|
||||
@@ -253,14 +335,7 @@ export const EnvelopeEditorProvider = ({
|
||||
},
|
||||
}));
|
||||
|
||||
setEnvelopeDebounced(envelopeUpdates);
|
||||
};
|
||||
|
||||
const updateEnvelopeAsync = async (envelopeUpdates: UpdateEnvelopePayload) => {
|
||||
await envelopeUpdateMutationQuery.mutateAsync({
|
||||
envelopeId: envelope.id,
|
||||
...envelopeUpdates,
|
||||
});
|
||||
updateEnvelopeDebounced(envelopeUpdates);
|
||||
};
|
||||
|
||||
const getRecipientColorKey = useCallback(
|
||||
@@ -276,12 +351,13 @@ export const EnvelopeEditorProvider = ({
|
||||
[envelope.recipients],
|
||||
);
|
||||
|
||||
const { refetch: reloadEnvelope, isLoading: isReloadingEnvelope } = trpc.envelope.get.useQuery(
|
||||
const { refetch: reloadEnvelope } = trpc.envelope.get.useQuery(
|
||||
{
|
||||
envelopeId: envelope.id,
|
||||
},
|
||||
{
|
||||
initialData: envelope,
|
||||
enabled: !isEmbedded,
|
||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -293,6 +369,11 @@ export const EnvelopeEditorProvider = ({
|
||||
const syncEnvelope = async () => {
|
||||
await flushAutosave();
|
||||
|
||||
// Bypass syncing for embedded mode.
|
||||
if (isEmbedded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchedEnvelopeData = await reloadEnvelope();
|
||||
|
||||
if (fetchedEnvelopeData.data) {
|
||||
@@ -302,55 +383,89 @@ export const EnvelopeEditorProvider = ({
|
||||
recipients: fetchedEnvelopeData.data.recipients,
|
||||
documentMeta: fetchedEnvelopeData.data.documentMeta,
|
||||
});
|
||||
|
||||
editorFields.resetForm(fetchedEnvelopeData.data.fields);
|
||||
}
|
||||
};
|
||||
|
||||
const setLocalEnvelope = (localEnvelope: Partial<TEnvelope>) => {
|
||||
const setLocalEnvelope = (localEnvelope: Partial<TEditorEnvelope>) => {
|
||||
setEnvelope((prev) => ({ ...prev, ...localEnvelope }));
|
||||
};
|
||||
|
||||
const isAutosaving = useMemo(() => {
|
||||
return (
|
||||
envelopeFieldSetMutationQuery.isPending ||
|
||||
envelopeRecipientSetMutationQuery.isPending ||
|
||||
envelopeUpdateMutationQuery.isPending ||
|
||||
isFieldsMutationPending ||
|
||||
isRecipientsMutationPending ||
|
||||
isEnvelopeMutationPending
|
||||
);
|
||||
}, [
|
||||
envelopeFieldSetMutationQuery.isPending,
|
||||
envelopeRecipientSetMutationQuery.isPending,
|
||||
envelopeUpdateMutationQuery.isPending,
|
||||
isFieldsMutationPending,
|
||||
isRecipientsMutationPending,
|
||||
isEnvelopeMutationPending,
|
||||
]);
|
||||
return isFieldsMutationPending || isRecipientsMutationPending || isEnvelopeMutationPending;
|
||||
}, [isFieldsMutationPending, isRecipientsMutationPending, isEnvelopeMutationPending]);
|
||||
|
||||
const relativePath = useMemo(() => {
|
||||
const documentRootPath = formatDocumentsPath(envelope.team.url);
|
||||
const templateRootPath = formatTemplatesPath(envelope.team.url);
|
||||
let documentRootPath = formatDocumentsPath(envelope.team.url);
|
||||
let templateRootPath = formatTemplatesPath(envelope.team.url);
|
||||
|
||||
const basePath = envelope.type === EnvelopeType.DOCUMENT ? documentRootPath : templateRootPath;
|
||||
let envelopePath = `${basePath}/${envelope.id}`;
|
||||
let editorPath = `${basePath}/${envelope.id}/edit`;
|
||||
|
||||
if (editorConfig.embeded) {
|
||||
let embeddedEditorPath =
|
||||
editorConfig.embeded.mode === 'edit'
|
||||
? `/embed/v2/authoring/envelope/edit/${envelope.id}`
|
||||
: `/embed/v2/authoring/envelope/create`;
|
||||
|
||||
embeddedEditorPath += `?token=${editorConfig.embeded.presignToken}`;
|
||||
|
||||
// Todo: Embeds - This should be thought about more.
|
||||
envelopePath = embeddedEditorPath;
|
||||
editorPath = embeddedEditorPath;
|
||||
documentRootPath = embeddedEditorPath;
|
||||
templateRootPath = embeddedEditorPath;
|
||||
}
|
||||
|
||||
return {
|
||||
basePath,
|
||||
envelopePath: `${basePath}/${envelope.id}`,
|
||||
editorPath: `${basePath}/${envelope.id}/edit`,
|
||||
envelopePath,
|
||||
editorPath,
|
||||
documentRootPath,
|
||||
templateRootPath,
|
||||
};
|
||||
}, [envelope.type, envelope.id]);
|
||||
|
||||
const flushAutosave = async (): Promise<void> => {
|
||||
await Promise.all([setFieldsAsync(), setRecipientsAsync(), setEnvelopeAsync()]);
|
||||
const navigateToStep = async (step: EnvelopeEditorStep) => {
|
||||
setSearchParams((prev) => {
|
||||
const newParams = new URLSearchParams(prev);
|
||||
|
||||
if (step === 'upload') {
|
||||
newParams.delete('step');
|
||||
} else {
|
||||
newParams.set('step', step);
|
||||
}
|
||||
|
||||
return newParams;
|
||||
});
|
||||
|
||||
await flushAutosave();
|
||||
|
||||
resetForms();
|
||||
};
|
||||
|
||||
const resetForms = () => {
|
||||
editorRecipients.resetForm({
|
||||
recipients: envelopeRef.current.recipients,
|
||||
documentMeta: envelopeRef.current.documentMeta,
|
||||
});
|
||||
|
||||
editorFields.resetForm(envelopeRef.current.fields);
|
||||
};
|
||||
|
||||
const flushAutosave = async (): Promise<TEditorEnvelope> => {
|
||||
await Promise.all([flushSetFields(), flushSetRecipients(), flushUpdateEnvelope()]);
|
||||
return envelopeRef.current;
|
||||
};
|
||||
|
||||
return (
|
||||
<EnvelopeEditorContext.Provider
|
||||
value={{
|
||||
editorConfig,
|
||||
envelope,
|
||||
isEmbedded,
|
||||
isDocument: envelope.type === EnvelopeType.DOCUMENT,
|
||||
isTemplate: envelope.type === EnvelopeType.TEMPLATE,
|
||||
setLocalEnvelope,
|
||||
@@ -366,9 +481,107 @@ export const EnvelopeEditorProvider = ({
|
||||
isAutosaving,
|
||||
relativePath,
|
||||
syncEnvelope,
|
||||
navigateToStep,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</EnvelopeEditorContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
type MapLocalRecipientsToRecipientsOptions = {
|
||||
envelope: TEditorEnvelope;
|
||||
localRecipients: TSetEnvelopeRecipientsRequest['recipients'];
|
||||
};
|
||||
|
||||
const mapLocalRecipientsToRecipients = ({
|
||||
envelope,
|
||||
localRecipients,
|
||||
}: MapLocalRecipientsToRecipientsOptions): TEditorEnvelope['recipients'] => {
|
||||
let smallestRecipientId = localRecipients.reduce((min, recipient) => {
|
||||
if (recipient.id && recipient.id < min) {
|
||||
return recipient.id;
|
||||
}
|
||||
|
||||
return min;
|
||||
}, -1);
|
||||
|
||||
return localRecipients.map((recipient) => {
|
||||
const foundRecipient = envelope.recipients.find((recipient) => recipient.id === recipient.id);
|
||||
|
||||
let recipientId = recipient.id;
|
||||
|
||||
if (recipientId === undefined) {
|
||||
recipientId = smallestRecipientId;
|
||||
smallestRecipientId--;
|
||||
}
|
||||
|
||||
return {
|
||||
id: recipientId,
|
||||
envelopeId: envelope.id,
|
||||
email: recipient.email,
|
||||
name: recipient.name,
|
||||
token: foundRecipient?.token || '',
|
||||
documentDeletedAt: foundRecipient?.documentDeletedAt || null,
|
||||
expired: foundRecipient?.expired || null,
|
||||
signedAt: foundRecipient?.signedAt || null,
|
||||
authOptions:
|
||||
recipient.actionAuth.length > 0
|
||||
? { actionAuth: recipient.actionAuth, accessAuth: [] }
|
||||
: null,
|
||||
signingOrder: recipient.signingOrder ?? null,
|
||||
rejectionReason: foundRecipient?.rejectionReason || null,
|
||||
role: recipient.role,
|
||||
readStatus: foundRecipient?.readStatus || ReadStatus.NOT_OPENED,
|
||||
signingStatus: foundRecipient?.signingStatus || SigningStatus.NOT_SIGNED,
|
||||
sendStatus: foundRecipient?.sendStatus || SendStatus.NOT_SENT,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
type MapLocalFieldsToFieldsOptions = {
|
||||
localFields: TLocalField[];
|
||||
envelope: TEditorEnvelope;
|
||||
};
|
||||
|
||||
const mapLocalFieldsToFields = ({
|
||||
envelope,
|
||||
localFields,
|
||||
}: MapLocalFieldsToFieldsOptions): TSetEnvelopeFieldsResponse['data'] => {
|
||||
let smallestFieldId = localFields.reduce((min, field) => {
|
||||
if (field.id && field.id < min) {
|
||||
return field.id;
|
||||
}
|
||||
|
||||
return min;
|
||||
}, -1);
|
||||
|
||||
return localFields.map((field) => {
|
||||
const foundField = envelope.fields.find((envelopeField) => envelopeField.id === field.id);
|
||||
|
||||
let fieldId = field.id;
|
||||
|
||||
if (fieldId === undefined) {
|
||||
fieldId = smallestFieldId;
|
||||
smallestFieldId--;
|
||||
}
|
||||
|
||||
return {
|
||||
...field,
|
||||
formId: field.formId,
|
||||
id: fieldId,
|
||||
envelopeId: envelope.id,
|
||||
envelopeItemId: field.envelopeItemId,
|
||||
type: field.type,
|
||||
recipientId: field.recipientId,
|
||||
positionX: new Prisma.Decimal(field.positionX),
|
||||
positionY: new Prisma.Decimal(field.positionY),
|
||||
width: new Prisma.Decimal(field.width),
|
||||
height: new Prisma.Decimal(field.height),
|
||||
secondaryId: foundField?.secondaryId || '',
|
||||
inserted: foundField?.inserted || false,
|
||||
customText: foundField?.customText || '',
|
||||
fieldMeta: field.fieldMeta || null,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
@@ -11,6 +11,8 @@ import React from 'react';
|
||||
|
||||
import type { Field, Recipient } from '@prisma/client';
|
||||
|
||||
import { PDF_IMAGE_RENDER_SCALE } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { PRESIGNED_ENVELOPE_ITEM_ID_PREFIX } from '@documenso/lib/utils/embed-config';
|
||||
import type { TGetEnvelopeItemsMetaResponse } from '@documenso/remix/server/api/files/files.types';
|
||||
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
|
||||
import { AVAILABLE_RECIPIENT_COLORS } from '@documenso/ui/lib/recipient-colors';
|
||||
@@ -48,7 +50,13 @@ type EnvelopeRenderOverrideSettings = {
|
||||
showRecipientSigningStatus?: boolean;
|
||||
};
|
||||
|
||||
type EnvelopeRenderItem = TEnvelope['envelopeItems'][number];
|
||||
type EnvelopeRenderItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
order: number;
|
||||
envelopeId: string;
|
||||
data?: Uint8Array | null;
|
||||
};
|
||||
|
||||
type EnvelopeRenderProviderValue = {
|
||||
version: DocumentDataVersion;
|
||||
@@ -76,7 +84,12 @@ interface EnvelopeRenderProviderProps {
|
||||
*/
|
||||
version: DocumentDataVersion;
|
||||
|
||||
envelope: Pick<TEnvelope, 'id' | 'envelopeItems' | 'status' | 'type'>;
|
||||
envelope: Pick<TEnvelope, 'id' | 'status' | 'type'>;
|
||||
|
||||
/**
|
||||
* The envelope items to render.
|
||||
*/
|
||||
envelopeItems: EnvelopeRenderItem[];
|
||||
|
||||
/**
|
||||
* Optional fields which are passed down to renderers for custom rendering needs.
|
||||
@@ -100,6 +113,13 @@ interface EnvelopeRenderProviderProps {
|
||||
*/
|
||||
token: string | undefined;
|
||||
|
||||
/**
|
||||
* The presign token to access the envelope.
|
||||
*
|
||||
* If not provided, it will be assumed that the current user can access the document.
|
||||
*/
|
||||
presignToken?: string | undefined;
|
||||
|
||||
/**
|
||||
* Custom override settings for generic page renderers.
|
||||
*/
|
||||
@@ -124,8 +144,10 @@ export const useCurrentEnvelopeRender = () => {
|
||||
export const EnvelopeRenderProvider = ({
|
||||
children,
|
||||
envelope,
|
||||
envelopeItems: envelopeItemsFromProps,
|
||||
fields,
|
||||
token,
|
||||
presignToken,
|
||||
recipients = [],
|
||||
version,
|
||||
overrideSettings,
|
||||
@@ -144,12 +166,12 @@ export const EnvelopeRenderProvider = ({
|
||||
const fetchStartedAtRef = useRef<number>(0);
|
||||
|
||||
const envelopeItems = useMemo(
|
||||
() => envelope.envelopeItems.sort((a, b) => a.order - b.order),
|
||||
[envelope.envelopeItems],
|
||||
() => envelopeItemsFromProps.sort((a, b) => a.order - b.order),
|
||||
[envelopeItemsFromProps],
|
||||
);
|
||||
|
||||
const [currentItem, setCurrentItem] = useState<EnvelopeRenderItem | null>(
|
||||
envelope.envelopeItems[0] ?? null,
|
||||
envelopeItems[0] ?? null,
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -157,10 +179,23 @@ export const EnvelopeRenderProvider = ({
|
||||
*/
|
||||
useEffect(() => {
|
||||
void fetchEnvelopeRenderData();
|
||||
}, [envelope.id, envelope.envelopeItems.length, token, version]);
|
||||
}, [envelope.id, envelopeItems.length, token, version]);
|
||||
|
||||
const fetchEnvelopeRenderData = async () => {
|
||||
if (!envelope.id || envelope.envelopeItems.length === 0) {
|
||||
if (envelopeItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Render certain envelope items locally, such as embedded.
|
||||
// No envelope ID means it's in embedded create mode.
|
||||
if (
|
||||
!envelope.id ||
|
||||
envelopeItems.some(
|
||||
(item) => item.id.startsWith(PRESIGNED_ENVELOPE_ITEM_ID_PREFIX) && item.data,
|
||||
)
|
||||
) {
|
||||
await handleLocalFileFetch();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -175,6 +210,7 @@ export const EnvelopeRenderProvider = ({
|
||||
const metaUrl = getEnvelopeItemMetaUrl({
|
||||
envelopeId: envelope.id,
|
||||
token,
|
||||
presignToken,
|
||||
});
|
||||
|
||||
const response = await fetch(metaUrl);
|
||||
@@ -201,6 +237,7 @@ export const EnvelopeRenderProvider = ({
|
||||
documentDataId: item.documentDataId,
|
||||
pageIndex,
|
||||
token,
|
||||
presignToken,
|
||||
version,
|
||||
});
|
||||
|
||||
@@ -228,8 +265,49 @@ export const EnvelopeRenderProvider = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleLocalFileFetch = async () => {
|
||||
setEnvelopeItemsMetaLoadingState('loading');
|
||||
|
||||
try {
|
||||
// Build a map of envelope items by ID
|
||||
const metaMap: Record<string, BasePageRenderData[]> = {};
|
||||
|
||||
// Dynamically import "pdfToImagesClientSide" function to prevent bundling.
|
||||
const { pdfToImagesClientSide } = await import(
|
||||
'@documenso/lib/server-only/ai/pdf-to-images.client'
|
||||
);
|
||||
|
||||
for (const item of envelopeItems) {
|
||||
if (item.data) {
|
||||
// Clone the buffer so PDF.js can transfer it to its worker without detaching the one in state
|
||||
const pdfBytes = new Uint8Array(structuredClone(item.data));
|
||||
|
||||
const pdfImages = await pdfToImagesClientSide(pdfBytes, {
|
||||
scale: PDF_IMAGE_RENDER_SCALE,
|
||||
});
|
||||
|
||||
metaMap[item.id] = pdfImages.map((image) => ({
|
||||
envelopeItemId: item.id,
|
||||
documentDataId: item.id,
|
||||
pageIndex: image.pageIndex,
|
||||
pageNumber: image.pageIndex + 1,
|
||||
pageWidth: image.width,
|
||||
pageHeight: image.height,
|
||||
imageUrl: image.image,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
setEnvelopeItemsMeta(metaMap);
|
||||
setEnvelopeItemsMetaLoadingState('loaded');
|
||||
} catch (error) {
|
||||
console.error('Failed to load envelope data:', error);
|
||||
setEnvelopeItemsMetaLoadingState('error');
|
||||
}
|
||||
};
|
||||
|
||||
const setCurrentEnvelopeItem = (envelopeItemId: string) => {
|
||||
const foundItem = envelope.envelopeItems.find((item) => item.id === envelopeItemId);
|
||||
const foundItem = envelopeItems.find((item) => item.id === envelopeItemId);
|
||||
|
||||
setCurrentItem(foundItem ?? null);
|
||||
};
|
||||
|
||||
@@ -147,6 +147,14 @@ export const DEFAULT_EDITOR_CONFIG: EnvelopeEditorConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The default configuration for the embedded editor. This is merged with whatever is provided
|
||||
* by the embedded hash.
|
||||
*
|
||||
* This is duplicated in the embedded repo playground
|
||||
*
|
||||
* /playground/src/components/embedddings/envelope-feature.ts
|
||||
*/
|
||||
export const DEFAULT_EMBEDDED_EDITOR_CONFIG = {
|
||||
general: {
|
||||
allowConfigureEnvelopeTitle: true,
|
||||
@@ -179,7 +187,7 @@ export const DEFAULT_EMBEDDED_EDITOR_CONFIG = {
|
||||
allowDelete: true,
|
||||
},
|
||||
recipients: {
|
||||
allowAIDetection: true,
|
||||
allowAIDetection: false,
|
||||
allowConfigureSigningOrder: true,
|
||||
allowConfigureDictateNextSigner: true,
|
||||
allowApproverRole: true,
|
||||
@@ -188,7 +196,7 @@ export const DEFAULT_EMBEDDED_EDITOR_CONFIG = {
|
||||
allowAssistantRole: true,
|
||||
},
|
||||
fields: {
|
||||
allowAIDetection: true,
|
||||
allowAIDetection: false,
|
||||
},
|
||||
} as const satisfies EnvelopeEditorConfig;
|
||||
|
||||
@@ -258,6 +266,7 @@ export const ZEditorEnvelopeSchema = EnvelopeSchema.pick({
|
||||
order: true,
|
||||
})
|
||||
.extend({
|
||||
// Only used for embedded.
|
||||
data: z.instanceof(Uint8Array).optional(),
|
||||
})
|
||||
.array(),
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { RecipientSchema } from '@documenso/prisma/generated/zod/modelSchema/RecipientSchema';
|
||||
@@ -114,10 +113,5 @@ export const ZEnvelopeRecipientManySchema = ZRecipientManySchema.omit({
|
||||
|
||||
export const ZRecipientEmailSchema = z.union([
|
||||
z.literal(''),
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.email({ message: msg`Invalid email`.id })
|
||||
.max(254),
|
||||
z.string().trim().toLowerCase().email({ message: 'Invalid email' }).max(254),
|
||||
]);
|
||||
|
||||
@@ -25,7 +25,7 @@ export const ZCreateEmbeddingPresignTokenRequestSchema = z.object({
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Resource restriction. V1 embeds only support documentId:1, templateId:2. V2 embeds only support envelope:envelope_123',
|
||||
'Resource restriction. V1 embeds only support documentId:1, templateId:2. V2 embeds only support envelopeId:envelope_123',
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ export const RecipientRoleSelect = forwardRef<HTMLButtonElement, RecipientRoleSe
|
||||
ref,
|
||||
) => (
|
||||
<Select {...props}>
|
||||
<SelectTrigger ref={ref} className="w-[50px] bg-background p-2">
|
||||
<SelectTrigger ref={ref} className="w-[50px] bg-background p-2" title={props.value}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */}
|
||||
{ROLE_ICONS[props.value as RecipientRole]}
|
||||
</SelectTrigger>
|
||||
|
||||
Reference in New Issue
Block a user