Compare commits
40 Commits
7fa86fe297
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1772c3ee36 | |||
| f55b902a01 | |||
| 50db4e39be | |||
| 2802813c76 | |||
| 29d40f1cca | |||
| d67f32eae2 | |||
| a33233443b | |||
| 68a3608aee | |||
| 378dd605b9 | |||
| 211ae6c9e9 | |||
| f931885a95 | |||
| 4ade408001 | |||
| 3d0e3c6e8e | |||
| 936d8d90b3 | |||
| c6b08d8594 | |||
| 575634e326 | |||
| c66eda4aae | |||
| ef52b35f79 | |||
| 95a647034a | |||
| 34dba0b6ff | |||
| fccd97e124 | |||
| 3dbbcefddf | |||
| 2aea3c4de0 | |||
| ff44ffbc03 | |||
| 441842d2bd | |||
| ca0b83579f | |||
| 6c0d1da91e | |||
| 805982f3e8 | |||
| e2f5e570cf | |||
| 9fd9613076 | |||
| 0977c16e33 | |||
| 88d5a636c3 | |||
| 1e6292b1d9 | |||
| d65866156d | |||
| fe8915162f | |||
| 37a2634aca | |||
| eff7d90f43 | |||
| db5524f8ce | |||
| 3d539b20ad | |||
| 48626b9169 |
@ -336,7 +336,7 @@ export const EnvelopeDistributeDialog = ({
|
|||||||
<Trans>Message</Trans>{' '}
|
<Trans>Message</Trans>{' '}
|
||||||
<span className="text-muted-foreground">(Optional)</span>
|
<span className="text-muted-foreground">(Optional)</span>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger type="button">
|
||||||
<InfoIcon className="mx-2 h-4 w-4" />
|
<InfoIcon className="mx-2 h-4 w-4" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="text-muted-foreground p-4">
|
<TooltipContent className="text-muted-foreground p-4">
|
||||||
|
|||||||
@ -176,7 +176,7 @@ export const EnvelopeDownloadDialog = ({
|
|||||||
{!isDownloadingState[generateDownloadKey(item.id, 'original')] && (
|
{!isDownloadingState[generateDownloadKey(item.id, 'original')] && (
|
||||||
<DownloadIcon className="mr-2 h-4 w-4" />
|
<DownloadIcon className="mr-2 h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
<Trans>Original</Trans>
|
<Trans context="Original document (adjective)">Original</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{envelopeStatus === DocumentStatus.COMPLETED && (
|
{envelopeStatus === DocumentStatus.COMPLETED && (
|
||||||
@ -190,7 +190,7 @@ export const EnvelopeDownloadDialog = ({
|
|||||||
{!isDownloadingState[generateDownloadKey(item.id, 'signed')] && (
|
{!isDownloadingState[generateDownloadKey(item.id, 'signed')] && (
|
||||||
<DownloadIcon className="mr-2 h-4 w-4" />
|
<DownloadIcon className="mr-2 h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
<Trans>Signed</Trans>
|
<Trans context="Signed document (adjective)">Signed</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Plural, Trans } from '@lingui/react/macro';
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
import { startRegistration } from '@simplewebauthn/browser';
|
import { startRegistration } from '@simplewebauthn/browser';
|
||||||
import { KeyRoundIcon } from 'lucide-react';
|
import { KeyRoundIcon } from 'lucide-react';
|
||||||
@ -209,7 +209,11 @@ export const PasskeyCreateDialog = ({ trigger, onSuccess, ...props }: PasskeyCre
|
|||||||
))
|
))
|
||||||
.with('TOO_MANY_PASSKEYS', () => (
|
.with('TOO_MANY_PASSKEYS', () => (
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<Trans>You cannot have more than {MAXIMUM_PASSKEYS} passkeys.</Trans>
|
<Plural
|
||||||
|
value={MAXIMUM_PASSKEYS}
|
||||||
|
one="You cannot have more than # passkey."
|
||||||
|
other="You cannot have more than # passkeys."
|
||||||
|
/>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
))
|
))
|
||||||
.with('InvalidStateError', () => (
|
.with('InvalidStateError', () => (
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { createCallable } from 'react-call';
|
import { createCallable } from 'react-call';
|
||||||
@ -28,49 +27,71 @@ import {
|
|||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
|
||||||
const createNumberFieldSchema = (fieldMeta: TNumberFieldMeta) => {
|
|
||||||
let schema = z.coerce.number({
|
|
||||||
invalid_type_error: msg`Please enter a valid number`.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { numberFormat, minValue, maxValue } = fieldMeta;
|
|
||||||
|
|
||||||
if (typeof minValue === 'number') {
|
|
||||||
schema = schema.min(minValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof maxValue === 'number') {
|
|
||||||
schema = schema.max(maxValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (numberFormat) {
|
|
||||||
const foundRegex = numberFormatValues.find((item) => item.value === numberFormat)?.regex;
|
|
||||||
|
|
||||||
if (!foundRegex) {
|
|
||||||
return schema;
|
|
||||||
}
|
|
||||||
|
|
||||||
return schema.refine(
|
|
||||||
(value) => {
|
|
||||||
return foundRegex.test(value.toString());
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: msg`Number needs to be formatted as ${numberFormat}`.id,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return schema;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SignFieldNumberDialogProps = {
|
export type SignFieldNumberDialogProps = {
|
||||||
fieldMeta: TNumberFieldMeta;
|
fieldMeta: TNumberFieldMeta;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SignFieldNumberDialog = createCallable<SignFieldNumberDialogProps, number | null>(
|
export const SignFieldNumberDialog = createCallable<SignFieldNumberDialogProps, string | null>(
|
||||||
({ call, fieldMeta }) => {
|
({ call, fieldMeta }) => {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
// Needs to be inside dialog for translation purposes.
|
||||||
|
const createNumberFieldSchema = (fieldMeta: TNumberFieldMeta) => {
|
||||||
|
const { numberFormat, minValue, maxValue } = fieldMeta;
|
||||||
|
|
||||||
|
if (numberFormat) {
|
||||||
|
const foundRegex = numberFormatValues.find((item) => item.value === numberFormat)?.regex;
|
||||||
|
|
||||||
|
if (foundRegex) {
|
||||||
|
return z.string().refine(
|
||||||
|
(value) => {
|
||||||
|
return foundRegex.test(value.toString());
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: t`Number needs to be formatted as ${numberFormat}`,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not gong to work with min/max numbers + number format
|
||||||
|
// Since currently doesn't work in V1 going to ignore for now.
|
||||||
|
return z.string().superRefine((value, ctx) => {
|
||||||
|
const isValidNumber = /^[0-9,.]+$/.test(value.toString());
|
||||||
|
|
||||||
|
if (!isValidNumber) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: t`Please enter a valid number`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof minValue === 'number' && parseFloat(value) < minValue) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.too_small,
|
||||||
|
minimum: minValue,
|
||||||
|
inclusive: true,
|
||||||
|
type: 'number',
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof maxValue === 'number' && parseFloat(value) > maxValue) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.too_big,
|
||||||
|
maximum: maxValue,
|
||||||
|
inclusive: true,
|
||||||
|
type: 'number',
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const ZSignFieldNumberFormSchema = z.object({
|
const ZSignFieldNumberFormSchema = z.object({
|
||||||
number: createNumberFieldSchema(fieldMeta),
|
number: createNumberFieldSchema(fieldMeta),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -96,7 +96,7 @@ export const TemplateCreateDialog = ({ folderId }: TemplateCreateDialogProps) =>
|
|||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button className="cursor-pointer" disabled={!user.emailVerified}>
|
<Button className="cursor-pointer" disabled={!user.emailVerified}>
|
||||||
<FilePlus className="-ml-1 mr-2 h-4 w-4" />
|
<FilePlus className="-ml-1 mr-2 h-4 w-4" />
|
||||||
<Trans>New Template</Trans>
|
<Trans>Template (Legacy)</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
|
|||||||
@ -265,7 +265,7 @@ export const TemplateDirectLinkDialog = ({
|
|||||||
{remaining.directTemplates !== 0 && (
|
{remaining.directTemplates !== 0 && (
|
||||||
<DialogFooter className="mx-auto mt-4">
|
<DialogFooter className="mx-auto mt-4">
|
||||||
<Button type="button" onClick={() => setCurrentStep('SELECT_RECIPIENT')}>
|
<Button type="button" onClick={() => setCurrentStep('SELECT_RECIPIENT')}>
|
||||||
<Trans> Enable direct link signing</Trans>
|
<Trans>Enable direct link signing</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -336,7 +336,7 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<PDFViewer
|
<PDFViewer
|
||||||
envelopeItem={envelopeItems[0]}
|
envelopeItem={envelopeItems[0]}
|
||||||
token={token}
|
token={recipient.token}
|
||||||
version="signed"
|
version="signed"
|
||||||
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
49
apps/remix/app/components/filters/date-range-filter.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { useTransition } from 'react';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
|
import type { DateRange } from '@documenso/lib/types/search-params';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@documenso/ui/primitives/select';
|
||||||
|
|
||||||
|
type DateRangeFilterProps = {
|
||||||
|
currentRange: DateRange;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DateRangeFilter = ({ currentRange }: DateRangeFilterProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
|
||||||
|
const handleRangeChange = (value: string) => {
|
||||||
|
startTransition(() => {
|
||||||
|
updateSearchParams({
|
||||||
|
dateRange: value as DateRange,
|
||||||
|
page: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={currentRange} onValueChange={handleRangeChange} disabled={isPending}>
|
||||||
|
<SelectTrigger className="w-48">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="last30days">{_(msg`Last 30 Days`)}</SelectItem>
|
||||||
|
<SelectItem value="last90days">{_(msg`Last 90 Days`)}</SelectItem>
|
||||||
|
<SelectItem value="lastYear">{_(msg`Last Year`)}</SelectItem>
|
||||||
|
<SelectItem value="allTime">{_(msg`All Time`)}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -7,6 +7,7 @@ import type { z } from 'zod';
|
|||||||
import {
|
import {
|
||||||
DEFAULT_FIELD_FONT_SIZE,
|
DEFAULT_FIELD_FONT_SIZE,
|
||||||
type TDateFieldMeta as DateFieldMeta,
|
type TDateFieldMeta as DateFieldMeta,
|
||||||
|
FIELD_DEFAULT_GENERIC_ALIGN,
|
||||||
ZDateFieldMeta,
|
ZDateFieldMeta,
|
||||||
} from '@documenso/lib/types/field-meta';
|
} from '@documenso/lib/types/field-meta';
|
||||||
import { Form } from '@documenso/ui/primitives/form/form';
|
import { Form } from '@documenso/ui/primitives/form/form';
|
||||||
@ -39,7 +40,7 @@ export const EditorFieldDateForm = ({
|
|||||||
mode: 'onChange',
|
mode: 'onChange',
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
||||||
textAlign: value.textAlign || 'left',
|
textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import type { z } from 'zod';
|
|||||||
import {
|
import {
|
||||||
DEFAULT_FIELD_FONT_SIZE,
|
DEFAULT_FIELD_FONT_SIZE,
|
||||||
type TEmailFieldMeta as EmailFieldMeta,
|
type TEmailFieldMeta as EmailFieldMeta,
|
||||||
|
FIELD_DEFAULT_GENERIC_ALIGN,
|
||||||
ZEmailFieldMeta,
|
ZEmailFieldMeta,
|
||||||
} from '@documenso/lib/types/field-meta';
|
} from '@documenso/lib/types/field-meta';
|
||||||
import { Form } from '@documenso/ui/primitives/form/form';
|
import { Form } from '@documenso/ui/primitives/form/form';
|
||||||
@ -39,7 +40,7 @@ export const EditorFieldEmailForm = ({
|
|||||||
mode: 'onChange',
|
mode: 'onChange',
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
||||||
textAlign: value.textAlign || 'left',
|
textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,10 @@ import { useEffect } from 'react';
|
|||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import { type Control, useFormContext } from 'react-hook-form';
|
import { type Control, useFormContext } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { FIELD_MIN_LINE_HEIGHT } from '@documenso/lib/types/field-meta';
|
||||||
|
import { FIELD_MAX_LINE_HEIGHT } from '@documenso/lib/types/field-meta';
|
||||||
|
import { FIELD_MIN_LETTER_SPACING } from '@documenso/lib/types/field-meta';
|
||||||
|
import { FIELD_MAX_LETTER_SPACING } from '@documenso/lib/types/field-meta';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||||
import {
|
import {
|
||||||
@ -107,6 +111,119 @@ export const EditorGenericTextAlignField = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const EditorGenericVerticalAlignField = ({
|
||||||
|
formControl,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
formControl: FormControlType;
|
||||||
|
className?: string;
|
||||||
|
}) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormField
|
||||||
|
control={formControl}
|
||||||
|
name="verticalAlign"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className={className}>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Vertical Align</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select {...field} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={t`Select vertical align`} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="top">
|
||||||
|
<Trans>Top</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="middle">
|
||||||
|
<Trans>Middle</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="bottom">
|
||||||
|
<Trans>Bottom</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EditorGenericLineHeightField = ({
|
||||||
|
formControl,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
formControl: FormControlType;
|
||||||
|
className?: string;
|
||||||
|
}) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormField
|
||||||
|
control={formControl}
|
||||||
|
name="lineHeight"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className={className}>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Line Height</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={FIELD_MIN_LINE_HEIGHT}
|
||||||
|
max={FIELD_MAX_LINE_HEIGHT}
|
||||||
|
className="bg-background"
|
||||||
|
placeholder={t`Line height`}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EditorGenericLetterSpacingField = ({
|
||||||
|
formControl,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
formControl: FormControlType;
|
||||||
|
className?: string;
|
||||||
|
}) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormField
|
||||||
|
control={formControl}
|
||||||
|
name="letterSpacing"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className={className}>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Letter Spacing</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={FIELD_MIN_LETTER_SPACING}
|
||||||
|
max={FIELD_MAX_LETTER_SPACING}
|
||||||
|
className="bg-background"
|
||||||
|
placeholder={t`Letter spacing`}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const EditorGenericRequiredField = ({
|
export const EditorGenericRequiredField = ({
|
||||||
formControl,
|
formControl,
|
||||||
className,
|
className,
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import type { z } from 'zod';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_FIELD_FONT_SIZE,
|
DEFAULT_FIELD_FONT_SIZE,
|
||||||
|
FIELD_DEFAULT_GENERIC_ALIGN,
|
||||||
type TInitialsFieldMeta as InitialsFieldMeta,
|
type TInitialsFieldMeta as InitialsFieldMeta,
|
||||||
ZInitialsFieldMeta,
|
ZInitialsFieldMeta,
|
||||||
} from '@documenso/lib/types/field-meta';
|
} from '@documenso/lib/types/field-meta';
|
||||||
@ -39,7 +40,7 @@ export const EditorFieldInitialsForm = ({
|
|||||||
mode: 'onChange',
|
mode: 'onChange',
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
||||||
textAlign: value.textAlign || 'left',
|
textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import type { z } from 'zod';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_FIELD_FONT_SIZE,
|
DEFAULT_FIELD_FONT_SIZE,
|
||||||
|
FIELD_DEFAULT_GENERIC_ALIGN,
|
||||||
type TNameFieldMeta as NameFieldMeta,
|
type TNameFieldMeta as NameFieldMeta,
|
||||||
ZNameFieldMeta,
|
ZNameFieldMeta,
|
||||||
} from '@documenso/lib/types/field-meta';
|
} from '@documenso/lib/types/field-meta';
|
||||||
@ -39,7 +40,7 @@ export const EditorFieldNameForm = ({
|
|||||||
mode: 'onChange',
|
mode: 'onChange',
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
||||||
textAlign: value.textAlign || 'left',
|
textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,11 @@ import { useForm, useWatch } from 'react-hook-form';
|
|||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_FIELD_FONT_SIZE,
|
||||||
|
FIELD_DEFAULT_GENERIC_ALIGN,
|
||||||
|
FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN,
|
||||||
|
FIELD_DEFAULT_LETTER_SPACING,
|
||||||
|
FIELD_DEFAULT_LINE_HEIGHT,
|
||||||
type TNumberFieldMeta as NumberFieldMeta,
|
type TNumberFieldMeta as NumberFieldMeta,
|
||||||
ZNumberFieldMeta,
|
ZNumberFieldMeta,
|
||||||
} from '@documenso/lib/types/field-meta';
|
} from '@documenso/lib/types/field-meta';
|
||||||
@ -31,9 +36,12 @@ import { Separator } from '@documenso/ui/primitives/separator';
|
|||||||
import {
|
import {
|
||||||
EditorGenericFontSizeField,
|
EditorGenericFontSizeField,
|
||||||
EditorGenericLabelField,
|
EditorGenericLabelField,
|
||||||
|
EditorGenericLetterSpacingField,
|
||||||
|
EditorGenericLineHeightField,
|
||||||
EditorGenericReadOnlyField,
|
EditorGenericReadOnlyField,
|
||||||
EditorGenericRequiredField,
|
EditorGenericRequiredField,
|
||||||
EditorGenericTextAlignField,
|
EditorGenericTextAlignField,
|
||||||
|
EditorGenericVerticalAlignField,
|
||||||
} from './editor-field-generic-field-forms';
|
} from './editor-field-generic-field-forms';
|
||||||
|
|
||||||
const ZNumberFieldFormSchema = ZNumberFieldMeta.pick({
|
const ZNumberFieldFormSchema = ZNumberFieldMeta.pick({
|
||||||
@ -43,6 +51,9 @@ const ZNumberFieldFormSchema = ZNumberFieldMeta.pick({
|
|||||||
numberFormat: true,
|
numberFormat: true,
|
||||||
fontSize: true,
|
fontSize: true,
|
||||||
textAlign: true,
|
textAlign: true,
|
||||||
|
lineHeight: true,
|
||||||
|
letterSpacing: true,
|
||||||
|
verticalAlign: true,
|
||||||
required: true,
|
required: true,
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
minValue: true,
|
minValue: true,
|
||||||
@ -99,8 +110,11 @@ export const EditorFieldNumberForm = ({
|
|||||||
placeholder: value.placeholder || '',
|
placeholder: value.placeholder || '',
|
||||||
value: value.value || '',
|
value: value.value || '',
|
||||||
numberFormat: value.numberFormat || null,
|
numberFormat: value.numberFormat || null,
|
||||||
fontSize: value.fontSize || 14,
|
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
||||||
textAlign: value.textAlign || 'left',
|
textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN,
|
||||||
|
lineHeight: value.lineHeight ?? FIELD_DEFAULT_LINE_HEIGHT,
|
||||||
|
letterSpacing: value.letterSpacing ?? FIELD_DEFAULT_LETTER_SPACING,
|
||||||
|
verticalAlign: value.verticalAlign ?? FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN,
|
||||||
required: value.required || false,
|
required: value.required || false,
|
||||||
readOnly: value.readOnly || false,
|
readOnly: value.readOnly || false,
|
||||||
minValue: value.minValue,
|
minValue: value.minValue,
|
||||||
@ -118,6 +132,10 @@ export const EditorFieldNumberForm = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const validatedFormValues = ZNumberFieldFormSchema.safeParse(formValues);
|
const validatedFormValues = ZNumberFieldFormSchema.safeParse(formValues);
|
||||||
|
|
||||||
|
if (formValues.readOnly && !formValues.value) {
|
||||||
|
void form.trigger('value');
|
||||||
|
}
|
||||||
|
|
||||||
if (validatedFormValues.success) {
|
if (validatedFormValues.success) {
|
||||||
onValueChange({
|
onValueChange({
|
||||||
type: 'number',
|
type: 'number',
|
||||||
@ -130,10 +148,12 @@ export const EditorFieldNumberForm = ({
|
|||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form>
|
<form>
|
||||||
<fieldset className="flex flex-col gap-2">
|
<fieldset className="flex flex-col gap-2">
|
||||||
<div className="flex w-full flex-row gap-x-4">
|
<EditorGenericFontSizeField className="w-full" formControl={form.control} />
|
||||||
<EditorGenericFontSizeField className="w-full" formControl={form.control} />
|
|
||||||
|
|
||||||
|
<div className="flex w-full flex-row gap-x-4">
|
||||||
<EditorGenericTextAlignField className="w-full" formControl={form.control} />
|
<EditorGenericTextAlignField className="w-full" formControl={form.control} />
|
||||||
|
|
||||||
|
<EditorGenericVerticalAlignField className="w-full" formControl={form.control} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EditorGenericLabelField formControl={form.control} />
|
<EditorGenericLabelField formControl={form.control} />
|
||||||
@ -204,6 +224,12 @@ export const EditorFieldNumberForm = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="flex w-full flex-row gap-x-4">
|
||||||
|
<EditorGenericLineHeightField className="w-full" formControl={form.control} />
|
||||||
|
|
||||||
|
<EditorGenericLetterSpacingField className="w-full" formControl={form.control} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<EditorGenericRequiredField formControl={form.control} />
|
<EditorGenericRequiredField formControl={form.control} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -5,11 +5,8 @@ import { Trans } from '@lingui/react/macro';
|
|||||||
import { useForm, useWatch } from 'react-hook-form';
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
import {
|
import { DEFAULT_SIGNATURE_TEXT_FONT_SIZE } from '@documenso/lib/constants/pdf';
|
||||||
DEFAULT_FIELD_FONT_SIZE,
|
import { type TSignatureFieldMeta, ZSignatureFieldMeta } from '@documenso/lib/types/field-meta';
|
||||||
type TSignatureFieldMeta,
|
|
||||||
ZSignatureFieldMeta,
|
|
||||||
} from '@documenso/lib/types/field-meta';
|
|
||||||
import { Form } from '@documenso/ui/primitives/form/form';
|
import { Form } from '@documenso/ui/primitives/form/form';
|
||||||
|
|
||||||
import { EditorGenericFontSizeField } from './editor-field-generic-field-forms';
|
import { EditorGenericFontSizeField } from './editor-field-generic-field-forms';
|
||||||
@ -35,7 +32,7 @@ export const EditorFieldSignatureForm = ({
|
|||||||
resolver: zodResolver(ZSignatureFieldFormSchema),
|
resolver: zodResolver(ZSignatureFieldFormSchema),
|
||||||
mode: 'onChange',
|
mode: 'onChange',
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
fontSize: value.fontSize || DEFAULT_SIGNATURE_TEXT_FONT_SIZE,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -3,11 +3,16 @@ import { useEffect } from 'react';
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import { useForm, useWatch } from 'react-hook-form';
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_FIELD_FONT_SIZE,
|
DEFAULT_FIELD_FONT_SIZE,
|
||||||
|
FIELD_DEFAULT_GENERIC_ALIGN,
|
||||||
|
FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN,
|
||||||
|
FIELD_DEFAULT_LETTER_SPACING,
|
||||||
|
FIELD_DEFAULT_LINE_HEIGHT,
|
||||||
type TTextFieldMeta as TextFieldMeta,
|
type TTextFieldMeta as TextFieldMeta,
|
||||||
|
ZTextFieldMeta,
|
||||||
} from '@documenso/lib/types/field-meta';
|
} from '@documenso/lib/types/field-meta';
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
@ -22,32 +27,36 @@ import { Textarea } from '@documenso/ui/primitives/textarea';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
EditorGenericFontSizeField,
|
EditorGenericFontSizeField,
|
||||||
|
EditorGenericLetterSpacingField,
|
||||||
|
EditorGenericLineHeightField,
|
||||||
EditorGenericReadOnlyField,
|
EditorGenericReadOnlyField,
|
||||||
EditorGenericRequiredField,
|
EditorGenericRequiredField,
|
||||||
EditorGenericTextAlignField,
|
EditorGenericTextAlignField,
|
||||||
|
EditorGenericVerticalAlignField,
|
||||||
} from './editor-field-generic-field-forms';
|
} from './editor-field-generic-field-forms';
|
||||||
|
|
||||||
const ZTextFieldFormSchema = z
|
const ZTextFieldFormSchema = ZTextFieldMeta.pick({
|
||||||
.object({
|
label: true,
|
||||||
label: z.string().optional(),
|
placeholder: true,
|
||||||
placeholder: z.string().optional(),
|
text: true,
|
||||||
text: z.string().optional(),
|
characterLimit: true,
|
||||||
characterLimit: z.coerce.number().min(0).optional(),
|
fontSize: true,
|
||||||
fontSize: z.coerce.number().min(8).max(96).optional(),
|
textAlign: true,
|
||||||
textAlign: z.enum(['left', 'center', 'right']).optional(),
|
lineHeight: true,
|
||||||
required: z.boolean().optional(),
|
letterSpacing: true,
|
||||||
readOnly: z.boolean().optional(),
|
verticalAlign: true,
|
||||||
})
|
required: true,
|
||||||
.refine(
|
readOnly: true,
|
||||||
(data) => {
|
}).refine(
|
||||||
// A read-only field must have text
|
(data) => {
|
||||||
return !data.readOnly || (data.text && data.text.length > 0);
|
// A read-only field must have text
|
||||||
},
|
return !data.readOnly || (data.text && data.text.length > 0);
|
||||||
{
|
},
|
||||||
message: 'A read-only field must have text',
|
{
|
||||||
path: ['text'],
|
message: 'A read-only field must have text',
|
||||||
},
|
path: ['text'],
|
||||||
);
|
},
|
||||||
|
);
|
||||||
|
|
||||||
type TTextFieldFormSchema = z.infer<typeof ZTextFieldFormSchema>;
|
type TTextFieldFormSchema = z.infer<typeof ZTextFieldFormSchema>;
|
||||||
|
|
||||||
@ -73,7 +82,10 @@ export const EditorFieldTextForm = ({
|
|||||||
text: value.text || '',
|
text: value.text || '',
|
||||||
characterLimit: value.characterLimit || 0,
|
characterLimit: value.characterLimit || 0,
|
||||||
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
||||||
textAlign: value.textAlign || 'left',
|
textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN,
|
||||||
|
lineHeight: value.lineHeight ?? FIELD_DEFAULT_LINE_HEIGHT,
|
||||||
|
letterSpacing: value.letterSpacing ?? FIELD_DEFAULT_LETTER_SPACING,
|
||||||
|
verticalAlign: value.verticalAlign ?? FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN,
|
||||||
required: value.required || false,
|
required: value.required || false,
|
||||||
readOnly: value.readOnly || false,
|
readOnly: value.readOnly || false,
|
||||||
},
|
},
|
||||||
@ -89,6 +101,10 @@ export const EditorFieldTextForm = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const validatedFormValues = ZTextFieldFormSchema.safeParse(formValues);
|
const validatedFormValues = ZTextFieldFormSchema.safeParse(formValues);
|
||||||
|
|
||||||
|
if (formValues.readOnly && !formValues.text) {
|
||||||
|
void form.trigger('text');
|
||||||
|
}
|
||||||
|
|
||||||
if (validatedFormValues.success) {
|
if (validatedFormValues.success) {
|
||||||
onValueChange({
|
onValueChange({
|
||||||
type: 'text',
|
type: 'text',
|
||||||
@ -101,10 +117,12 @@ export const EditorFieldTextForm = ({
|
|||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form>
|
<form>
|
||||||
<fieldset className="flex flex-col gap-2">
|
<fieldset className="flex flex-col gap-2">
|
||||||
<div className="flex w-full flex-row gap-x-4">
|
<EditorGenericFontSizeField className="w-full" formControl={form.control} />
|
||||||
<EditorGenericFontSizeField className="w-full" formControl={form.control} />
|
|
||||||
|
|
||||||
|
<div className="flex w-full flex-row gap-x-4">
|
||||||
<EditorGenericTextAlignField className="w-full" formControl={form.control} />
|
<EditorGenericTextAlignField className="w-full" formControl={form.control} />
|
||||||
|
|
||||||
|
<EditorGenericVerticalAlignField className="w-full" formControl={form.control} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
@ -182,17 +200,16 @@ export const EditorFieldTextForm = ({
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
className="bg-background"
|
className="bg-background"
|
||||||
placeholder={t`Field character limit`}
|
placeholder={t`Character limit`}
|
||||||
{...field}
|
{...field}
|
||||||
|
value={field.value || ''}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
field.onChange(e);
|
|
||||||
|
|
||||||
const values = form.getValues();
|
const values = form.getValues();
|
||||||
const characterLimit = parseInt(e.target.value, 10) || 0;
|
const characterLimit = parseInt(e.target.value, 10) || 0;
|
||||||
|
|
||||||
|
field.onChange(characterLimit || '');
|
||||||
|
|
||||||
const textValue = values.text || '';
|
const textValue = values.text || '';
|
||||||
|
|
||||||
if (characterLimit > 0 && textValue.length > characterLimit) {
|
if (characterLimit > 0 && textValue.length > characterLimit) {
|
||||||
@ -206,6 +223,12 @@ export const EditorFieldTextForm = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="flex w-full flex-row gap-x-4">
|
||||||
|
<EditorGenericLineHeightField className="w-full" formControl={form.control} />
|
||||||
|
|
||||||
|
<EditorGenericLetterSpacingField className="w-full" formControl={form.control} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<EditorGenericRequiredField formControl={form.control} />
|
<EditorGenericRequiredField formControl={form.control} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -184,10 +184,10 @@ export const DocumentSigningPageViewV2 = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="embed--DocumentWidgetFooter">
|
<div className="embed--DocumentWidgetFooter mt-auto">
|
||||||
{/* Footer of left sidebar. */}
|
{/* Footer of left sidebar. */}
|
||||||
{!isEmbed && (
|
{!isEmbed && (
|
||||||
<div className="mt-auto px-4">
|
<div className="px-4">
|
||||||
<Button asChild variant="ghost" className="w-full justify-start">
|
<Button asChild variant="ghost" className="w-full justify-start">
|
||||||
<Link to="/">
|
<Link to="/">
|
||||||
<ArrowLeftIcon className="mr-2 h-4 w-4" />
|
<ArrowLeftIcon className="mr-2 h-4 w-4" />
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { type DocumentData, DocumentStatus, type EnvelopeItem } from '@prisma/client';
|
import { type DocumentData, DocumentStatus, type EnvelopeItem, EnvelopeType } from '@prisma/client';
|
||||||
import { DownloadIcon } from 'lucide-react';
|
import { DownloadIcon } from 'lucide-react';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
@ -100,7 +100,14 @@ export const DocumentCertificateQRView = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{internalVersion === 2 ? (
|
{internalVersion === 2 ? (
|
||||||
<EnvelopeRenderProvider envelope={{ envelopeItems }} token={token}>
|
<EnvelopeRenderProvider
|
||||||
|
envelope={{
|
||||||
|
envelopeItems,
|
||||||
|
status: DocumentStatus.COMPLETED,
|
||||||
|
type: EnvelopeType.DOCUMENT,
|
||||||
|
}}
|
||||||
|
token={token}
|
||||||
|
>
|
||||||
<DocumentCertificateQrV2
|
<DocumentCertificateQrV2
|
||||||
title={title}
|
title={title}
|
||||||
recipientCount={recipientCount}
|
recipientCount={recipientCount}
|
||||||
@ -130,7 +137,7 @@ export const DocumentCertificateQRView = ({
|
|||||||
envelopeItems={envelopeItems}
|
envelopeItems={envelopeItems}
|
||||||
token={token}
|
token={token}
|
||||||
trigger={
|
trigger={
|
||||||
<Button type="button" variant="outline" className="flex-1">
|
<Button type="button" variant="outline" className="w-fit">
|
||||||
<DownloadIcon className="mr-2 h-5 w-5" />
|
<DownloadIcon className="mr-2 h-5 w-5" />
|
||||||
<Trans>Download</Trans>
|
<Trans>Download</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
@ -189,7 +196,7 @@ const DocumentCertificateQrV2 = ({
|
|||||||
envelopeItems={envelopeItems}
|
envelopeItems={envelopeItems}
|
||||||
token={token}
|
token={token}
|
||||||
trigger={
|
trigger={
|
||||||
<Button type="button" variant="outline" className="flex-1">
|
<Button type="button" variant="outline" className="w-fit">
|
||||||
<DownloadIcon className="mr-2 h-5 w-5" />
|
<DownloadIcon className="mr-2 h-5 w-5" />
|
||||||
<Trans>Download</Trans>
|
<Trans>Download</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { DateTime } from 'luxon';
|
|||||||
|
|
||||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||||
|
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||||
|
|
||||||
export type DocumentPageViewInformationProps = {
|
export type DocumentPageViewInformationProps = {
|
||||||
userId: number;
|
userId: number;
|
||||||
@ -40,6 +41,10 @@ export const DocumentPageViewInformation = ({
|
|||||||
.setLocale(i18n.locales?.[0] || i18n.locale)
|
.setLocale(i18n.locales?.[0] || i18n.locale)
|
||||||
.toRelative(),
|
.toRelative(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
description: msg`Document ID (Legacy)`,
|
||||||
|
value: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isMounted, envelope, userId]);
|
}, [isMounted, envelope, userId]);
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { useMemo, useState } from 'react';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { EnvelopeType } from '@prisma/client';
|
||||||
import { useNavigate, useParams } from 'react-router';
|
import { useNavigate, useParams } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
@ -17,7 +18,7 @@ import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
|||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import type { TCreateDocumentPayloadSchema } from '@documenso/trpc/server/document-router/create-document.types';
|
import type { TCreateDocumentPayloadSchema } from '@documenso/trpc/server/document-router/create-document.types';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { DocumentDropzone } from '@documenso/ui/primitives/document-upload';
|
import { DocumentUploadButton as DocumentUploadButtonPrimitive } from '@documenso/ui/primitives/document-upload-button';
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@ -28,11 +29,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export type DocumentUploadButtonProps = {
|
export type DocumentUploadButtonLegacyProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentUploadButton = ({ className }: DocumentUploadButtonProps) => {
|
export const DocumentUploadButtonLegacy = ({ className }: DocumentUploadButtonLegacyProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { user } = useSession();
|
const { user } = useSession();
|
||||||
@ -75,8 +76,10 @@ export const DocumentUploadButton = ({ className }: DocumentUploadButtonProps) =
|
|||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
title: file.name,
|
title: file.name,
|
||||||
timezone: userTimezone,
|
|
||||||
folderId: folderId ?? undefined,
|
folderId: folderId ?? undefined,
|
||||||
|
meta: {
|
||||||
|
timezone: userTimezone,
|
||||||
|
},
|
||||||
} satisfies TCreateDocumentPayloadSchema;
|
} satisfies TCreateDocumentPayloadSchema;
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
@ -144,12 +147,14 @@ export const DocumentUploadButton = ({ className }: DocumentUploadButtonProps) =
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div>
|
<div>
|
||||||
<DocumentDropzone
|
<DocumentUploadButtonPrimitive
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
disabled={remaining.documents === 0 || !user.emailVerified}
|
disabled={remaining.documents === 0 || !user.emailVerified}
|
||||||
disabledMessage={disabledMessage}
|
disabledMessage={disabledMessage}
|
||||||
onDrop={async (files) => onFileDrop(files[0])}
|
onDrop={async (files) => onFileDrop(files[0])}
|
||||||
onDropRejected={onFileDropRejected}
|
onDropRejected={onFileDropRejected}
|
||||||
|
type={EnvelopeType.DOCUMENT}
|
||||||
|
internalVersion="1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@ -616,13 +616,14 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
transform: 'translateX(-50%)',
|
transform: 'translateX(-50%)',
|
||||||
zIndex: 50,
|
zIndex: 50,
|
||||||
}}
|
}}
|
||||||
className="text-muted-foreground grid w-max grid-cols-5 gap-x-1 gap-y-0.5 rounded-md border bg-white p-1 shadow-sm"
|
// Don't use darkmode for this component, it should look the same for both light/dark modes.
|
||||||
|
className="grid w-max grid-cols-5 gap-x-1 gap-y-0.5 rounded-md border border-gray-300 bg-white p-1 text-gray-500 shadow-sm"
|
||||||
>
|
>
|
||||||
{fieldButtonList.map((field) => (
|
{fieldButtonList.map((field) => (
|
||||||
<button
|
<button
|
||||||
key={field.type}
|
key={field.type}
|
||||||
onClick={() => createFieldFromPendingTemplate(pendingFieldCreation, field.type)}
|
onClick={() => createFieldFromPendingTemplate(pendingFieldCreation, field.type)}
|
||||||
className="hover:text-foreground col-span-1 w-full flex-shrink-0 rounded-sm px-2 py-1 text-xs hover:bg-gray-100"
|
className="col-span-1 w-full flex-shrink-0 rounded-sm px-2 py-1 text-xs hover:bg-gray-100 hover:text-gray-600"
|
||||||
>
|
>
|
||||||
{t(field.name)}
|
{t(field.name)}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { lazy, useEffect, useMemo, useState } from 'react';
|
|||||||
|
|
||||||
import { faker } from '@faker-js/faker/locale/en';
|
import { faker } from '@faker-js/faker/locale/en';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { FieldType } from '@prisma/client';
|
import { FieldType, SigningStatus } from '@prisma/client';
|
||||||
import { FileTextIcon } from 'lucide-react';
|
import { FileTextIcon } from 'lucide-react';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
@ -201,7 +201,10 @@ export const EnvelopeEditorPreviewPage = () => {
|
|||||||
envelope={envelope}
|
envelope={envelope}
|
||||||
token={undefined}
|
token={undefined}
|
||||||
fields={fieldsWithPlaceholders}
|
fields={fieldsWithPlaceholders}
|
||||||
recipients={envelope.recipients}
|
recipients={envelope.recipients.map((recipient) => ({
|
||||||
|
...recipient,
|
||||||
|
signingStatus: SigningStatus.SIGNED,
|
||||||
|
}))}
|
||||||
overrideSettings={{
|
overrideSettings={{
|
||||||
mode: 'export',
|
mode: 'export',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -212,7 +212,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const hasDocumentBeenSent = recipients.some(
|
const hasDocumentBeenSent = recipients.some(
|
||||||
(recipient) => recipient.sendStatus === SendStatus.SENT,
|
(recipient) => recipient.role !== RecipientRole.CC && recipient.sendStatus === SendStatus.SENT,
|
||||||
);
|
);
|
||||||
|
|
||||||
const canRecipientBeModified = (recipientId?: number) => {
|
const canRecipientBeModified = (recipientId?: number) => {
|
||||||
|
|||||||
@ -49,7 +49,7 @@ export const EnvelopeEditorUploadPage = () => {
|
|||||||
const organisation = useCurrentOrganisation();
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const { envelope, setLocalEnvelope, relativePath } = useCurrentEnvelopeEditor();
|
const { envelope, setLocalEnvelope, relativePath, editorFields } = useCurrentEnvelopeEditor();
|
||||||
const { maximumEnvelopeItemCount, remaining } = useLimits();
|
const { maximumEnvelopeItemCount, remaining } = useLimits();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@ -165,9 +165,17 @@ export const EnvelopeEditorUploadPage = () => {
|
|||||||
const onFileDelete = (envelopeItemId: string) => {
|
const onFileDelete = (envelopeItemId: string) => {
|
||||||
setLocalFiles((prev) => prev.filter((uploadingFile) => uploadingFile.id !== envelopeItemId));
|
setLocalFiles((prev) => prev.filter((uploadingFile) => uploadingFile.id !== envelopeItemId));
|
||||||
|
|
||||||
|
const fieldsWithoutDeletedItem = envelope.fields.filter(
|
||||||
|
(field) => field.envelopeItemId !== envelopeItemId,
|
||||||
|
);
|
||||||
|
|
||||||
setLocalEnvelope({
|
setLocalEnvelope({
|
||||||
envelopeItems: envelope.envelopeItems.filter((item) => item.id !== envelopeItemId),
|
envelopeItems: envelope.envelopeItems.filter((item) => item.id !== envelopeItemId),
|
||||||
|
fields: envelope.fields.filter((field) => field.envelopeItemId !== envelopeItemId),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reset editor fields.
|
||||||
|
editorFields.resetForm(fieldsWithoutDeletedItem);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { type Recipient, SigningStatus } from '@prisma/client';
|
import { DocumentStatus, type Recipient, SigningStatus } from '@prisma/client';
|
||||||
import type Konva from 'konva';
|
import type Konva from 'konva';
|
||||||
|
|
||||||
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
||||||
@ -19,6 +19,7 @@ export default function EnvelopeGenericPageRenderer() {
|
|||||||
const { i18n } = useLingui();
|
const { i18n } = useLingui();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
envelopeStatus,
|
||||||
currentEnvelopeItem,
|
currentEnvelopeItem,
|
||||||
fields,
|
fields,
|
||||||
recipients,
|
recipients,
|
||||||
@ -42,6 +43,10 @@ export default function EnvelopeGenericPageRenderer() {
|
|||||||
const { _className, scale } = pageContext;
|
const { _className, scale } = pageContext;
|
||||||
|
|
||||||
const localPageFields = useMemo((): GenericLocalField[] => {
|
const localPageFields = useMemo((): GenericLocalField[] => {
|
||||||
|
if (envelopeStatus === DocumentStatus.COMPLETED) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
return fields
|
return fields
|
||||||
.filter(
|
.filter(
|
||||||
(field) =>
|
(field) =>
|
||||||
@ -54,11 +59,20 @@ export default function EnvelopeGenericPageRenderer() {
|
|||||||
throw new Error(`Recipient not found for field ${field.id}`);
|
throw new Error(`Recipient not found for field ${field.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isInserted = recipient.signingStatus === SigningStatus.SIGNED && field.inserted;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...field,
|
...field,
|
||||||
|
inserted: isInserted,
|
||||||
|
customText: isInserted ? field.customText : '',
|
||||||
recipient,
|
recipient,
|
||||||
};
|
};
|
||||||
});
|
})
|
||||||
|
.filter(
|
||||||
|
({ inserted, fieldMeta, recipient }) =>
|
||||||
|
(recipient.signingStatus === SigningStatus.SIGNED ? inserted : true) ||
|
||||||
|
fieldMeta?.readOnly,
|
||||||
|
);
|
||||||
}, [fields, pageContext.pageNumber, currentEnvelopeItem?.id, recipients]);
|
}, [fields, pageContext.pageNumber, currentEnvelopeItem?.id, recipients]);
|
||||||
|
|
||||||
const unsafeRenderFieldOnLayer = (field: GenericLocalField) => {
|
const unsafeRenderFieldOnLayer = (field: GenericLocalField) => {
|
||||||
@ -67,12 +81,8 @@ export default function EnvelopeGenericPageRenderer() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { recipient } = field;
|
|
||||||
|
|
||||||
const fieldTranslations = getClientSideFieldTranslations(i18n);
|
const fieldTranslations = getClientSideFieldTranslations(i18n);
|
||||||
|
|
||||||
const isInserted = recipient.signingStatus === SigningStatus.SIGNED && field.inserted;
|
|
||||||
|
|
||||||
renderField({
|
renderField({
|
||||||
scale,
|
scale,
|
||||||
pageLayer: pageLayer.current,
|
pageLayer: pageLayer.current,
|
||||||
@ -83,7 +93,6 @@ export default function EnvelopeGenericPageRenderer() {
|
|||||||
height: Number(field.height),
|
height: Number(field.height),
|
||||||
positionX: Number(field.positionX),
|
positionX: Number(field.positionX),
|
||||||
positionY: Number(field.positionY),
|
positionY: Number(field.positionY),
|
||||||
customText: isInserted ? field.customText : '',
|
|
||||||
fieldMeta: field.fieldMeta,
|
fieldMeta: field.fieldMeta,
|
||||||
signature: {
|
signature: {
|
||||||
signatureImageAsBase64: '',
|
signatureImageAsBase64: '',
|
||||||
@ -95,7 +104,7 @@ export default function EnvelopeGenericPageRenderer() {
|
|||||||
pageHeight: unscaledViewport.height,
|
pageHeight: unscaledViewport.height,
|
||||||
color: getRecipientColorKey(field.recipientId),
|
color: getRecipientColorKey(field.recipientId),
|
||||||
editable: false,
|
editable: false,
|
||||||
mode: overrideSettings?.mode ?? 'sign',
|
mode: overrideSettings?.mode ?? 'edit',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,14 @@
|
|||||||
import { useEffect, useMemo } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import { type Field, FieldType, RecipientRole, type Signature } from '@prisma/client';
|
import {
|
||||||
|
type Field,
|
||||||
|
FieldType,
|
||||||
|
type Recipient,
|
||||||
|
RecipientRole,
|
||||||
|
type Signature,
|
||||||
|
SigningStatus,
|
||||||
|
} from '@prisma/client';
|
||||||
import type Konva from 'konva';
|
import type Konva from 'konva';
|
||||||
import type { KonvaEventObject } from 'konva/lib/Node';
|
import type { KonvaEventObject } from 'konva/lib/Node';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
@ -12,6 +19,7 @@ import { useOptionalSession } from '@documenso/lib/client-only/providers/session
|
|||||||
import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates';
|
import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates';
|
||||||
import { isBase64Image } from '@documenso/lib/constants/signatures';
|
import { isBase64Image } from '@documenso/lib/constants/signatures';
|
||||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||||
|
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||||
import { ZFullFieldSchema } from '@documenso/lib/types/field';
|
import { ZFullFieldSchema } from '@documenso/lib/types/field';
|
||||||
import { createSpinner } from '@documenso/lib/universal/field-renderer/field-generic-items';
|
import { createSpinner } from '@documenso/lib/universal/field-renderer/field-generic-items';
|
||||||
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
|
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
|
||||||
@ -19,6 +27,7 @@ import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields
|
|||||||
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
|
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
|
||||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import type { TSignEnvelopeFieldValue } from '@documenso/trpc/server/envelope-router/sign-envelope-field.types';
|
import type { TSignEnvelopeFieldValue } from '@documenso/trpc/server/envelope-router/sign-envelope-field.types';
|
||||||
|
import { EnvelopeRecipientFieldTooltip } from '@documenso/ui/components/document/envelope-recipient-field-tooltip';
|
||||||
import { EnvelopeFieldToolTip } from '@documenso/ui/components/field/envelope-field-tooltip';
|
import { EnvelopeFieldToolTip } from '@documenso/ui/components/field/envelope-field-tooltip';
|
||||||
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
|
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
@ -36,6 +45,10 @@ import { handleTextFieldClick } from '~/utils/field-signing/text-field';
|
|||||||
import { useRequiredDocumentSigningAuthContext } from '../document-signing/document-signing-auth-provider';
|
import { useRequiredDocumentSigningAuthContext } from '../document-signing/document-signing-auth-provider';
|
||||||
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
|
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
|
||||||
|
|
||||||
|
type GenericLocalField = TEnvelope['fields'][number] & {
|
||||||
|
recipient: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>;
|
||||||
|
};
|
||||||
|
|
||||||
export default function EnvelopeSignerPageRenderer() {
|
export default function EnvelopeSignerPageRenderer() {
|
||||||
const { t, i18n } = useLingui();
|
const { t, i18n } = useLingui();
|
||||||
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
|
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
|
||||||
@ -91,6 +104,36 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
);
|
);
|
||||||
}, [recipientFields, selectedAssistantRecipientFields, pageContext.pageNumber]);
|
}, [recipientFields, selectedAssistantRecipientFields, pageContext.pageNumber]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns fields that have been fully signed by other recipients for this specific
|
||||||
|
* page.
|
||||||
|
*/
|
||||||
|
const localPageOtherRecipientFields = useMemo((): GenericLocalField[] => {
|
||||||
|
const signedRecipients = envelope.recipients.filter(
|
||||||
|
(recipient) => recipient.signingStatus === SigningStatus.SIGNED,
|
||||||
|
);
|
||||||
|
|
||||||
|
return signedRecipients.flatMap((recipient) => {
|
||||||
|
return recipient.fields
|
||||||
|
.filter(
|
||||||
|
(field) =>
|
||||||
|
field.page === pageContext.pageNumber &&
|
||||||
|
field.envelopeItemId === currentEnvelopeItem?.id &&
|
||||||
|
(field.inserted || field.fieldMeta?.readOnly),
|
||||||
|
)
|
||||||
|
.map((field) => ({
|
||||||
|
...field,
|
||||||
|
recipient: {
|
||||||
|
id: recipient.id,
|
||||||
|
name: recipient.name,
|
||||||
|
email: recipient.email,
|
||||||
|
signingStatus: recipient.signingStatus,
|
||||||
|
role: recipient.role,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}, [envelope.recipients, pageContext.pageNumber]);
|
||||||
|
|
||||||
const unsafeRenderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
|
const unsafeRenderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
|
||||||
if (!pageLayer.current) {
|
if (!pageLayer.current) {
|
||||||
console.error('Layer not loaded yet');
|
console.error('Layer not loaded yet');
|
||||||
@ -376,6 +419,46 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderFields = () => {
|
||||||
|
if (!pageLayer.current) {
|
||||||
|
console.error('Layer not loaded yet');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render current recipient fields.
|
||||||
|
for (const field of localPageFields) {
|
||||||
|
renderFieldOnLayer(field);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render other recipient signed and inserted fields.
|
||||||
|
for (const field of localPageOtherRecipientFields) {
|
||||||
|
try {
|
||||||
|
renderField({
|
||||||
|
scale,
|
||||||
|
pageLayer: pageLayer.current,
|
||||||
|
field: {
|
||||||
|
renderId: field.id.toString(),
|
||||||
|
...field,
|
||||||
|
width: Number(field.width),
|
||||||
|
height: Number(field.height),
|
||||||
|
positionX: Number(field.positionX),
|
||||||
|
positionY: Number(field.positionY),
|
||||||
|
fieldMeta: field.fieldMeta,
|
||||||
|
},
|
||||||
|
translations: getClientSideFieldTranslations(i18n),
|
||||||
|
pageWidth: unscaledViewport.width,
|
||||||
|
pageHeight: unscaledViewport.height,
|
||||||
|
color: 'readOnly',
|
||||||
|
editable: false,
|
||||||
|
mode: 'sign',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Unable to render one or more fields belonging to other recipients.');
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const signField = async (
|
const signField = async (
|
||||||
fieldId: number,
|
fieldId: number,
|
||||||
payload: TSignEnvelopeFieldValue,
|
payload: TSignEnvelopeFieldValue,
|
||||||
@ -412,11 +495,7 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
* Initialize the Konva page canvas and all fields and interactions.
|
* Initialize the Konva page canvas and all fields and interactions.
|
||||||
*/
|
*/
|
||||||
const createPageCanvas = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
|
const createPageCanvas = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
|
||||||
// Render the fields.
|
renderFields();
|
||||||
for (const field of localPageFields) {
|
|
||||||
renderFieldOnLayer(field);
|
|
||||||
}
|
|
||||||
|
|
||||||
currentPageLayer.batchDraw();
|
currentPageLayer.batchDraw();
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -428,9 +507,7 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
localPageFields.forEach((field) => {
|
renderFields();
|
||||||
renderFieldOnLayer(field);
|
|
||||||
});
|
|
||||||
|
|
||||||
pageLayer.current.batchDraw();
|
pageLayer.current.batchDraw();
|
||||||
}, [localPageFields, showPendingFieldTooltip, fullName, signature, email]);
|
}, [localPageFields, showPendingFieldTooltip, fullName, signature, email]);
|
||||||
@ -446,9 +523,7 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
// Rerender the whole page.
|
// Rerender the whole page.
|
||||||
pageLayer.current.destroyChildren();
|
pageLayer.current.destroyChildren();
|
||||||
|
|
||||||
localPageFields.forEach((field) => {
|
renderFields();
|
||||||
renderFieldOnLayer(field);
|
|
||||||
});
|
|
||||||
|
|
||||||
pageLayer.current.batchDraw();
|
pageLayer.current.batchDraw();
|
||||||
}, [selectedAssistantRecipient]);
|
}, [selectedAssistantRecipient]);
|
||||||
@ -475,6 +550,15 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
</EnvelopeFieldToolTip>
|
</EnvelopeFieldToolTip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{localPageOtherRecipientFields.map((field) => (
|
||||||
|
<EnvelopeRecipientFieldTooltip
|
||||||
|
key={field.id}
|
||||||
|
field={field}
|
||||||
|
showFieldStatus={true}
|
||||||
|
showRecipientTooltip={true}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
{/* The element Konva will inject it's canvas into. */}
|
{/* The element Konva will inject it's canvas into. */}
|
||||||
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
|
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
|
||||||
|
|
||||||
|
|||||||
@ -75,14 +75,12 @@ export const EnvelopeSignerCompleteDialog = () => {
|
|||||||
accessAuthOptions?: TRecipientAccessAuth,
|
accessAuthOptions?: TRecipientAccessAuth,
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const payload = {
|
await completeDocument({
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||||
authOptions: accessAuthOptions,
|
accessAuthOptions,
|
||||||
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
|
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
|
||||||
};
|
});
|
||||||
|
|
||||||
await completeDocument(payload);
|
|
||||||
|
|
||||||
analytics.capture('App: Recipient has completed signing', {
|
analytics.capture('App: Recipient has completed signing', {
|
||||||
signerId: recipient.id,
|
signerId: recipient.id,
|
||||||
|
|||||||
@ -1,10 +1,15 @@
|
|||||||
import { type ReactNode, useState } from 'react';
|
import { type ReactNode, useState } from 'react';
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { EnvelopeType } from '@prisma/client';
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
import { ErrorCode, type FileRejection, useDropzone } from 'react-dropzone';
|
import {
|
||||||
|
ErrorCode as DropzoneErrorCode,
|
||||||
|
ErrorCode,
|
||||||
|
type FileRejection,
|
||||||
|
useDropzone,
|
||||||
|
} from 'react-dropzone';
|
||||||
import { Link, useNavigate, useParams } from 'react-router';
|
import { Link, useNavigate, useParams } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
@ -16,21 +21,26 @@ import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT, IS_BILLING_ENABLED } from '@documenso/l
|
|||||||
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
|
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import type { TCreateDocumentPayloadSchema } from '@documenso/trpc/server/document-router/create-document.types';
|
import type { TCreateEnvelopePayload } from '@documenso/trpc/server/envelope-router/create-envelope.types';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export interface DocumentDropZoneWrapperProps {
|
export interface EnvelopeDropZoneWrapperProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
type: EnvelopeType;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZoneWrapperProps) => {
|
export const EnvelopeDropZoneWrapper = ({
|
||||||
const { _ } = useLingui();
|
children,
|
||||||
|
type,
|
||||||
|
className,
|
||||||
|
}: EnvelopeDropZoneWrapperProps) => {
|
||||||
|
const { t } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { user } = useSession();
|
const { user } = useSession();
|
||||||
const { folderId } = useParams();
|
const { folderId } = useParams();
|
||||||
@ -47,13 +57,13 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
|
|||||||
TIME_ZONES.find((timezone) => timezone === Intl.DateTimeFormat().resolvedOptions().timeZone) ??
|
TIME_ZONES.find((timezone) => timezone === Intl.DateTimeFormat().resolvedOptions().timeZone) ??
|
||||||
DEFAULT_DOCUMENT_TIME_ZONE;
|
DEFAULT_DOCUMENT_TIME_ZONE;
|
||||||
|
|
||||||
const { quota, remaining, refreshLimits } = useLimits();
|
const { quota, remaining, refreshLimits, maximumEnvelopeItemCount } = useLimits();
|
||||||
|
|
||||||
const { mutateAsync: createDocument } = trpc.document.create.useMutation();
|
const { mutateAsync: createEnvelope } = trpc.envelope.create.useMutation();
|
||||||
|
|
||||||
const isUploadDisabled = remaining.documents === 0 || !user.emailVerified;
|
const isUploadDisabled = remaining.documents === 0 || !user.emailVerified;
|
||||||
|
|
||||||
const onFileDrop = async (file: File) => {
|
const onFileDrop = async (files: File[]) => {
|
||||||
if (isUploadDisabled && IS_BILLING_ENABLED()) {
|
if (isUploadDisabled && IS_BILLING_ENABLED()) {
|
||||||
await navigate(`/o/${organisation.url}/settings/billing`);
|
await navigate(`/o/${organisation.url}/settings/billing`);
|
||||||
return;
|
return;
|
||||||
@ -63,51 +73,67 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
title: file.name,
|
folderId,
|
||||||
timezone: userTimezone,
|
type,
|
||||||
folderId: folderId ?? undefined,
|
title: files[0].name,
|
||||||
} satisfies TCreateDocumentPayloadSchema;
|
meta: {
|
||||||
|
timezone: userTimezone,
|
||||||
|
},
|
||||||
|
} satisfies TCreateEnvelopePayload;
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|
||||||
formData.append('payload', JSON.stringify(payload));
|
formData.append('payload', JSON.stringify(payload));
|
||||||
formData.append('file', file);
|
|
||||||
|
|
||||||
const { envelopeId: id } = await createDocument(formData);
|
for (const file of files) {
|
||||||
|
formData.append('files', file);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await createEnvelope(formData);
|
||||||
|
|
||||||
void refreshLimits();
|
void refreshLimits();
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Document uploaded`),
|
title: type === EnvelopeType.DOCUMENT ? t`Document uploaded` : t`Template uploaded`,
|
||||||
description: _(msg`Your document has been uploaded successfully.`),
|
description:
|
||||||
|
type === EnvelopeType.DOCUMENT
|
||||||
|
? t`Your document has been uploaded successfully.`
|
||||||
|
: t`Your template has been uploaded successfully.`,
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
analytics.capture('App: Document Uploaded', {
|
if (type === EnvelopeType.DOCUMENT) {
|
||||||
userId: user.id,
|
analytics.capture('App: Document Uploaded', {
|
||||||
documentId: id,
|
userId: user.id,
|
||||||
timestamp: new Date().toISOString(),
|
documentId: id,
|
||||||
});
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await navigate(`${formatDocumentsPath(team.url)}/${id}/edit`);
|
const pathPrefix =
|
||||||
|
type === EnvelopeType.DOCUMENT
|
||||||
|
? formatDocumentsPath(team.url)
|
||||||
|
: formatTemplatesPath(team.url);
|
||||||
|
|
||||||
|
await navigate(`${pathPrefix}/${id}/edit`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = AppError.parseError(err);
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
const errorMessage = match(error.code)
|
const errorMessage = match(error.code)
|
||||||
.with('INVALID_DOCUMENT_FILE', () => msg`You cannot upload encrypted PDFs`)
|
.with('INVALID_DOCUMENT_FILE', () => t`You cannot upload encrypted PDFs`)
|
||||||
.with(
|
.with(
|
||||||
AppErrorCode.LIMIT_EXCEEDED,
|
AppErrorCode.LIMIT_EXCEEDED,
|
||||||
() => msg`You have reached your document limit for this month. Please upgrade your plan.`,
|
() => t`You have reached your document limit for this month. Please upgrade your plan.`,
|
||||||
)
|
)
|
||||||
.with(
|
.with(
|
||||||
'ENVELOPE_ITEM_LIMIT_EXCEEDED',
|
'ENVELOPE_ITEM_LIMIT_EXCEEDED',
|
||||||
() => msg`You have reached the limit of the number of files per envelope`,
|
() => t`You have reached the limit of the number of files per envelope`,
|
||||||
)
|
)
|
||||||
.otherwise(() => msg`An error occurred while uploading your document.`);
|
.otherwise(() => t`An error occurred during upload.`);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Error`),
|
title: t`Error`,
|
||||||
description: _(errorMessage),
|
description: errorMessage,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
duration: 7500,
|
duration: 7500,
|
||||||
});
|
});
|
||||||
@ -121,6 +147,20 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const maxItemsReached = fileRejections.some((fileRejection) =>
|
||||||
|
fileRejection.errors.some((error) => error.code === DropzoneErrorCode.TooManyFiles),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (maxItemsReached) {
|
||||||
|
toast({
|
||||||
|
title: t`You cannot upload more than ${maximumEnvelopeItemCount} items per envelope.`,
|
||||||
|
duration: 5000,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Since users can only upload only one file (no multi-upload), we only handle the first file rejection
|
// Since users can only upload only one file (no multi-upload), we only handle the first file rejection
|
||||||
const { file, errors } = fileRejections[0];
|
const { file, errors } = fileRejections[0];
|
||||||
|
|
||||||
@ -148,14 +188,14 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
|
|||||||
const description = (
|
const description = (
|
||||||
<>
|
<>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{file.name} <Trans>couldn't be uploaded:</Trans>
|
<Trans>{file.name} couldn't be uploaded:</Trans>
|
||||||
</span>
|
</span>
|
||||||
{errorNodes}
|
{errorNodes}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Upload failed`),
|
title: t`Upload failed`,
|
||||||
description,
|
description,
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
@ -165,17 +205,11 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
|
|||||||
accept: {
|
accept: {
|
||||||
'application/pdf': ['.pdf'],
|
'application/pdf': ['.pdf'],
|
||||||
},
|
},
|
||||||
//disabled: isUploadDisabled,
|
multiple: true,
|
||||||
multiple: false,
|
|
||||||
maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT),
|
maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT),
|
||||||
onDrop: ([acceptedFile]) => {
|
maxFiles: maximumEnvelopeItemCount,
|
||||||
if (acceptedFile) {
|
onDrop: (files) => void onFileDrop(files),
|
||||||
void onFileDrop(acceptedFile);
|
onDropRejected: onFileDropRejected,
|
||||||
}
|
|
||||||
},
|
|
||||||
onDropRejected: (fileRejections) => {
|
|
||||||
onFileDropRejected(fileRejections);
|
|
||||||
},
|
|
||||||
noClick: true,
|
noClick: true,
|
||||||
noDragEventsBubbling: true,
|
noDragEventsBubbling: true,
|
||||||
});
|
});
|
||||||
@ -189,7 +223,11 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
|
|||||||
<div className="bg-muted/60 fixed left-0 top-0 z-[9999] h-full w-full backdrop-blur-[4px]">
|
<div className="bg-muted/60 fixed left-0 top-0 z-[9999] h-full w-full backdrop-blur-[4px]">
|
||||||
<div className="pointer-events-none flex h-full w-full flex-col items-center justify-center">
|
<div className="pointer-events-none flex h-full w-full flex-col items-center justify-center">
|
||||||
<h2 className="text-foreground text-2xl font-semibold">
|
<h2 className="text-foreground text-2xl font-semibold">
|
||||||
<Trans>Upload Document</Trans>
|
{type === EnvelopeType.DOCUMENT ? (
|
||||||
|
<Trans>Upload Document</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Upload Template</Trans>
|
||||||
|
)}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p className="text-muted-foreground text-md mt-4">
|
<p className="text-muted-foreground text-md mt-4">
|
||||||
@ -224,7 +262,7 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
|
|||||||
<div className="pointer-events-none flex h-1/2 w-full flex-col items-center justify-center">
|
<div className="pointer-events-none flex h-1/2 w-full flex-col items-center justify-center">
|
||||||
<Loader className="text-primary h-12 w-12 animate-spin" />
|
<Loader className="text-primary h-12 w-12 animate-spin" />
|
||||||
<p className="text-foreground mt-8 font-medium">
|
<p className="text-foreground mt-8 font-medium">
|
||||||
<Trans>Uploading document...</Trans>
|
<Trans>Uploading</Trans>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -18,7 +18,7 @@ import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/t
|
|||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import type { TCreateEnvelopePayload } from '@documenso/trpc/server/envelope-router/create-envelope.types';
|
import type { TCreateEnvelopePayload } from '@documenso/trpc/server/envelope-router/create-envelope.types';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { DocumentDropzone } from '@documenso/ui/primitives/document-upload';
|
import { DocumentUploadButton } from '@documenso/ui/primitives/document-upload-button';
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@ -175,13 +175,14 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div>
|
<div>
|
||||||
<DocumentDropzone
|
<DocumentUploadButton
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
disabled={remaining.documents === 0 || !user.emailVerified}
|
disabled={remaining.documents === 0 || !user.emailVerified}
|
||||||
disabledMessage={disabledMessage}
|
disabledMessage={disabledMessage}
|
||||||
onDrop={onFileDrop}
|
onDrop={onFileDrop}
|
||||||
onDropRejected={onFileDropRejected}
|
onDropRejected={onFileDropRejected}
|
||||||
type="envelope"
|
type={type}
|
||||||
|
internalVersion="2"
|
||||||
maxFiles={maximumEnvelopeItemCount}
|
maxFiles={maximumEnvelopeItemCount}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -6,7 +6,6 @@ import { FolderIcon, HomeIcon } from 'lucide-react';
|
|||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { IS_ENVELOPES_ENABLED } from '@documenso/lib/constants/app';
|
|
||||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
||||||
@ -17,11 +16,11 @@ import { FolderDeleteDialog } from '~/components/dialogs/folder-delete-dialog';
|
|||||||
import { FolderMoveDialog } from '~/components/dialogs/folder-move-dialog';
|
import { FolderMoveDialog } from '~/components/dialogs/folder-move-dialog';
|
||||||
import { FolderUpdateDialog } from '~/components/dialogs/folder-update-dialog';
|
import { FolderUpdateDialog } from '~/components/dialogs/folder-update-dialog';
|
||||||
import { TemplateCreateDialog } from '~/components/dialogs/template-create-dialog';
|
import { TemplateCreateDialog } from '~/components/dialogs/template-create-dialog';
|
||||||
import { DocumentUploadButton } from '~/components/general/document/document-upload-button';
|
import { DocumentUploadButtonLegacy } from '~/components/general/document/document-upload-button-legacy';
|
||||||
import { FolderCard, FolderCardEmpty } from '~/components/general/folder/folder-card';
|
import { FolderCard, FolderCardEmpty } from '~/components/general/folder/folder-card';
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
import { EnvelopeUploadButton } from '../document/envelope-upload-button';
|
import { EnvelopeUploadButton } from '../envelope/envelope-upload-button';
|
||||||
|
|
||||||
export type FolderGridProps = {
|
export type FolderGridProps = {
|
||||||
type: FolderType;
|
type: FolderType;
|
||||||
@ -99,14 +98,12 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-4 sm:flex-row sm:justify-end">
|
<div className="flex gap-4 sm:flex-row sm:justify-end">
|
||||||
{(IS_ENVELOPES_ENABLED || organisation.organisationClaim.flags.allowEnvelopes) && (
|
<EnvelopeUploadButton type={type} folderId={parentId || undefined} />
|
||||||
<EnvelopeUploadButton type={type} folderId={parentId || undefined} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{type === FolderType.DOCUMENT ? (
|
{type === FolderType.DOCUMENT ? (
|
||||||
<DocumentUploadButton />
|
<DocumentUploadButtonLegacy /> // If you delete this, delete the component as well.
|
||||||
) : (
|
) : (
|
||||||
<TemplateCreateDialog folderId={parentId ?? undefined} />
|
<TemplateCreateDialog folderId={parentId ?? undefined} /> // If you delete this, delete the component as well.
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<FolderCreateDialog type={type} />
|
<FolderCreateDialog type={type} />
|
||||||
|
|||||||
@ -1,171 +0,0 @@
|
|||||||
import { type ReactNode, useState } from 'react';
|
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { Loader } from 'lucide-react';
|
|
||||||
import { ErrorCode, type FileRejection, useDropzone } from 'react-dropzone';
|
|
||||||
import { useNavigate, useParams } from 'react-router';
|
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
|
|
||||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
|
||||||
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
|
|
||||||
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import type { TCreateTemplatePayloadSchema } from '@documenso/trpc/server/template-router/schema';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
|
||||||
|
|
||||||
export interface TemplateDropZoneWrapperProps {
|
|
||||||
children: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TemplateDropZoneWrapper = ({ children, className }: TemplateDropZoneWrapperProps) => {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { folderId } = useParams();
|
|
||||||
|
|
||||||
const team = useCurrentTeam();
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation();
|
|
||||||
|
|
||||||
const onFileDrop = async (file: File) => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
title: file.name,
|
|
||||||
folderId: folderId ?? undefined,
|
|
||||||
} satisfies TCreateTemplatePayloadSchema;
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
|
|
||||||
formData.append('payload', JSON.stringify(payload));
|
|
||||||
formData.append('file', file);
|
|
||||||
|
|
||||||
const { envelopeId: id } = await createTemplate(formData);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`Template uploaded`),
|
|
||||||
description: _(
|
|
||||||
msg`Your template has been uploaded successfully. You will be redirected to the template page.`,
|
|
||||||
),
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
await navigate(`${formatTemplatesPath(team.url)}/${id}/edit`);
|
|
||||||
} catch {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Something went wrong`),
|
|
||||||
description: _(msg`Please try again later.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onFileDropRejected = (fileRejections: FileRejection[]) => {
|
|
||||||
if (!fileRejections.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Since users can only upload only one file (no multi-upload), we only handle the first file rejection
|
|
||||||
const { file, errors } = fileRejections[0];
|
|
||||||
|
|
||||||
if (!errors.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const errorNodes = errors.map((error, index) => (
|
|
||||||
<span key={index} className="block">
|
|
||||||
{match(error.code)
|
|
||||||
.with(ErrorCode.FileTooLarge, () => (
|
|
||||||
<Trans>File is larger than {APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB</Trans>
|
|
||||||
))
|
|
||||||
.with(ErrorCode.FileInvalidType, () => <Trans>Only PDF files are allowed</Trans>)
|
|
||||||
.with(ErrorCode.FileTooSmall, () => <Trans>File is too small</Trans>)
|
|
||||||
.with(ErrorCode.TooManyFiles, () => (
|
|
||||||
<Trans>Only one file can be uploaded at a time</Trans>
|
|
||||||
))
|
|
||||||
.otherwise(() => (
|
|
||||||
<Trans>Unknown error</Trans>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
));
|
|
||||||
|
|
||||||
const description = (
|
|
||||||
<>
|
|
||||||
<span className="font-medium">
|
|
||||||
{file.name} <Trans>couldn't be uploaded:</Trans>
|
|
||||||
</span>
|
|
||||||
{errorNodes}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`Upload failed`),
|
|
||||||
description,
|
|
||||||
duration: 5000,
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
|
||||||
accept: {
|
|
||||||
'application/pdf': ['.pdf'],
|
|
||||||
},
|
|
||||||
//disabled: isUploadDisabled,
|
|
||||||
multiple: false,
|
|
||||||
maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT),
|
|
||||||
onDrop: ([acceptedFile]) => {
|
|
||||||
if (acceptedFile) {
|
|
||||||
void onFileDrop(acceptedFile);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onDropRejected: (fileRejections) => {
|
|
||||||
onFileDropRejected(fileRejections);
|
|
||||||
},
|
|
||||||
noClick: true,
|
|
||||||
noDragEventsBubbling: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div {...getRootProps()} className={cn('relative min-h-screen', className)}>
|
|
||||||
<input {...getInputProps()} />
|
|
||||||
{children}
|
|
||||||
|
|
||||||
{isDragActive && (
|
|
||||||
<div className="bg-muted/60 fixed left-0 top-0 z-[9999] h-full w-full backdrop-blur-[4px]">
|
|
||||||
<div className="pointer-events-none flex h-full w-full flex-col items-center justify-center">
|
|
||||||
<h2 className="text-foreground text-2xl font-semibold">
|
|
||||||
<Trans>Upload Template</Trans>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground text-md mt-4">
|
|
||||||
<Trans>Drag and drop your PDF file here</Trans>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isLoading && (
|
|
||||||
<div className="bg-muted/30 absolute inset-0 z-50 backdrop-blur-[2px]">
|
|
||||||
<div className="pointer-events-none flex h-1/2 w-full flex-col items-center justify-center">
|
|
||||||
<Loader className="text-primary h-12 w-12 animate-spin" />
|
|
||||||
<p className="text-foreground mt-8 font-medium">
|
|
||||||
<Trans>Uploading template...</Trans>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -7,11 +7,13 @@ import type { User } from '@prisma/client';
|
|||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||||
|
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
|
||||||
|
|
||||||
export type TemplatePageViewInformationProps = {
|
export type TemplatePageViewInformationProps = {
|
||||||
userId: number;
|
userId: number;
|
||||||
template: {
|
template: {
|
||||||
userId: number;
|
userId: number;
|
||||||
|
secondaryId: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
user: Pick<User, 'id' | 'name' | 'email'>;
|
user: Pick<User, 'id' | 'name' | 'email'>;
|
||||||
@ -43,6 +45,10 @@ export const TemplatePageViewInformation = ({
|
|||||||
.setLocale(i18n.locales?.[0] || i18n.locale)
|
.setLocale(i18n.locales?.[0] || i18n.locale)
|
||||||
.toRelative(),
|
.toRelative(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
description: msg`Template ID (Legacy)`,
|
||||||
|
value: mapSecondaryIdToTemplateId(template.secondaryId),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isMounted, template, userId]);
|
}, [isMounted, template, userId]);
|
||||||
|
|||||||
@ -2,40 +2,49 @@ import { useEffect, useMemo, useState, useTransition } from 'react';
|
|||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { ChevronDownIcon, ChevronUpIcon, ChevronsUpDown, Loader } from 'lucide-react';
|
import { ChevronDownIcon, ChevronUpIcon, ChevronsUpDown, Loader } from 'lucide-react';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
|
import type { DateRange } from '@documenso/lib/types/search-params';
|
||||||
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
|
||||||
export type SigningVolume = {
|
export type OrganisationOverview = {
|
||||||
id: number;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
signingVolume: number;
|
signingVolume: number;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
planId: string;
|
customerId: string;
|
||||||
|
subscriptionStatus?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
teamCount?: number;
|
||||||
|
memberCount?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type LeaderboardTableProps = {
|
type OrganisationOverviewTableProps = {
|
||||||
signingVolume: SigningVolume[];
|
organisations: OrganisationOverview[];
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
perPage: number;
|
perPage: number;
|
||||||
page: number;
|
page: number;
|
||||||
sortBy: 'name' | 'createdAt' | 'signingVolume';
|
sortBy: 'name' | 'createdAt' | 'signingVolume';
|
||||||
sortOrder: 'asc' | 'desc';
|
sortOrder: 'asc' | 'desc';
|
||||||
|
dateRange: DateRange;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AdminLeaderboardTable = ({
|
export const AdminOrganisationOverviewTable = ({
|
||||||
signingVolume,
|
organisations,
|
||||||
totalPages,
|
totalPages,
|
||||||
perPage,
|
perPage,
|
||||||
page,
|
page,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
}: LeaderboardTableProps) => {
|
dateRange,
|
||||||
|
}: OrganisationOverviewTableProps) => {
|
||||||
const { _, i18n } = useLingui();
|
const { _, i18n } = useLingui();
|
||||||
|
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
@ -67,17 +76,16 @@ export const AdminLeaderboardTable = ({
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<a
|
<Link
|
||||||
className="text-primary underline"
|
className="hover:underline"
|
||||||
href={`https://dashboard.stripe.com/subscriptions/${row.original.planId}`}
|
to={`/admin/organisation-insights/${row.original.id}?dateRange=${dateRange}`}
|
||||||
target="_blank"
|
|
||||||
>
|
>
|
||||||
{row.getValue('name')}
|
{row.getValue('name')}
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
size: 250,
|
size: 240,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: () => (
|
header: () => (
|
||||||
@ -85,7 +93,9 @@ export const AdminLeaderboardTable = ({
|
|||||||
className="flex cursor-pointer items-center"
|
className="flex cursor-pointer items-center"
|
||||||
onClick={() => handleColumnSort('signingVolume')}
|
onClick={() => handleColumnSort('signingVolume')}
|
||||||
>
|
>
|
||||||
{_(msg`Signing Volume`)}
|
<span className="whitespace-nowrap">
|
||||||
|
<Trans>Document Volume</Trans>
|
||||||
|
</span>
|
||||||
{sortBy === 'signingVolume' ? (
|
{sortBy === 'signingVolume' ? (
|
||||||
sortOrder === 'asc' ? (
|
sortOrder === 'asc' ? (
|
||||||
<ChevronUpIcon className="ml-2 h-4 w-4" />
|
<ChevronUpIcon className="ml-2 h-4 w-4" />
|
||||||
@ -99,6 +109,23 @@ export const AdminLeaderboardTable = ({
|
|||||||
),
|
),
|
||||||
accessorKey: 'signingVolume',
|
accessorKey: 'signingVolume',
|
||||||
cell: ({ row }) => <div>{Number(row.getValue('signingVolume'))}</div>,
|
cell: ({ row }) => <div>{Number(row.getValue('signingVolume'))}</div>,
|
||||||
|
size: 160,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: () => {
|
||||||
|
return <Trans>Teams</Trans>;
|
||||||
|
},
|
||||||
|
accessorKey: 'teamCount',
|
||||||
|
cell: ({ row }) => <div>{Number(row.original.teamCount) || 0}</div>,
|
||||||
|
size: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: () => {
|
||||||
|
return <Trans>Members</Trans>;
|
||||||
|
},
|
||||||
|
accessorKey: 'memberCount',
|
||||||
|
cell: ({ row }) => <div>{Number(row.original.memberCount) || 0}</div>,
|
||||||
|
size: 160,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: () => {
|
header: () => {
|
||||||
@ -107,7 +134,9 @@ export const AdminLeaderboardTable = ({
|
|||||||
className="flex cursor-pointer items-center"
|
className="flex cursor-pointer items-center"
|
||||||
onClick={() => handleColumnSort('createdAt')}
|
onClick={() => handleColumnSort('createdAt')}
|
||||||
>
|
>
|
||||||
{_(msg`Created`)}
|
<span className="whitespace-nowrap">
|
||||||
|
<Trans>Created</Trans>
|
||||||
|
</span>
|
||||||
{sortBy === 'createdAt' ? (
|
{sortBy === 'createdAt' ? (
|
||||||
sortOrder === 'asc' ? (
|
sortOrder === 'asc' ? (
|
||||||
<ChevronUpIcon className="ml-2 h-4 w-4" />
|
<ChevronUpIcon className="ml-2 h-4 w-4" />
|
||||||
@ -121,10 +150,11 @@ export const AdminLeaderboardTable = ({
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
accessorKey: 'createdAt',
|
accessorKey: 'createdAt',
|
||||||
cell: ({ row }) => i18n.date(row.original.createdAt),
|
cell: ({ row }) => i18n.date(new Date(row.original.createdAt)),
|
||||||
|
size: 120,
|
||||||
},
|
},
|
||||||
] satisfies DataTableColumnDef<SigningVolume>[];
|
] satisfies DataTableColumnDef<OrganisationOverview>[];
|
||||||
}, [sortOrder, sortBy]);
|
}, [sortOrder, sortBy, dateRange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
@ -169,13 +199,13 @@ export const AdminLeaderboardTable = ({
|
|||||||
<Input
|
<Input
|
||||||
className="my-6 flex flex-row gap-4"
|
className="my-6 flex flex-row gap-4"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={_(msg`Search by name or email`)}
|
placeholder={_(msg`Search by organisation name`)}
|
||||||
value={searchString}
|
value={searchString}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
/>
|
/>
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={signingVolume}
|
data={organisations}
|
||||||
perPage={perPage}
|
perPage={perPage}
|
||||||
currentPage={page}
|
currentPage={page}
|
||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
@ -93,13 +93,31 @@ export const AdminOrganisationsTable = ({
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: t`Status`,
|
id: 'role',
|
||||||
|
header: t`Role`,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Badge variant="neutral">
|
<Badge variant="neutral">
|
||||||
{row.original.owner.id === memberUserId ? t`Owner` : t`Member`}
|
{row.original.owner.id === memberUserId ? t`Owner` : t`Member`}
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'billingStatus',
|
||||||
|
header: t`Status`,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const subscription = row.original.subscription;
|
||||||
|
const isPaid = subscription && subscription.status === 'ACTIVE';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
|
||||||
|
isPaid ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isPaid ? t`Paid` : t`Free`}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
header: t`Subscription`,
|
header: t`Subscription`,
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
@ -168,7 +186,7 @@ export const AdminOrganisationsTable = ({
|
|||||||
onPaginationChange={onPaginationChange}
|
onPaginationChange={onPaginationChange}
|
||||||
columnVisibility={{
|
columnVisibility={{
|
||||||
owner: showOwnerColumn,
|
owner: showOwnerColumn,
|
||||||
status: memberUserId !== undefined,
|
role: memberUserId !== undefined,
|
||||||
}}
|
}}
|
||||||
error={{
|
error={{
|
||||||
enable: isLoadingError,
|
enable: isLoadingError,
|
||||||
|
|||||||
287
apps/remix/app/components/tables/organisation-insights-table.tsx
Normal file
@ -0,0 +1,287 @@
|
|||||||
|
import { useTransition } from 'react';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Building2, Loader, TrendingUp, Users } from 'lucide-react';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
import { useNavigation } from 'react-router';
|
||||||
|
|
||||||
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
|
import type { OrganisationDetailedInsights } from '@documenso/lib/server-only/admin/get-organisation-detailed-insights';
|
||||||
|
import type { DateRange } from '@documenso/lib/types/search-params';
|
||||||
|
import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||||
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
|
|
||||||
|
import { DateRangeFilter } from '~/components/filters/date-range-filter';
|
||||||
|
import { DocumentStatus } from '~/components/general/document/document-status';
|
||||||
|
|
||||||
|
type OrganisationInsightsTableProps = {
|
||||||
|
insights: OrganisationDetailedInsights;
|
||||||
|
page: number;
|
||||||
|
perPage: number;
|
||||||
|
dateRange: DateRange;
|
||||||
|
view: 'teams' | 'users' | 'documents';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OrganisationInsightsTable = ({
|
||||||
|
insights,
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
dateRange,
|
||||||
|
view,
|
||||||
|
}: OrganisationInsightsTableProps) => {
|
||||||
|
const { _, i18n } = useLingui();
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
|
||||||
|
const isLoading = isPending || navigation.state === 'loading';
|
||||||
|
|
||||||
|
const onPaginationChange = (newPage: number, newPerPage: number) => {
|
||||||
|
startTransition(() => {
|
||||||
|
updateSearchParams({
|
||||||
|
page: newPage,
|
||||||
|
perPage: newPerPage,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewChange = (newView: 'teams' | 'users' | 'documents') => {
|
||||||
|
startTransition(() => {
|
||||||
|
updateSearchParams({
|
||||||
|
view: newView,
|
||||||
|
page: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const teamsColumns = [
|
||||||
|
{
|
||||||
|
header: _(msg`Team Name`),
|
||||||
|
accessorKey: 'name',
|
||||||
|
cell: ({ row }) => <span className="block max-w-full truncate">{row.getValue('name')}</span>,
|
||||||
|
size: 240,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: _(msg`Members`),
|
||||||
|
accessorKey: 'memberCount',
|
||||||
|
cell: ({ row }) => Number(row.getValue('memberCount')),
|
||||||
|
size: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: _(msg`Documents`),
|
||||||
|
accessorKey: 'documentCount',
|
||||||
|
cell: ({ row }) => Number(row.getValue('documentCount')),
|
||||||
|
size: 140,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: _(msg`Created`),
|
||||||
|
accessorKey: 'createdAt',
|
||||||
|
cell: ({ row }) => i18n.date(new Date(row.getValue('createdAt'))),
|
||||||
|
size: 160,
|
||||||
|
},
|
||||||
|
] satisfies DataTableColumnDef<(typeof insights.teams)[number]>[];
|
||||||
|
|
||||||
|
const usersColumns = [
|
||||||
|
{
|
||||||
|
header: () => <span className="whitespace-nowrap">{_(msg`Name`)}</span>,
|
||||||
|
accessorKey: 'name',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Link
|
||||||
|
className="block max-w-full truncate hover:underline"
|
||||||
|
to={`/admin/users/${row.original.id}`}
|
||||||
|
>
|
||||||
|
{(row.getValue('name') as string) || (row.getValue('email') as string)}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
size: 220,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: () => <span className="whitespace-nowrap">{_(msg`Email`)}</span>,
|
||||||
|
accessorKey: 'email',
|
||||||
|
cell: ({ row }) => <span className="block max-w-full truncate">{row.getValue('email')}</span>,
|
||||||
|
size: 260,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: () => <span className="whitespace-nowrap">{_(msg`Documents Created`)}</span>,
|
||||||
|
accessorKey: 'documentCount',
|
||||||
|
cell: ({ row }) => Number(row.getValue('documentCount')),
|
||||||
|
size: 180,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: () => <span className="whitespace-nowrap">{_(msg`Documents Completed`)}</span>,
|
||||||
|
accessorKey: 'signedDocumentCount',
|
||||||
|
cell: ({ row }) => Number(row.getValue('signedDocumentCount')),
|
||||||
|
size: 180,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: () => <span className="whitespace-nowrap">{_(msg`Joined`)}</span>,
|
||||||
|
accessorKey: 'createdAt',
|
||||||
|
cell: ({ row }) => i18n.date(new Date(row.getValue('createdAt'))),
|
||||||
|
size: 160,
|
||||||
|
},
|
||||||
|
] satisfies DataTableColumnDef<(typeof insights.users)[number]>[];
|
||||||
|
|
||||||
|
const documentsColumns = [
|
||||||
|
{
|
||||||
|
header: () => <span className="whitespace-nowrap">{_(msg`Title`)}</span>,
|
||||||
|
accessorKey: 'title',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Link
|
||||||
|
className="block max-w-[200px] truncate hover:underline"
|
||||||
|
to={`/admin/documents/${row.original.id}`}
|
||||||
|
title={row.getValue('title') as string}
|
||||||
|
>
|
||||||
|
{row.getValue('title')}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
size: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: () => <span className="whitespace-nowrap">{_(msg`Status`)}</span>,
|
||||||
|
accessorKey: 'status',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<DocumentStatus status={row.getValue('status') as ExtendedDocumentStatus} />
|
||||||
|
),
|
||||||
|
size: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: () => <span className="whitespace-nowrap">{_(msg`Team`)}</span>,
|
||||||
|
accessorKey: 'teamName',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="block max-w-[150px] truncate" title={row.getValue('teamName') as string}>
|
||||||
|
{row.getValue('teamName')}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
size: 150,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: () => <span className="whitespace-nowrap">{_(msg`Created`)}</span>,
|
||||||
|
accessorKey: 'createdAt',
|
||||||
|
cell: ({ row }) => i18n.date(new Date(row.getValue('createdAt'))),
|
||||||
|
size: 140,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: () => <span className="whitespace-nowrap">{_(msg`Completed`)}</span>,
|
||||||
|
accessorKey: 'completedAt',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const completedAt = row.getValue('completedAt') as Date | null;
|
||||||
|
|
||||||
|
return completedAt ? i18n.date(new Date(completedAt)) : '-';
|
||||||
|
},
|
||||||
|
size: 140,
|
||||||
|
},
|
||||||
|
] satisfies DataTableColumnDef<(typeof insights.documents)[number]>[];
|
||||||
|
|
||||||
|
const getCurrentData = (): unknown[] => {
|
||||||
|
switch (view) {
|
||||||
|
case 'teams':
|
||||||
|
return insights.teams;
|
||||||
|
case 'users':
|
||||||
|
return insights.users;
|
||||||
|
case 'documents':
|
||||||
|
return insights.documents;
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCurrentColumns = (): DataTableColumnDef<unknown>[] => {
|
||||||
|
switch (view) {
|
||||||
|
case 'teams':
|
||||||
|
return teamsColumns as unknown as DataTableColumnDef<unknown>[];
|
||||||
|
case 'users':
|
||||||
|
return usersColumns as unknown as DataTableColumnDef<unknown>[];
|
||||||
|
case 'documents':
|
||||||
|
return documentsColumns as unknown as DataTableColumnDef<unknown>[];
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
{insights.summary && (
|
||||||
|
<div className="mb-6 grid grid-cols-2 gap-4 md:grid-cols-3">
|
||||||
|
<SummaryCard icon={Building2} title={_(msg`Teams`)} value={insights.summary.totalTeams} />
|
||||||
|
<SummaryCard icon={Users} title={_(msg`Members`)} value={insights.summary.totalMembers} />
|
||||||
|
<SummaryCard
|
||||||
|
icon={TrendingUp}
|
||||||
|
title={_(msg`Documents Completed`)}
|
||||||
|
value={insights.summary.volumeThisPeriod}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant={view === 'teams' ? 'default' : 'outline'}
|
||||||
|
onClick={() => handleViewChange('teams')}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{_(msg`Teams`)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={view === 'users' ? 'default' : 'outline'}
|
||||||
|
onClick={() => handleViewChange('users')}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{_(msg`Users`)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={view === 'documents' ? 'default' : 'outline'}
|
||||||
|
onClick={() => handleViewChange('documents')}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{_(msg`Documents`)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<DateRangeFilter currentRange={dateRange} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={view === 'documents' ? 'overflow-hidden' : undefined}>
|
||||||
|
<DataTable<unknown, unknown>
|
||||||
|
columns={getCurrentColumns()}
|
||||||
|
data={getCurrentData()}
|
||||||
|
perPage={perPage}
|
||||||
|
currentPage={page}
|
||||||
|
totalPages={insights.totalPages}
|
||||||
|
onPaginationChange={onPaginationChange}
|
||||||
|
>
|
||||||
|
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
|
||||||
|
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SummaryCard = ({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
subtitle,
|
||||||
|
}: {
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
title: string;
|
||||||
|
value: number;
|
||||||
|
subtitle?: string;
|
||||||
|
}) => (
|
||||||
|
<div className="bg-card flex items-start gap-x-2 rounded-lg border px-4 py-3">
|
||||||
|
<Icon className="text-muted-foreground h-4 w-4 items-start" />
|
||||||
|
<div className="-mt-0.5 space-y-2">
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">{title}</p>
|
||||||
|
<p className="text-2xl font-bold">{value}</p>
|
||||||
|
{subtitle && <p className="text-muted-foreground text-xs">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@ -56,7 +56,14 @@ export const UserOrganisationsTable = () => {
|
|||||||
avatarFallback={row.original.name.slice(0, 1).toUpperCase()}
|
avatarFallback={row.original.name.slice(0, 1).toUpperCase()}
|
||||||
primaryText={
|
primaryText={
|
||||||
<span className="text-foreground/80 font-semibold">
|
<span className="text-foreground/80 font-semibold">
|
||||||
{isPersonalLayoutMode ? _(msg`Personal`) : row.original.name}
|
{isPersonalLayoutMode
|
||||||
|
? _(
|
||||||
|
msg({
|
||||||
|
message: `Personal`,
|
||||||
|
context: `Personal organisation (adjective)`,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: row.original.name}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
secondaryText={
|
secondaryText={
|
||||||
|
|||||||
@ -88,14 +88,12 @@ export default function Layout({ loaderData, params, matches }: Route.ComponentP
|
|||||||
? {
|
? {
|
||||||
heading: msg`Organisation not found`,
|
heading: msg`Organisation not found`,
|
||||||
subHeading: msg`404 Organisation not found`,
|
subHeading: msg`404 Organisation not found`,
|
||||||
message: msg`The organisation you are looking for may have been removed, renamed or may have never
|
message: msg`The organisation you are looking for may have been removed, renamed or may have never existed.`,
|
||||||
existed.`,
|
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
heading: msg`Team not found`,
|
heading: msg`Team not found`,
|
||||||
subHeading: msg`404 Team not found`,
|
subHeading: msg`404 Team not found`,
|
||||||
message: msg`The team you are looking for may have been removed, renamed or may have never
|
message: msg`The team you are looking for may have been removed, renamed or may have never existed.`,
|
||||||
existed.`,
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
primaryButton={
|
primaryButton={
|
||||||
|
|||||||
@ -114,13 +114,13 @@ export default function AdminLayout() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={cn(
|
className={cn(
|
||||||
'justify-start md:w-full',
|
'justify-start md:w-full',
|
||||||
pathname?.startsWith('/admin/leaderboard') && 'bg-secondary',
|
pathname?.startsWith('/admin/organisation-insights') && 'bg-secondary',
|
||||||
)}
|
)}
|
||||||
asChild
|
asChild
|
||||||
>
|
>
|
||||||
<Link to="/admin/leaderboard">
|
<Link to="/admin/organisation-insights">
|
||||||
<Trophy className="mr-2 h-5 w-5" />
|
<Trophy className="mr-2 h-5 w-5" />
|
||||||
<Trans>Leaderboard</Trans>
|
<Trans>Organisation Insights</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@ -128,7 +128,7 @@ export default function AdminLayout() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={cn(
|
className={cn(
|
||||||
'justify-start md:w-full',
|
'justify-start md:w-full',
|
||||||
pathname?.startsWith('/admin/banner') && 'bg-secondary',
|
pathname?.startsWith('/admin/site-settings') && 'bg-secondary',
|
||||||
)}
|
)}
|
||||||
asChild
|
asChild
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,77 +0,0 @@
|
|||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
|
|
||||||
import { getSigningVolume } from '@documenso/lib/server-only/admin/get-signing-volume';
|
|
||||||
|
|
||||||
import {
|
|
||||||
AdminLeaderboardTable,
|
|
||||||
type SigningVolume,
|
|
||||||
} from '~/components/tables/admin-leaderboard-table';
|
|
||||||
|
|
||||||
import type { Route } from './+types/leaderboard';
|
|
||||||
|
|
||||||
export async function loader({ request }: Route.LoaderArgs) {
|
|
||||||
const url = new URL(request.url);
|
|
||||||
|
|
||||||
const rawSortBy = url.searchParams.get('sortBy') || 'signingVolume';
|
|
||||||
const rawSortOrder = url.searchParams.get('sortOrder') || 'desc';
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
||||||
const sortOrder = (['asc', 'desc'].includes(rawSortOrder) ? rawSortOrder : 'desc') as
|
|
||||||
| 'asc'
|
|
||||||
| 'desc';
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
||||||
const sortBy = (
|
|
||||||
['name', 'createdAt', 'signingVolume'].includes(rawSortBy) ? rawSortBy : 'signingVolume'
|
|
||||||
) as 'name' | 'createdAt' | 'signingVolume';
|
|
||||||
|
|
||||||
const page = Number(url.searchParams.get('page')) || 1;
|
|
||||||
const perPage = Number(url.searchParams.get('perPage')) || 10;
|
|
||||||
const search = url.searchParams.get('search') || '';
|
|
||||||
|
|
||||||
const { leaderboard, totalPages } = await getSigningVolume({
|
|
||||||
search,
|
|
||||||
page,
|
|
||||||
perPage,
|
|
||||||
sortBy,
|
|
||||||
sortOrder,
|
|
||||||
});
|
|
||||||
|
|
||||||
const typedSigningVolume: SigningVolume[] = leaderboard.map((item) => ({
|
|
||||||
...item,
|
|
||||||
name: item.name || '',
|
|
||||||
createdAt: item.createdAt || new Date(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
signingVolume: typedSigningVolume,
|
|
||||||
totalPages,
|
|
||||||
page,
|
|
||||||
perPage,
|
|
||||||
sortBy,
|
|
||||||
sortOrder,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Leaderboard({ loaderData }: Route.ComponentProps) {
|
|
||||||
const { signingVolume, totalPages, page, perPage, sortBy, sortOrder } = loaderData;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<h2 className="text-4xl font-semibold">
|
|
||||||
<Trans>Signing Volume</Trans>
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div className="mt-8">
|
|
||||||
<AdminLeaderboardTable
|
|
||||||
signingVolume={signingVolume}
|
|
||||||
totalPages={totalPages}
|
|
||||||
page={page}
|
|
||||||
perPage={perPage}
|
|
||||||
sortBy={sortBy}
|
|
||||||
sortOrder={sortOrder}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
import { getOrganisationDetailedInsights } from '@documenso/lib/server-only/admin/get-organisation-detailed-insights';
|
||||||
|
import type { DateRange } from '@documenso/lib/types/search-params';
|
||||||
|
import { getAdminOrganisation } from '@documenso/trpc/server/admin-router/get-admin-organisation';
|
||||||
|
|
||||||
|
import { OrganisationInsightsTable } from '~/components/tables/organisation-insights-table';
|
||||||
|
|
||||||
|
import type { Route } from './+types/organisation-insights.$id';
|
||||||
|
|
||||||
|
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||||
|
const { id } = params;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
const page = Number(url.searchParams.get('page')) || 1;
|
||||||
|
const perPage = Number(url.searchParams.get('perPage')) || 10;
|
||||||
|
const dateRange = (url.searchParams.get('dateRange') || 'last30days') as DateRange;
|
||||||
|
const view = (url.searchParams.get('view') || 'teams') as 'teams' | 'users' | 'documents';
|
||||||
|
|
||||||
|
const [insights, organisation] = await Promise.all([
|
||||||
|
getOrganisationDetailedInsights({
|
||||||
|
organisationId: id,
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
dateRange,
|
||||||
|
view,
|
||||||
|
}),
|
||||||
|
getAdminOrganisation({ organisationId: id }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
organisationId: id,
|
||||||
|
organisationName: organisation.name,
|
||||||
|
insights,
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
dateRange,
|
||||||
|
view,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OrganisationInsights({ loaderData }: Route.ComponentProps) {
|
||||||
|
const { insights, page, perPage, dateRange, view, organisationName } = loaderData;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-4xl font-semibold">{organisationName}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="mt-8">
|
||||||
|
<OrganisationInsightsTable
|
||||||
|
insights={insights}
|
||||||
|
page={page}
|
||||||
|
perPage={perPage}
|
||||||
|
dateRange={dateRange}
|
||||||
|
view={view}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,91 @@
|
|||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
|
||||||
|
import { getOrganisationInsights } from '@documenso/lib/server-only/admin/get-signing-volume';
|
||||||
|
import type { DateRange } from '@documenso/lib/types/search-params';
|
||||||
|
|
||||||
|
import { DateRangeFilter } from '~/components/filters/date-range-filter';
|
||||||
|
import {
|
||||||
|
AdminOrganisationOverviewTable,
|
||||||
|
type OrganisationOverview,
|
||||||
|
} from '~/components/tables/admin-organisation-overview-table';
|
||||||
|
|
||||||
|
import type { Route } from './+types/organisation-insights._index';
|
||||||
|
|
||||||
|
export async function loader({ request }: Route.LoaderArgs) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
const rawSortBy = url.searchParams.get('sortBy') || 'signingVolume';
|
||||||
|
const rawSortOrder = url.searchParams.get('sortOrder') || 'desc';
|
||||||
|
|
||||||
|
const isSortOrder = (value: string): value is 'asc' | 'desc' =>
|
||||||
|
value === 'asc' || value === 'desc';
|
||||||
|
const isSortBy = (value: string): value is 'name' | 'createdAt' | 'signingVolume' =>
|
||||||
|
value === 'name' || value === 'createdAt' || value === 'signingVolume';
|
||||||
|
|
||||||
|
const sortOrder: 'asc' | 'desc' = isSortOrder(rawSortOrder) ? rawSortOrder : 'desc';
|
||||||
|
const sortBy: 'name' | 'createdAt' | 'signingVolume' = isSortBy(rawSortBy)
|
||||||
|
? rawSortBy
|
||||||
|
: 'signingVolume';
|
||||||
|
|
||||||
|
const page = Number(url.searchParams.get('page')) || 1;
|
||||||
|
const perPage = Number(url.searchParams.get('perPage')) || 10;
|
||||||
|
const search = url.searchParams.get('search') || '';
|
||||||
|
const dateRange = (url.searchParams.get('dateRange') || 'last30days') as DateRange;
|
||||||
|
|
||||||
|
const { organisations, totalPages } = await getOrganisationInsights({
|
||||||
|
search,
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
dateRange,
|
||||||
|
});
|
||||||
|
|
||||||
|
const typedOrganisations: OrganisationOverview[] = organisations.map((item) => ({
|
||||||
|
id: String(item.id),
|
||||||
|
name: item.name || '',
|
||||||
|
signingVolume: item.signingVolume,
|
||||||
|
createdAt: item.createdAt || new Date(),
|
||||||
|
customerId: item.customerId || '',
|
||||||
|
subscriptionStatus: item.subscriptionStatus,
|
||||||
|
teamCount: item.teamCount || 0,
|
||||||
|
memberCount: item.memberCount || 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
organisations: typedOrganisations,
|
||||||
|
totalPages,
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
dateRange,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Organisations({ loaderData }: Route.ComponentProps) {
|
||||||
|
const { organisations, totalPages, page, perPage, sortBy, sortOrder, dateRange } = loaderData;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-4xl font-semibold">
|
||||||
|
<Trans>Organisation Insights</Trans>
|
||||||
|
</h2>
|
||||||
|
<DateRangeFilter currentRange={dateRange} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<AdminOrganisationOverviewTable
|
||||||
|
organisations={organisations}
|
||||||
|
totalPages={totalPages}
|
||||||
|
page={page}
|
||||||
|
perPage={perPage}
|
||||||
|
sortBy={sortBy}
|
||||||
|
sortOrder={sortOrder}
|
||||||
|
dateRange={dateRange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -142,8 +142,7 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
|
|||||||
404: {
|
404: {
|
||||||
heading: msg`Organisation not found`,
|
heading: msg`Organisation not found`,
|
||||||
subHeading: msg`404 Organisation not found`,
|
subHeading: msg`404 Organisation not found`,
|
||||||
message: msg`The organisation you are looking for may have been removed, renamed or may have never
|
message: msg`The organisation you are looking for may have been removed, renamed or may have never existed.`,
|
||||||
existed.`,
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
primaryButton={
|
primaryButton={
|
||||||
|
|||||||
@ -59,8 +59,7 @@ export default function UserPage({ params }: { params: { id: number } }) {
|
|||||||
404: {
|
404: {
|
||||||
heading: msg`User not found`,
|
heading: msg`User not found`,
|
||||||
subHeading: msg`404 User not found`,
|
subHeading: msg`404 User not found`,
|
||||||
message: msg`The user you are looking for may have been removed, renamed or may have never
|
message: msg`The user you are looking for may have been removed, renamed or may have never existed.`,
|
||||||
existed.`,
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
primaryButton={
|
primaryButton={
|
||||||
|
|||||||
@ -117,8 +117,7 @@ export default function OrganisationEmailDomainSettingsPage({ params }: Route.Co
|
|||||||
404: {
|
404: {
|
||||||
heading: msg`Email domain not found`,
|
heading: msg`Email domain not found`,
|
||||||
subHeading: msg`404 Email domain not found`,
|
subHeading: msg`404 Email domain not found`,
|
||||||
message: msg`The email domain you are looking for may have been removed, renamed or may have never
|
message: msg`The email domain you are looking for may have been removed, renamed or may have never existed.`,
|
||||||
existed.`,
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
primaryButton={
|
primaryButton={
|
||||||
|
|||||||
@ -89,8 +89,7 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
|
|||||||
404: {
|
404: {
|
||||||
heading: msg`Organisation group not found`,
|
heading: msg`Organisation group not found`,
|
||||||
subHeading: msg`404 Organisation group not found`,
|
subHeading: msg`404 Organisation group not found`,
|
||||||
message: msg`The organisation group you are looking for may have been removed, renamed or may have never
|
message: msg`The organisation group you are looking for may have been removed, renamed or may have never existed.`,
|
||||||
existed.`,
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
primaryButton={
|
primaryButton={
|
||||||
|
|||||||
@ -60,8 +60,7 @@ export default function Layout() {
|
|||||||
404: {
|
404: {
|
||||||
heading: msg`Team not found`,
|
heading: msg`Team not found`,
|
||||||
subHeading: msg`404 Team not found`,
|
subHeading: msg`404 Team not found`,
|
||||||
message: msg`The team you are looking for may have been removed, renamed or may have never
|
message: msg`The team you are looking for may have been removed, renamed or may have never existed.`,
|
||||||
existed.`,
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
primaryButton={
|
primaryButton={
|
||||||
|
|||||||
@ -71,8 +71,7 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
|
|||||||
404: {
|
404: {
|
||||||
heading: msg`Not found`,
|
heading: msg`Not found`,
|
||||||
subHeading: msg`404 Not found`,
|
subHeading: msg`404 Not found`,
|
||||||
message: msg`The document you are looking for may have been removed, renamed or may have never
|
message: msg`The document you are looking for may have been removed, renamed or may have never existed.`,
|
||||||
existed.`,
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
primaryButton={
|
primaryButton={
|
||||||
@ -127,7 +126,11 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
|
|||||||
position="bottom"
|
position="bottom"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
<Trans>{envelope.recipients.length} Recipient(s)</Trans>
|
<Plural
|
||||||
|
value={envelope.recipients.length}
|
||||||
|
one="# Recipient"
|
||||||
|
other="# Recipients"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
</StackAvatarsWithTooltip>
|
</StackAvatarsWithTooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -82,8 +82,7 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
|
|||||||
404: {
|
404: {
|
||||||
heading: msg`Not found`,
|
heading: msg`Not found`,
|
||||||
subHeading: msg`404 Not found`,
|
subHeading: msg`404 Not found`,
|
||||||
message: msg`The document you are looking for may have been removed, renamed or may have never
|
message: msg`The document you are looking for may have been removed, renamed or may have never existed.`,
|
||||||
existed.`,
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
primaryButton={
|
primaryButton={
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { EnvelopeType } from '@prisma/client';
|
||||||
import { FolderType, OrganisationType } from '@prisma/client';
|
import { FolderType, OrganisationType } from '@prisma/client';
|
||||||
import { useParams, useSearchParams } from 'react-router';
|
import { useParams, useSearchParams } from 'react-router';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
@ -18,9 +19,9 @@ import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/av
|
|||||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||||
|
|
||||||
import { DocumentMoveToFolderDialog } from '~/components/dialogs/document-move-to-folder-dialog';
|
import { DocumentMoveToFolderDialog } from '~/components/dialogs/document-move-to-folder-dialog';
|
||||||
import { DocumentDropZoneWrapper } from '~/components/general/document/document-drop-zone-wrapper';
|
|
||||||
import { DocumentSearch } from '~/components/general/document/document-search';
|
import { DocumentSearch } from '~/components/general/document/document-search';
|
||||||
import { DocumentStatus } from '~/components/general/document/document-status';
|
import { DocumentStatus } from '~/components/general/document/document-status';
|
||||||
|
import { EnvelopeDropZoneWrapper } from '~/components/general/envelope/envelope-drop-zone-wrapper';
|
||||||
import { FolderGrid } from '~/components/general/folder/folder-grid';
|
import { FolderGrid } from '~/components/general/folder/folder-grid';
|
||||||
import { PeriodSelector } from '~/components/general/period-selector';
|
import { PeriodSelector } from '~/components/general/period-selector';
|
||||||
import { DocumentsTable } from '~/components/tables/documents-table';
|
import { DocumentsTable } from '~/components/tables/documents-table';
|
||||||
@ -108,9 +109,8 @@ export default function DocumentsPage() {
|
|||||||
}
|
}
|
||||||
}, [data?.stats]);
|
}, [data?.stats]);
|
||||||
|
|
||||||
// Todo: Envelopes - Change the dropzone wrapper to create to V2 documents after we're ready.
|
|
||||||
return (
|
return (
|
||||||
<DocumentDropZoneWrapper>
|
<EnvelopeDropZoneWrapper type={EnvelopeType.DOCUMENT}>
|
||||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||||
<FolderGrid type={FolderType.DOCUMENT} parentId={folderId ?? null} />
|
<FolderGrid type={FolderType.DOCUMENT} parentId={folderId ?? null} />
|
||||||
|
|
||||||
@ -210,6 +210,6 @@ export default function DocumentsPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DocumentDropZoneWrapper>
|
</EnvelopeDropZoneWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -109,8 +109,7 @@ export default function WebhookPage({ params }: Route.ComponentProps) {
|
|||||||
404: {
|
404: {
|
||||||
heading: msg`Webhook not found`,
|
heading: msg`Webhook not found`,
|
||||||
subHeading: msg`404 Webhook not found`,
|
subHeading: msg`404 Webhook not found`,
|
||||||
message: msg`The webhook you are looking for may have been removed, renamed or may have never
|
message: msg`The webhook you are looking for may have been removed, renamed or may have never existed.`,
|
||||||
existed.`,
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
primaryButton={
|
primaryButton={
|
||||||
|
|||||||
@ -66,8 +66,7 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
|
|||||||
404: {
|
404: {
|
||||||
heading: msg`Not found`,
|
heading: msg`Not found`,
|
||||||
subHeading: msg`404 Not found`,
|
subHeading: msg`404 Not found`,
|
||||||
message: msg`The template you are looking for may have been removed, renamed or may have never
|
message: msg`The template you are looking for may have been removed, renamed or may have never existed.`,
|
||||||
existed.`,
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
primaryButton={
|
primaryButton={
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { EnvelopeType } from '@prisma/client';
|
||||||
import { Bird } from 'lucide-react';
|
import { Bird } from 'lucide-react';
|
||||||
import { useParams, useSearchParams } from 'react-router';
|
import { useParams, useSearchParams } from 'react-router';
|
||||||
|
|
||||||
@ -8,8 +9,8 @@ import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/t
|
|||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||||
|
|
||||||
|
import { EnvelopeDropZoneWrapper } from '~/components/general/envelope/envelope-drop-zone-wrapper';
|
||||||
import { FolderGrid } from '~/components/general/folder/folder-grid';
|
import { FolderGrid } from '~/components/general/folder/folder-grid';
|
||||||
import { TemplateDropZoneWrapper } from '~/components/general/template/template-drop-zone-wrapper';
|
|
||||||
import { TemplatesTable } from '~/components/tables/templates-table';
|
import { TemplatesTable } from '~/components/tables/templates-table';
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
import { appMetaTags } from '~/utils/meta';
|
import { appMetaTags } from '~/utils/meta';
|
||||||
@ -37,7 +38,7 @@ export default function TemplatesPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TemplateDropZoneWrapper>
|
<EnvelopeDropZoneWrapper type={EnvelopeType.TEMPLATE}>
|
||||||
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
|
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
|
||||||
<FolderGrid type={FolderType.TEMPLATE} parentId={folderId ?? null} />
|
<FolderGrid type={FolderType.TEMPLATE} parentId={folderId ?? null} />
|
||||||
|
|
||||||
@ -85,6 +86,6 @@ export default function TemplatesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TemplateDropZoneWrapper>
|
</EnvelopeDropZoneWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import { SignFieldNumberDialog } from '~/components/dialogs/sign-field-number-di
|
|||||||
|
|
||||||
type HandleNumberFieldClickOptions = {
|
type HandleNumberFieldClickOptions = {
|
||||||
field: TFieldNumber;
|
field: TFieldNumber;
|
||||||
number: number | null;
|
number: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleNumberFieldClick = async (
|
export const handleNumberFieldClick = async (
|
||||||
|
|||||||
@ -41,6 +41,7 @@
|
|||||||
"@simplewebauthn/server": "^9.0.3",
|
"@simplewebauthn/server": "^9.0.3",
|
||||||
"autoprefixer": "^10.4.13",
|
"autoprefixer": "^10.4.13",
|
||||||
"colord": "^2.9.3",
|
"colord": "^2.9.3",
|
||||||
|
"content-disposition": "^0.5.4",
|
||||||
"framer-motion": "^10.12.8",
|
"framer-motion": "^10.12.8",
|
||||||
"hono": "4.7.0",
|
"hono": "4.7.0",
|
||||||
"hono-rate-limiter": "^0.4.2",
|
"hono-rate-limiter": "^0.4.2",
|
||||||
@ -87,6 +88,7 @@
|
|||||||
"@rollup/plugin-node-resolve": "^16.0.0",
|
"@rollup/plugin-node-resolve": "^16.0.0",
|
||||||
"@rollup/plugin-typescript": "^12.1.2",
|
"@rollup/plugin-typescript": "^12.1.2",
|
||||||
"@simplewebauthn/types": "^9.0.1",
|
"@simplewebauthn/types": "^9.0.1",
|
||||||
|
"@types/content-disposition": "^0.5.9",
|
||||||
"@types/formidable": "^2.0.6",
|
"@types/formidable": "^2.0.6",
|
||||||
"@types/luxon": "^3.3.1",
|
"@types/luxon": "^3.3.1",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
@ -104,5 +106,5 @@
|
|||||||
"vite-plugin-babel-macros": "^1.0.6",
|
"vite-plugin-babel-macros": "^1.0.6",
|
||||||
"vite-tsconfig-paths": "^5.1.4"
|
"vite-tsconfig-paths": "^5.1.4"
|
||||||
},
|
},
|
||||||
"version": "2.0.0"
|
"version": "2.0.10"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,7 +21,7 @@ export const downloadRoute = new Hono<HonoEnv>()
|
|||||||
* Requires API key authentication via Authorization header.
|
* Requires API key authentication via Authorization header.
|
||||||
*/
|
*/
|
||||||
.get(
|
.get(
|
||||||
'/envelopeItem/:envelopeItemId/download',
|
'/envelope/item/:envelopeItemId/download',
|
||||||
sValidator('param', ZDownloadEnvelopeItemRequestParamsSchema),
|
sValidator('param', ZDownloadEnvelopeItemRequestParamsSchema),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const logger = c.get('logger');
|
const logger = c.get('logger');
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { type DocumentDataType, DocumentStatus } from '@prisma/client';
|
import { type DocumentDataType, DocumentStatus } from '@prisma/client';
|
||||||
|
import contentDisposition from 'content-disposition';
|
||||||
import { type Context } from 'hono';
|
import { type Context } from 'hono';
|
||||||
|
|
||||||
import { sha256 } from '@documenso/lib/universal/crypto';
|
import { sha256 } from '@documenso/lib/universal/crypto';
|
||||||
@ -34,7 +35,7 @@ export const handleEnvelopeItemFileRequest = async ({
|
|||||||
|
|
||||||
const etag = Buffer.from(sha256(documentDataToUse)).toString('hex');
|
const etag = Buffer.from(sha256(documentDataToUse)).toString('hex');
|
||||||
|
|
||||||
if (c.req.header('If-None-Match') === etag) {
|
if (c.req.header('If-None-Match') === etag && !isDownload) {
|
||||||
return c.body(null, 304);
|
return c.body(null, 304);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -58,7 +59,6 @@ export const handleEnvelopeItemFileRequest = async ({
|
|||||||
if (status === DocumentStatus.COMPLETED) {
|
if (status === DocumentStatus.COMPLETED) {
|
||||||
c.header('Cache-Control', 'public, max-age=31536000, immutable');
|
c.header('Cache-Control', 'public, max-age=31536000, immutable');
|
||||||
} else {
|
} else {
|
||||||
// Set a tiny 1 minute cache, with must-revalidate to ensure the client always checks for updates.
|
|
||||||
c.header('Cache-Control', 'public, max-age=0, must-revalidate');
|
c.header('Cache-Control', 'public, max-age=0, must-revalidate');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -69,7 +69,7 @@ export const handleEnvelopeItemFileRequest = async ({
|
|||||||
const suffix = version === 'signed' ? '_signed.pdf' : '.pdf';
|
const suffix = version === 'signed' ? '_signed.pdf' : '.pdf';
|
||||||
const filename = `${baseTitle}${suffix}`;
|
const filename = `${baseTitle}${suffix}`;
|
||||||
|
|
||||||
c.header('Content-Disposition', `attachment; filename="${filename}"`);
|
c.header('Content-Disposition', contentDisposition(filename));
|
||||||
|
|
||||||
// For downloads, prevent caching to ensure fresh data
|
// For downloads, prevent caching to ensure fresh data
|
||||||
c.header('Cache-Control', 'no-cache, no-store, must-revalidate');
|
c.header('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||||
|
|||||||
@ -2,12 +2,19 @@ import { lingui } from '@lingui/vite-plugin';
|
|||||||
import { reactRouter } from '@react-router/dev/vite';
|
import { reactRouter } from '@react-router/dev/vite';
|
||||||
import autoprefixer from 'autoprefixer';
|
import autoprefixer from 'autoprefixer';
|
||||||
import serverAdapter from 'hono-react-router-adapter/vite';
|
import serverAdapter from 'hono-react-router-adapter/vite';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import tailwindcss from 'tailwindcss';
|
import tailwindcss from 'tailwindcss';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig, normalizePath } from 'vite';
|
||||||
import macrosPlugin from 'vite-plugin-babel-macros';
|
import macrosPlugin from 'vite-plugin-babel-macros';
|
||||||
|
import { viteStaticCopy } from 'vite-plugin-static-copy';
|
||||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
|
const pdfjsDistPath = path.dirname(require.resolve('pdfjs-dist/package.json'));
|
||||||
|
const cMapsDir = normalizePath(path.join(pdfjsDistPath, 'cmaps'));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Note: We load the env variables externally so we can have runtime enviroment variables
|
* Note: We load the env variables externally so we can have runtime enviroment variables
|
||||||
* for docker.
|
* for docker.
|
||||||
@ -25,6 +32,14 @@ export default defineConfig({
|
|||||||
strictPort: true,
|
strictPort: true,
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
|
viteStaticCopy({
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
src: cMapsDir,
|
||||||
|
dest: 'static',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
reactRouter(),
|
reactRouter(),
|
||||||
macrosPlugin(),
|
macrosPlugin(),
|
||||||
lingui(),
|
lingui(),
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
FROM node:22-alpine3.20 AS base
|
FROM node:22-alpine3.20 AS base
|
||||||
|
|
||||||
RUN apk add --no-cache openssl
|
RUN apk add --no-cache openssl
|
||||||
|
RUN apk add --no-cache font-freefont
|
||||||
|
|
||||||
|
|
||||||
###########################
|
###########################
|
||||||
@ -114,4 +115,4 @@ COPY --chown=nodejs:nodejs ./docker/start.sh /app/apps/remix/start.sh
|
|||||||
|
|
||||||
WORKDIR /app/apps/remix
|
WORKDIR /app/apps/remix
|
||||||
|
|
||||||
CMD ["sh", "start.sh"]
|
CMD ["sh", "start.sh"]
|
||||||
|
|||||||
124
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@documenso/root",
|
"name": "@documenso/root",
|
||||||
"version": "2.0.0",
|
"version": "2.0.10",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@documenso/root",
|
"name": "@documenso/root",
|
||||||
"version": "2.0.0",
|
"version": "2.0.10",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"apps/*",
|
"apps/*",
|
||||||
"packages/*"
|
"packages/*"
|
||||||
@ -19,7 +19,6 @@
|
|||||||
"inngest-cli": "^0.29.1",
|
"inngest-cli": "^0.29.1",
|
||||||
"luxon": "^3.5.0",
|
"luxon": "^3.5.0",
|
||||||
"mupdf": "^1.0.0",
|
"mupdf": "^1.0.0",
|
||||||
"pdf2json": "^4.0.0",
|
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"typescript": "5.6.2",
|
"typescript": "5.6.2",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
@ -53,6 +52,7 @@
|
|||||||
"trpc-to-openapi": "2.4.0",
|
"trpc-to-openapi": "2.4.0",
|
||||||
"turbo": "^1.9.3",
|
"turbo": "^1.9.3",
|
||||||
"vite": "^6.3.5",
|
"vite": "^6.3.5",
|
||||||
|
"vite-plugin-static-copy": "^3.1.4",
|
||||||
"zod-openapi": "^4.2.4",
|
"zod-openapi": "^4.2.4",
|
||||||
"zod-prisma-types": "3.3.5"
|
"zod-prisma-types": "3.3.5"
|
||||||
},
|
},
|
||||||
@ -101,7 +101,7 @@
|
|||||||
},
|
},
|
||||||
"apps/remix": {
|
"apps/remix": {
|
||||||
"name": "@documenso/remix",
|
"name": "@documenso/remix",
|
||||||
"version": "2.0.0",
|
"version": "2.0.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cantoo/pdf-lib": "^2.5.2",
|
"@cantoo/pdf-lib": "^2.5.2",
|
||||||
"@documenso/api": "*",
|
"@documenso/api": "*",
|
||||||
@ -130,6 +130,7 @@
|
|||||||
"@simplewebauthn/server": "^9.0.3",
|
"@simplewebauthn/server": "^9.0.3",
|
||||||
"autoprefixer": "^10.4.13",
|
"autoprefixer": "^10.4.13",
|
||||||
"colord": "^2.9.3",
|
"colord": "^2.9.3",
|
||||||
|
"content-disposition": "^0.5.4",
|
||||||
"framer-motion": "^10.12.8",
|
"framer-motion": "^10.12.8",
|
||||||
"hono": "4.7.0",
|
"hono": "4.7.0",
|
||||||
"hono-rate-limiter": "^0.4.2",
|
"hono-rate-limiter": "^0.4.2",
|
||||||
@ -176,6 +177,7 @@
|
|||||||
"@rollup/plugin-node-resolve": "^16.0.0",
|
"@rollup/plugin-node-resolve": "^16.0.0",
|
||||||
"@rollup/plugin-typescript": "^12.1.2",
|
"@rollup/plugin-typescript": "^12.1.2",
|
||||||
"@simplewebauthn/types": "^9.0.1",
|
"@simplewebauthn/types": "^9.0.1",
|
||||||
|
"@types/content-disposition": "^0.5.9",
|
||||||
"@types/formidable": "^2.0.6",
|
"@types/formidable": "^2.0.6",
|
||||||
"@types/luxon": "^3.3.1",
|
"@types/luxon": "^3.3.1",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
@ -12316,6 +12318,13 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/content-disposition": {
|
||||||
|
"version": "0.5.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/content-disposition/-/content-disposition-0.5.9.tgz",
|
||||||
|
"integrity": "sha512-8uYXI3Gw35MhiVYhG3s295oihrxRyytcRHjSjqnqZVDDy/xcGBRny7+Xj1Wgfhv5QzRtN2hB2dVRBUX9XW3UcQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/cross-spawn": {
|
"node_modules/@types/cross-spawn": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/cross-spawn/-/cross-spawn-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/cross-spawn/-/cross-spawn-6.0.2.tgz",
|
||||||
@ -19104,10 +19113,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fdir": {
|
"node_modules/fdir": {
|
||||||
"version": "6.4.4",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
"integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==",
|
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"picomatch": "^3 || ^4"
|
"picomatch": "^3 || ^4"
|
||||||
},
|
},
|
||||||
@ -27489,18 +27501,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/pdf2json": {
|
|
||||||
"version": "4.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/pdf2json/-/pdf2json-4.0.0.tgz",
|
|
||||||
"integrity": "sha512-WkezNsLK8sGpuFC7+PPP0DsXROwdoOxmXPBTtUWWkCwCi/Vi97MRC52Ly6FWIJjOKIywpm/L2oaUgSrmtU+7ZQ==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"bin": {
|
|
||||||
"pdf2json": "bin/pdf2json.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.18.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/pdfjs-dist": {
|
"node_modules/pdfjs-dist": {
|
||||||
"version": "3.11.174",
|
"version": "3.11.174",
|
||||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.11.174.tgz",
|
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.11.174.tgz",
|
||||||
@ -33483,13 +33483,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.13",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
"integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==",
|
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fdir": "^6.4.4",
|
"fdir": "^6.5.0",
|
||||||
"picomatch": "^4.0.2"
|
"picomatch": "^4.0.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
@ -33499,9 +33499,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@ -35948,6 +35948,76 @@
|
|||||||
"vite": ">=2"
|
"vite": ">=2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vite-plugin-static-copy": {
|
||||||
|
"version": "3.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-3.1.4.tgz",
|
||||||
|
"integrity": "sha512-iCmr4GSw4eSnaB+G8zc2f4dxSuDjbkjwpuBLLGvQYR9IW7rnDzftnUjOH5p4RYR+d4GsiBqXRvzuFhs5bnzVyw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"chokidar": "^3.6.0",
|
||||||
|
"p-map": "^7.0.3",
|
||||||
|
"picocolors": "^1.1.1",
|
||||||
|
"tinyglobby": "^0.2.15"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.0.0 || >=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite-plugin-static-copy/node_modules/chokidar": {
|
||||||
|
"version": "3.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||||
|
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"anymatch": "~3.1.2",
|
||||||
|
"braces": "~3.0.2",
|
||||||
|
"glob-parent": "~5.1.2",
|
||||||
|
"is-binary-path": "~2.1.0",
|
||||||
|
"is-glob": "~4.0.1",
|
||||||
|
"normalize-path": "~3.0.0",
|
||||||
|
"readdirp": "~3.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8.10.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "~2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite-plugin-static-copy/node_modules/p-map": {
|
||||||
|
"version": "7.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz",
|
||||||
|
"integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite-plugin-static-copy/node_modules/readdirp": {
|
||||||
|
"version": "3.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||||
|
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"picomatch": "^2.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite-tsconfig-paths": {
|
"node_modules/vite-tsconfig-paths": {
|
||||||
"version": "5.1.4",
|
"version": "5.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-5.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-5.1.4.tgz",
|
||||||
|
|||||||
22
package.json
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.0.0",
|
"version": "2.0.10",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"dev": "turbo run dev --filter=@documenso/remix",
|
"dev": "turbo run dev --filter=@documenso/remix",
|
||||||
@ -45,6 +45,12 @@
|
|||||||
"@commitlint/config-conventional": "^17.7.0",
|
"@commitlint/config-conventional": "^17.7.0",
|
||||||
"@lingui/cli": "^5.2.0",
|
"@lingui/cli": "^5.2.0",
|
||||||
"@prisma/client": "^6.18.0",
|
"@prisma/client": "^6.18.0",
|
||||||
|
"@trpc/client": "11.7.0",
|
||||||
|
"@trpc/react-query": "11.7.0",
|
||||||
|
"@trpc/server": "11.7.0",
|
||||||
|
"@ts-rest/core": "^3.52.1",
|
||||||
|
"@ts-rest/open-api": "^3.52.1",
|
||||||
|
"@ts-rest/serverless": "^3.52.1",
|
||||||
"dotenv": "^16.5.0",
|
"dotenv": "^16.5.0",
|
||||||
"dotenv-cli": "^8.0.0",
|
"dotenv-cli": "^8.0.0",
|
||||||
"eslint": "^8.40.0",
|
"eslint": "^8.40.0",
|
||||||
@ -59,18 +65,13 @@
|
|||||||
"prisma-json-types-generator": "^3.6.2",
|
"prisma-json-types-generator": "^3.6.2",
|
||||||
"prisma-kysely": "^1.8.0",
|
"prisma-kysely": "^1.8.0",
|
||||||
"rimraf": "^5.0.1",
|
"rimraf": "^5.0.1",
|
||||||
"turbo": "^1.9.3",
|
|
||||||
"@trpc/client": "11.7.0",
|
|
||||||
"@trpc/react-query": "11.7.0",
|
|
||||||
"@trpc/server": "11.7.0",
|
|
||||||
"superjson": "^2.2.5",
|
"superjson": "^2.2.5",
|
||||||
"trpc-to-openapi": "2.4.0",
|
"trpc-to-openapi": "2.4.0",
|
||||||
|
"turbo": "^1.9.3",
|
||||||
|
"vite": "^6.3.5",
|
||||||
|
"vite-plugin-static-copy": "^3.1.4",
|
||||||
"zod-openapi": "^4.2.4",
|
"zod-openapi": "^4.2.4",
|
||||||
"@ts-rest/core": "^3.52.1",
|
"zod-prisma-types": "3.3.5"
|
||||||
"@ts-rest/open-api": "^3.52.1",
|
|
||||||
"@ts-rest/serverless": "^3.52.1",
|
|
||||||
"zod-prisma-types": "3.3.5",
|
|
||||||
"vite": "^6.3.5"
|
|
||||||
},
|
},
|
||||||
"name": "@documenso/root",
|
"name": "@documenso/root",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@ -85,7 +86,6 @@
|
|||||||
"inngest-cli": "^0.29.1",
|
"inngest-cli": "^0.29.1",
|
||||||
"luxon": "^3.5.0",
|
"luxon": "^3.5.0",
|
||||||
"mupdf": "^1.0.0",
|
"mupdf": "^1.0.0",
|
||||||
"pdf2json": "^4.0.0",
|
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"typescript": "5.6.2",
|
"typescript": "5.6.2",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import { FieldType } from '@prisma/client';
|
import { FieldType } from '@prisma/client';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
import type { TFieldAndMeta } from '@documenso/lib/types/field-meta';
|
import type { TFieldAndMeta } from '@documenso/lib/types/field-meta';
|
||||||
import { toCheckboxCustomText } from '@documenso/lib/utils/fields';
|
import { toCheckboxCustomText } from '@documenso/lib/utils/fields';
|
||||||
@ -13,11 +15,66 @@ export type FieldTestData = TFieldAndMeta & {
|
|||||||
signature?: string;
|
signature?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const columnWidth = 19.125;
|
export const signatureBase64Demo = `data:image/png;base64,${fs.readFileSync(
|
||||||
const rowHeight = 6.7;
|
path.join(__dirname, '../../../packages/assets/', 'logo_icon.png'),
|
||||||
|
'base64',
|
||||||
|
)}`;
|
||||||
|
|
||||||
const alignmentGridStartX = 31;
|
const columnWidth = 19.125;
|
||||||
const alignmentGridStartY = 19.02;
|
const fullColumnWidth = 57.37499999999998;
|
||||||
|
const rowHeight = 6.7;
|
||||||
|
const rowPadding = 0;
|
||||||
|
|
||||||
|
const calculatePositionPageOne = (
|
||||||
|
row: number,
|
||||||
|
column: number,
|
||||||
|
width: 'full' | 'column' = 'column',
|
||||||
|
) => {
|
||||||
|
const alignmentGridStartX = 31;
|
||||||
|
const alignmentGridStartY = 19;
|
||||||
|
|
||||||
|
return {
|
||||||
|
height: rowHeight,
|
||||||
|
width: width === 'full' ? fullColumnWidth : columnWidth,
|
||||||
|
positionX: alignmentGridStartX + (column ?? 0) * columnWidth,
|
||||||
|
positionY: alignmentGridStartY + row * (rowHeight + rowPadding),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculatePositionPageTwo = (
|
||||||
|
row: number,
|
||||||
|
column: number,
|
||||||
|
width: 'full' | 'column' = 'column',
|
||||||
|
) => {
|
||||||
|
const alignmentGridStartX = 31;
|
||||||
|
const alignmentGridStartY = 16.35;
|
||||||
|
|
||||||
|
return {
|
||||||
|
height: rowHeight,
|
||||||
|
width: width === 'full' ? fullColumnWidth : columnWidth,
|
||||||
|
positionX: alignmentGridStartX + (column ?? 0) * columnWidth,
|
||||||
|
positionY: alignmentGridStartY + row * (rowHeight + rowPadding),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculatePositionPageThree = (
|
||||||
|
row: number,
|
||||||
|
column: number,
|
||||||
|
width: 'full' | 'column' = 'column',
|
||||||
|
rowQuantity: number = 1,
|
||||||
|
) => {
|
||||||
|
const alignmentGridStartX = 31;
|
||||||
|
const alignmentGridStartY = 16.4;
|
||||||
|
|
||||||
|
const rowHeight = 6.8;
|
||||||
|
|
||||||
|
return {
|
||||||
|
height: rowHeight * rowQuantity,
|
||||||
|
width: width === 'full' ? fullColumnWidth : columnWidth,
|
||||||
|
positionX: alignmentGridStartX + (column ?? 0) * columnWidth,
|
||||||
|
positionY: alignmentGridStartY + row * (rowHeight + rowPadding),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
|
export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
|
||||||
/**
|
/**
|
||||||
@ -31,10 +88,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
|
|||||||
type: 'email',
|
type: 'email',
|
||||||
},
|
},
|
||||||
page: 1,
|
page: 1,
|
||||||
height: rowHeight,
|
...calculatePositionPageOne(0, 0),
|
||||||
width: columnWidth,
|
|
||||||
positionX: 0,
|
|
||||||
positionY: 0,
|
|
||||||
customText: 'admin@documenso.com',
|
customText: 'admin@documenso.com',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -44,10 +98,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
|
|||||||
type: 'email',
|
type: 'email',
|
||||||
},
|
},
|
||||||
page: 1,
|
page: 1,
|
||||||
height: rowHeight,
|
...calculatePositionPageOne(0, 1),
|
||||||
width: columnWidth,
|
|
||||||
positionX: 0,
|
|
||||||
positionY: 0,
|
|
||||||
customText: 'admin@documenso.com',
|
customText: 'admin@documenso.com',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -58,10 +109,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
|
|||||||
type: 'email',
|
type: 'email',
|
||||||
},
|
},
|
||||||
page: 1,
|
page: 1,
|
||||||
height: rowHeight,
|
...calculatePositionPageOne(0, 2),
|
||||||
width: columnWidth,
|
|
||||||
positionX: 0,
|
|
||||||
positionY: 0,
|
|
||||||
customText: 'admin@documenso.com',
|
customText: 'admin@documenso.com',
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
@ -75,10 +123,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
|
|||||||
type: 'name',
|
type: 'name',
|
||||||
},
|
},
|
||||||
page: 1,
|
page: 1,
|
||||||
height: rowHeight,
|
...calculatePositionPageOne(1, 0),
|
||||||
width: columnWidth,
|
|
||||||
positionX: 0,
|
|
||||||
positionY: 0,
|
|
||||||
customText: 'John Doe',
|
customText: 'John Doe',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -88,10 +133,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
|
|||||||
type: 'name',
|
type: 'name',
|
||||||
},
|
},
|
||||||
page: 1,
|
page: 1,
|
||||||
height: rowHeight,
|
...calculatePositionPageOne(1, 1),
|
||||||
width: columnWidth,
|
|
||||||
positionX: 0,
|
|
||||||
positionY: 0,
|
|
||||||
customText: 'John Doe',
|
customText: 'John Doe',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -102,10 +144,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
|
|||||||
type: 'name',
|
type: 'name',
|
||||||
},
|
},
|
||||||
page: 1,
|
page: 1,
|
||||||
height: rowHeight,
|
...calculatePositionPageOne(1, 2),
|
||||||
width: columnWidth,
|
|
||||||
positionX: 0,
|
|
||||||
positionY: 0,
|
|
||||||
customText: 'John Doe',
|
customText: 'John Doe',
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
@ -119,10 +158,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
|
|||||||
type: 'date',
|
type: 'date',
|
||||||
},
|
},
|
||||||
page: 1,
|
page: 1,
|
||||||
height: rowHeight,
|
...calculatePositionPageOne(2, 0),
|
||||||
width: columnWidth,
|
|
||||||
positionX: 0,
|
|
||||||
positionY: 0,
|
|
||||||
customText: '123456789',
|
customText: '123456789',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -132,10 +168,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
|
|||||||
type: 'date',
|
type: 'date',
|
||||||
},
|
},
|
||||||
page: 1,
|
page: 1,
|
||||||
height: rowHeight,
|
...calculatePositionPageOne(2, 1),
|
||||||
width: columnWidth,
|
|
||||||
positionX: 0,
|
|
||||||
positionY: 0,
|
|
||||||
customText: '123456789',
|
customText: '123456789',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -146,10 +179,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
|
|||||||
type: 'date',
|
type: 'date',
|
||||||
},
|
},
|
||||||
page: 1,
|
page: 1,
|
||||||
height: rowHeight,
|
...calculatePositionPageOne(2, 2),
|
||||||
width: columnWidth,
|
|
||||||
positionX: 0,
|
|
||||||
positionY: 0,
|
|
||||||
customText: '123456789',
|
customText: '123456789',
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
@ -163,10 +193,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
|
|||||||
type: 'text',
|
type: 'text',
|
||||||
},
|
},
|
||||||
page: 1,
|
page: 1,
|
||||||
height: rowHeight,
|
...calculatePositionPageOne(3, 0),
|
||||||
width: columnWidth,
|
|
||||||
positionX: 0,
|
|
||||||
positionY: 0,
|
|
||||||
customText: '123456789',
|
customText: '123456789',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -176,10 +203,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
|
|||||||
type: 'text',
|
type: 'text',
|
||||||
},
|
},
|
||||||
page: 1,
|
page: 1,
|
||||||
height: rowHeight,
|
...calculatePositionPageOne(3, 1),
|
||||||
width: columnWidth,
|
|
||||||
positionX: 0,
|
|
||||||
positionY: 0,
|
|
||||||
customText: '123456789',
|
customText: '123456789',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -190,10 +214,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
|
|||||||
type: 'text',
|
type: 'text',
|
||||||
},
|
},
|
||||||
page: 1,
|
page: 1,
|
||||||
height: rowHeight,
|
...calculatePositionPageOne(3, 2),
|
||||||
width: columnWidth,
|
|
||||||
positionX: 0,
|
|
||||||
positionY: 0,
|
|
||||||
customText: '123456789',
|
customText: '123456789',
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
@ -207,10 +228,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
|
|||||||
type: 'number',
|
type: 'number',
|
||||||
},
|
},
|
||||||
page: 1,
|
page: 1,
|
||||||
height: rowHeight,
|
...calculatePositionPageOne(4, 0),
|
||||||
width: columnWidth,
|
|
||||||
positionX: 0,
|
|
||||||
positionY: 0,
|
|
||||||
customText: '123456789',
|
customText: '123456789',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -220,10 +238,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
|
|||||||
type: 'number',
|
type: 'number',
|
||||||
},
|
},
|
||||||
page: 1,
|
page: 1,
|
||||||
height: rowHeight,
|
...calculatePositionPageOne(4, 1),
|
||||||
width: columnWidth,
|
|
||||||
positionX: 0,
|
|
||||||
positionY: 0,
|
|
||||||
customText: '123456789',
|
customText: '123456789',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -234,10 +249,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
|
|||||||
type: 'number',
|
type: 'number',
|
||||||
},
|
},
|
||||||
page: 1,
|
page: 1,
|
||||||
height: rowHeight,
|
...calculatePositionPageOne(4, 2),
|
||||||
width: columnWidth,
|
|
||||||
positionX: 0,
|
|
||||||
positionY: 0,
|
|
||||||
customText: '123456789',
|
customText: '123456789',
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
@ -251,10 +263,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
|
|||||||
type: 'initials',
|
type: 'initials',
|
||||||
},
|
},
|
||||||
page: 1,
|
page: 1,
|
||||||
height: rowHeight,
|
...calculatePositionPageOne(5, 0),
|
||||||
width: columnWidth,
|
|
||||||
positionX: 0,
|
|
||||||
positionY: 0,
|
|
||||||
customText: 'JD',
|
customText: 'JD',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -264,10 +273,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
|
|||||||
type: 'initials',
|
type: 'initials',
|
||||||
},
|
},
|
||||||
page: 1,
|
page: 1,
|
||||||
height: rowHeight,
|
...calculatePositionPageOne(5, 1),
|
||||||
width: columnWidth,
|
|
||||||
positionX: 0,
|
|
||||||
positionY: 0,
|
|
||||||
customText: 'JD',
|
customText: 'JD',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -278,10 +284,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
|
|||||||
type: 'initials',
|
type: 'initials',
|
||||||
},
|
},
|
||||||
page: 1,
|
page: 1,
|
||||||
height: rowHeight,
|
...calculatePositionPageOne(5, 2),
|
||||||
width: columnWidth,
|
|
||||||
positionX: 0,
|
|
||||||
positionY: 0,
|
|
||||||
customText: 'JD',
|
customText: 'JD',
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
@ -299,10 +302,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
page: 1,
|
page: 1,
|
||||||
height: rowHeight,
|
...calculatePositionPageOne(6, 0),
|
||||||
width: columnWidth,
|
|
||||||
positionX: 0,
|
|
||||||
positionY: 0,
|
|
||||||
customText: '0',
|
customText: '0',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -312,15 +312,12 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
|
|||||||
type: 'radio',
|
type: 'radio',
|
||||||
values: [
|
values: [
|
||||||
{ id: 1, checked: false, value: 'Option 1' },
|
{ id: 1, checked: false, value: 'Option 1' },
|
||||||
{ id: 2, checked: true, value: 'Option 2' },
|
{ id: 2, checked: false, value: 'Option 2' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
page: 1,
|
page: 1,
|
||||||
height: rowHeight,
|
...calculatePositionPageOne(6, 1),
|
||||||
width: columnWidth,
|
customText: '',
|
||||||
positionX: 0,
|
|
||||||
positionY: 0,
|
|
||||||
customText: '2',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: FieldType.RADIO,
|
type: FieldType.RADIO,
|
||||||
@ -330,15 +327,12 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
|
|||||||
type: 'radio',
|
type: 'radio',
|
||||||
values: [
|
values: [
|
||||||
{ id: 1, checked: false, value: 'Option 1' },
|
{ id: 1, checked: false, value: 'Option 1' },
|
||||||
{ id: 2, checked: false, value: 'Option 2' },
|
{ id: 2, checked: true, value: 'Option 2' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
page: 1,
|
page: 1,
|
||||||
height: rowHeight,
|
...calculatePositionPageOne(6, 2),
|
||||||
width: columnWidth,
|
customText: '1',
|
||||||
positionX: 0,
|
|
||||||
positionY: 0,
|
|
||||||
customText: '',
|
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Row 8 Checkbox
|
* Row 8 Checkbox
|
||||||
@ -355,10 +349,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
page: 1,
|
page: 1,
|
||||||
height: rowHeight,
|
...calculatePositionPageOne(7, 0),
|
||||||
width: columnWidth,
|
|
||||||
positionX: 0,
|
|
||||||
positionY: 0,
|
|
||||||
customText: toCheckboxCustomText([0]),
|
customText: toCheckboxCustomText([0]),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -368,15 +359,12 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
|
|||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
values: [
|
values: [
|
||||||
{ id: 1, checked: false, value: 'Option 1' },
|
{ id: 1, checked: false, value: 'Option 1' },
|
||||||
{ id: 2, checked: true, value: 'Option 2' },
|
{ id: 2, checked: false, value: 'Option 2' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
page: 1,
|
page: 1,
|
||||||
height: rowHeight,
|
...calculatePositionPageOne(7, 1),
|
||||||
width: columnWidth,
|
customText: '',
|
||||||
positionX: 0,
|
|
||||||
positionY: 0,
|
|
||||||
customText: toCheckboxCustomText([1]),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: FieldType.CHECKBOX,
|
type: FieldType.CHECKBOX,
|
||||||
@ -386,15 +374,12 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
|
|||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
values: [
|
values: [
|
||||||
{ id: 1, checked: false, value: 'Option 1' },
|
{ id: 1, checked: false, value: 'Option 1' },
|
||||||
{ id: 2, checked: false, value: 'Option 2' },
|
{ id: 2, checked: true, value: 'Option 2' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
page: 1,
|
page: 1,
|
||||||
height: rowHeight,
|
...calculatePositionPageOne(7, 2),
|
||||||
width: columnWidth,
|
customText: toCheckboxCustomText([1]),
|
||||||
positionX: 0,
|
|
||||||
positionY: 0,
|
|
||||||
customText: '',
|
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Row 8 Dropdown
|
* Row 8 Dropdown
|
||||||
@ -407,10 +392,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
|
|||||||
type: 'dropdown',
|
type: 'dropdown',
|
||||||
},
|
},
|
||||||
page: 1,
|
page: 1,
|
||||||
height: rowHeight,
|
...calculatePositionPageOne(8, 0),
|
||||||
width: columnWidth,
|
|
||||||
positionX: 0,
|
|
||||||
positionY: 0,
|
|
||||||
customText: 'Option 1',
|
customText: 'Option 1',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -420,10 +402,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
|
|||||||
type: 'dropdown',
|
type: 'dropdown',
|
||||||
},
|
},
|
||||||
page: 1,
|
page: 1,
|
||||||
height: rowHeight,
|
...calculatePositionPageOne(8, 1),
|
||||||
width: columnWidth,
|
|
||||||
positionX: 0,
|
|
||||||
positionY: 0,
|
|
||||||
customText: 'Option 1',
|
customText: 'Option 1',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -434,10 +413,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
|
|||||||
type: 'dropdown',
|
type: 'dropdown',
|
||||||
},
|
},
|
||||||
page: 1,
|
page: 1,
|
||||||
height: rowHeight,
|
...calculatePositionPageOne(8, 2),
|
||||||
width: columnWidth,
|
|
||||||
positionX: 0,
|
|
||||||
positionY: 0,
|
|
||||||
customText: 'Option 1',
|
customText: 'Option 1',
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
@ -450,10 +426,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
|
|||||||
type: 'signature',
|
type: 'signature',
|
||||||
},
|
},
|
||||||
page: 1,
|
page: 1,
|
||||||
height: rowHeight,
|
...calculatePositionPageOne(9, 0),
|
||||||
width: columnWidth,
|
|
||||||
positionX: 0,
|
|
||||||
positionY: 0,
|
|
||||||
customText: '',
|
customText: '',
|
||||||
signature: 'My Signature',
|
signature: 'My Signature',
|
||||||
},
|
},
|
||||||
@ -463,10 +436,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
|
|||||||
type: 'signature',
|
type: 'signature',
|
||||||
},
|
},
|
||||||
page: 1,
|
page: 1,
|
||||||
height: rowHeight,
|
...calculatePositionPageOne(9, 1),
|
||||||
width: columnWidth,
|
|
||||||
positionX: 0,
|
|
||||||
positionY: 0,
|
|
||||||
customText: '',
|
customText: '',
|
||||||
signature: 'My Signature',
|
signature: 'My Signature',
|
||||||
},
|
},
|
||||||
@ -477,22 +447,295 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
|
|||||||
type: 'signature',
|
type: 'signature',
|
||||||
},
|
},
|
||||||
page: 1,
|
page: 1,
|
||||||
height: rowHeight,
|
...calculatePositionPageOne(9, 2),
|
||||||
width: columnWidth,
|
|
||||||
positionX: 0,
|
|
||||||
positionY: 0,
|
|
||||||
customText: '',
|
customText: '',
|
||||||
signature: 'My Signature',
|
signature: 'My Signature',
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* @@@@@@@@@@@@@@@@@@@@@@@
|
||||||
|
*
|
||||||
|
* PAGE 2
|
||||||
|
*
|
||||||
|
* @@@@@@@@@@@@@@@@@@@@@@@
|
||||||
|
*/
|
||||||
|
// TEXT GRID ROW 1
|
||||||
|
{
|
||||||
|
type: FieldType.TEXT,
|
||||||
|
fieldMeta: {
|
||||||
|
textAlign: 'left',
|
||||||
|
type: 'text',
|
||||||
|
verticalAlign: 'top',
|
||||||
|
},
|
||||||
|
page: 2,
|
||||||
|
...calculatePositionPageTwo(0, 0),
|
||||||
|
customText: 'SOME TEXT',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.TEXT,
|
||||||
|
fieldMeta: {
|
||||||
|
textAlign: 'center',
|
||||||
|
type: 'text',
|
||||||
|
verticalAlign: 'top',
|
||||||
|
},
|
||||||
|
page: 2,
|
||||||
|
...calculatePositionPageTwo(0, 1),
|
||||||
|
customText: 'SOME TEXT',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.TEXT,
|
||||||
|
fieldMeta: {
|
||||||
|
textAlign: 'right',
|
||||||
|
type: 'text',
|
||||||
|
verticalAlign: 'top',
|
||||||
|
},
|
||||||
|
page: 2,
|
||||||
|
...calculatePositionPageTwo(0, 2),
|
||||||
|
customText: 'SOME TEXT',
|
||||||
|
},
|
||||||
|
// TEXT GRID ROW 2
|
||||||
|
{
|
||||||
|
type: FieldType.TEXT,
|
||||||
|
fieldMeta: {
|
||||||
|
textAlign: 'left',
|
||||||
|
type: 'text',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
},
|
||||||
|
page: 2,
|
||||||
|
...calculatePositionPageTwo(1, 0),
|
||||||
|
customText: 'SOME TEXT',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.TEXT,
|
||||||
|
fieldMeta: {
|
||||||
|
textAlign: 'center',
|
||||||
|
type: 'text',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
},
|
||||||
|
page: 2,
|
||||||
|
...calculatePositionPageTwo(1, 1),
|
||||||
|
customText: 'SOME TEXT',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.TEXT,
|
||||||
|
fieldMeta: {
|
||||||
|
textAlign: 'right',
|
||||||
|
type: 'text',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
},
|
||||||
|
page: 2,
|
||||||
|
...calculatePositionPageTwo(1, 2),
|
||||||
|
customText: 'SOME TEXT',
|
||||||
|
},
|
||||||
|
// TEXT GRID ROW 3
|
||||||
|
{
|
||||||
|
type: FieldType.TEXT,
|
||||||
|
fieldMeta: {
|
||||||
|
textAlign: 'left',
|
||||||
|
type: 'text',
|
||||||
|
verticalAlign: 'bottom',
|
||||||
|
},
|
||||||
|
page: 2,
|
||||||
|
...calculatePositionPageTwo(2, 0),
|
||||||
|
customText: 'SOME TEXT',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.TEXT,
|
||||||
|
fieldMeta: {
|
||||||
|
textAlign: 'center',
|
||||||
|
type: 'text',
|
||||||
|
verticalAlign: 'bottom',
|
||||||
|
},
|
||||||
|
page: 2,
|
||||||
|
...calculatePositionPageTwo(2, 1),
|
||||||
|
customText: 'SOME TEXT',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.TEXT,
|
||||||
|
fieldMeta: {
|
||||||
|
textAlign: 'right',
|
||||||
|
type: 'text',
|
||||||
|
verticalAlign: 'bottom',
|
||||||
|
},
|
||||||
|
page: 2,
|
||||||
|
...calculatePositionPageTwo(2, 2),
|
||||||
|
customText: 'SOME TEXT',
|
||||||
|
},
|
||||||
|
// NUMBER GRID ROW 1
|
||||||
|
{
|
||||||
|
type: FieldType.NUMBER,
|
||||||
|
fieldMeta: {
|
||||||
|
textAlign: 'left',
|
||||||
|
type: 'number',
|
||||||
|
verticalAlign: 'top',
|
||||||
|
},
|
||||||
|
page: 2,
|
||||||
|
...calculatePositionPageTwo(3, 0),
|
||||||
|
customText: '123456789123456789',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.NUMBER,
|
||||||
|
fieldMeta: {
|
||||||
|
textAlign: 'center',
|
||||||
|
type: 'number',
|
||||||
|
verticalAlign: 'top',
|
||||||
|
},
|
||||||
|
page: 2,
|
||||||
|
...calculatePositionPageTwo(3, 1),
|
||||||
|
customText: '123456789123456789',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.NUMBER,
|
||||||
|
fieldMeta: {
|
||||||
|
textAlign: 'right',
|
||||||
|
type: 'number',
|
||||||
|
verticalAlign: 'top',
|
||||||
|
},
|
||||||
|
page: 2,
|
||||||
|
...calculatePositionPageTwo(3, 2),
|
||||||
|
customText: '123456789123456789',
|
||||||
|
},
|
||||||
|
// NUMBER GRID ROW 2
|
||||||
|
{
|
||||||
|
type: FieldType.NUMBER,
|
||||||
|
fieldMeta: {
|
||||||
|
textAlign: 'left',
|
||||||
|
type: 'number',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
},
|
||||||
|
page: 2,
|
||||||
|
...calculatePositionPageTwo(4, 0),
|
||||||
|
customText: '123456789123456789',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.NUMBER,
|
||||||
|
fieldMeta: {
|
||||||
|
textAlign: 'center',
|
||||||
|
type: 'number',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
},
|
||||||
|
page: 2,
|
||||||
|
...calculatePositionPageTwo(4, 1),
|
||||||
|
customText: '123456789123456789',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.NUMBER,
|
||||||
|
fieldMeta: {
|
||||||
|
textAlign: 'right',
|
||||||
|
type: 'number',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
},
|
||||||
|
page: 2,
|
||||||
|
...calculatePositionPageTwo(4, 2),
|
||||||
|
customText: '123456789123456789',
|
||||||
|
},
|
||||||
|
// NUMBER GRID ROW 3
|
||||||
|
{
|
||||||
|
type: FieldType.NUMBER,
|
||||||
|
fieldMeta: {
|
||||||
|
textAlign: 'left',
|
||||||
|
type: 'number',
|
||||||
|
verticalAlign: 'bottom',
|
||||||
|
},
|
||||||
|
page: 2,
|
||||||
|
...calculatePositionPageTwo(5, 0),
|
||||||
|
customText: '123456789123456789',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.NUMBER,
|
||||||
|
fieldMeta: {
|
||||||
|
textAlign: 'center',
|
||||||
|
type: 'number',
|
||||||
|
verticalAlign: 'bottom',
|
||||||
|
},
|
||||||
|
page: 2,
|
||||||
|
...calculatePositionPageTwo(5, 1),
|
||||||
|
customText: '123456789123456789',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.NUMBER,
|
||||||
|
fieldMeta: {
|
||||||
|
textAlign: 'right',
|
||||||
|
type: 'number',
|
||||||
|
verticalAlign: 'bottom',
|
||||||
|
},
|
||||||
|
page: 2,
|
||||||
|
...calculatePositionPageTwo(5, 2),
|
||||||
|
customText: '123456789123456789',
|
||||||
|
},
|
||||||
|
// Text combing
|
||||||
|
{
|
||||||
|
type: FieldType.TEXT,
|
||||||
|
fieldMeta: {
|
||||||
|
type: 'text',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
letterSpacing: 32,
|
||||||
|
characterLimit: 9,
|
||||||
|
},
|
||||||
|
page: 2,
|
||||||
|
...calculatePositionPageTwo(6, 0, 'full'),
|
||||||
|
positionX: calculatePositionPageTwo(6, 0, 'full').positionX + 1.75,
|
||||||
|
width: calculatePositionPageTwo(6, 0, 'full').width + 1.75,
|
||||||
|
customText: 'HEY HEY 1',
|
||||||
|
},
|
||||||
|
// Number combing
|
||||||
|
{
|
||||||
|
type: FieldType.NUMBER,
|
||||||
|
fieldMeta: {
|
||||||
|
type: 'number',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
letterSpacing: 32,
|
||||||
|
},
|
||||||
|
page: 2,
|
||||||
|
...calculatePositionPageTwo(7, 0, 'full'),
|
||||||
|
positionX: calculatePositionPageTwo(7, 0, 'full').positionX + 1.75,
|
||||||
|
width: calculatePositionPageTwo(7, 0, 'full').width + 1.75,
|
||||||
|
|
||||||
|
customText: '123456789',
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @@@@@@@@@@@@@@@@@@@@@@@
|
||||||
|
*
|
||||||
|
* PAGE 2 TEXT MULTILINE
|
||||||
|
*
|
||||||
|
* @@@@@@@@@@@@@@@@@@@@@@@
|
||||||
|
*/
|
||||||
|
{
|
||||||
|
type: FieldType.TEXT,
|
||||||
|
fieldMeta: {
|
||||||
|
verticalAlign: 'top',
|
||||||
|
textAlign: 'left',
|
||||||
|
lineHeight: 2.24,
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
page: 3,
|
||||||
|
...calculatePositionPageThree(0, 0, 'full', 3),
|
||||||
|
customText:
|
||||||
|
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.TEXT,
|
||||||
|
fieldMeta: {
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 2.24,
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
page: 3,
|
||||||
|
...calculatePositionPageThree(3, 0, 'full', 3),
|
||||||
|
customText:
|
||||||
|
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: FieldType.TEXT,
|
||||||
|
fieldMeta: {
|
||||||
|
verticalAlign: 'bottom',
|
||||||
|
textAlign: 'right',
|
||||||
|
lineHeight: 2.24,
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
page: 3,
|
||||||
|
...calculatePositionPageThree(6, 0, 'full', 3),
|
||||||
|
customText:
|
||||||
|
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
|
||||||
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const formatAlignmentTestFields = ALIGNMENT_TEST_FIELDS.map((field, index) => {
|
|
||||||
const row = Math.floor(index / 3);
|
|
||||||
const column = index % 3;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...field,
|
|
||||||
positionX: alignmentGridStartX + column * columnWidth,
|
|
||||||
positionY: alignmentGridStartY + row * rowHeight,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
} from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
|
} from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
|
||||||
|
|
||||||
import type { FieldTestData } from './field-alignment-pdf';
|
import type { FieldTestData } from './field-alignment-pdf';
|
||||||
|
import { signatureBase64Demo } from './field-alignment-pdf';
|
||||||
|
|
||||||
const columnWidth = 20.1;
|
const columnWidth = 20.1;
|
||||||
const fullColumnWidth = 75.8;
|
const fullColumnWidth = 75.8;
|
||||||
@ -37,7 +38,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
|
|||||||
page: 2,
|
page: 2,
|
||||||
...calculatePosition(0, 0),
|
...calculatePosition(0, 0),
|
||||||
customText: '',
|
customText: '',
|
||||||
signature: 'My Signature',
|
signature: signatureBase64Demo,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: FieldType.SIGNATURE,
|
type: FieldType.SIGNATURE,
|
||||||
@ -47,7 +48,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
|
|||||||
page: 2,
|
page: 2,
|
||||||
...calculatePosition(1, 0),
|
...calculatePosition(1, 0),
|
||||||
customText: '',
|
customText: '',
|
||||||
signature: 'My Signature',
|
signature: signatureBase64Demo,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: FieldType.SIGNATURE,
|
type: FieldType.SIGNATURE,
|
||||||
@ -67,7 +68,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
|
|||||||
page: 2,
|
page: 2,
|
||||||
...calculatePosition(3, 0),
|
...calculatePosition(3, 0),
|
||||||
customText: '',
|
customText: '',
|
||||||
signature: 'My Signature',
|
signature: 'My Signature super overflow maybe',
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -80,7 +81,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
|
|||||||
},
|
},
|
||||||
page: 3,
|
page: 3,
|
||||||
...calculatePosition(0, 0, 'full'),
|
...calculatePosition(0, 0, 'full'),
|
||||||
customText: '123456789',
|
customText: 'Hello world, this is some random text that I have written here',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: FieldType.TEXT,
|
type: FieldType.TEXT,
|
||||||
@ -89,7 +90,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
|
|||||||
},
|
},
|
||||||
page: 3,
|
page: 3,
|
||||||
...calculatePosition(1, 0),
|
...calculatePosition(1, 0),
|
||||||
customText: '123456789123456789123456789123456789',
|
customText: 'Some text that should overflow correctly',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: FieldType.TEXT,
|
type: FieldType.TEXT,
|
||||||
@ -109,7 +110,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
|
|||||||
},
|
},
|
||||||
page: 3,
|
page: 3,
|
||||||
...calculatePosition(3, 0),
|
...calculatePosition(3, 0),
|
||||||
customText: '123456789',
|
customText: 'Input should have a placeholder text when clicked',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: FieldType.TEXT,
|
type: FieldType.TEXT,
|
||||||
@ -119,7 +120,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
|
|||||||
},
|
},
|
||||||
page: 3,
|
page: 3,
|
||||||
...calculatePosition(3, 1),
|
...calculatePosition(3, 1),
|
||||||
customText: '123456789',
|
customText: 'Should have a label during editing and signing',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: FieldType.TEXT,
|
type: FieldType.TEXT,
|
||||||
@ -129,7 +130,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
|
|||||||
},
|
},
|
||||||
page: 3,
|
page: 3,
|
||||||
...calculatePosition(3, 2),
|
...calculatePosition(3, 2),
|
||||||
customText: '123456789',
|
customText: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: FieldType.TEXT,
|
type: FieldType.TEXT,
|
||||||
@ -139,20 +140,19 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
|
|||||||
},
|
},
|
||||||
page: 3,
|
page: 3,
|
||||||
...calculatePosition(4, 0),
|
...calculatePosition(4, 0),
|
||||||
customText: '123456789',
|
customText: 'This is a required field',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: FieldType.TEXT,
|
type: FieldType.TEXT,
|
||||||
fieldMeta: {
|
fieldMeta: {
|
||||||
type: 'text',
|
type: 'text',
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
text: 'Readonly Value',
|
text: 'Some Readonly Value',
|
||||||
},
|
},
|
||||||
page: 3,
|
page: 3,
|
||||||
...calculatePosition(4, 1),
|
...calculatePosition(4, 1),
|
||||||
customText: 'Readonly Value',
|
customText: '',
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PAGE 4 NUMBER
|
* PAGE 4 NUMBER
|
||||||
*/
|
*/
|
||||||
@ -220,7 +220,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
|
|||||||
type: FieldType.NUMBER,
|
type: FieldType.NUMBER,
|
||||||
fieldMeta: {
|
fieldMeta: {
|
||||||
type: 'number',
|
type: 'number',
|
||||||
value: '123',
|
value: '123456789',
|
||||||
},
|
},
|
||||||
page: 4,
|
page: 4,
|
||||||
...calculatePosition(3, 2),
|
...calculatePosition(3, 2),
|
||||||
@ -241,10 +241,11 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
|
|||||||
fieldMeta: {
|
fieldMeta: {
|
||||||
type: 'number',
|
type: 'number',
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
|
value: '123456789',
|
||||||
},
|
},
|
||||||
page: 4,
|
page: 4,
|
||||||
...calculatePosition(4, 1),
|
...calculatePosition(4, 1),
|
||||||
customText: '123456789',
|
customText: '',
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -272,8 +273,8 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
|
|||||||
type: 'radio',
|
type: 'radio',
|
||||||
values: [
|
values: [
|
||||||
{ id: 1, checked: false, value: 'Option 1' },
|
{ id: 1, checked: false, value: 'Option 1' },
|
||||||
{ id: 2, checked: true, value: 'Option 2' },
|
{ id: 2, checked: false, value: 'Option 2' },
|
||||||
{ id: 3, checked: false, value: 'Option 3' },
|
{ id: 3, checked: true, value: 'Option 3' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
page: 5,
|
page: 5,
|
||||||
@ -285,6 +286,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
|
|||||||
fieldMeta: {
|
fieldMeta: {
|
||||||
direction: 'vertical',
|
direction: 'vertical',
|
||||||
type: 'radio',
|
type: 'radio',
|
||||||
|
required: true,
|
||||||
values: [
|
values: [
|
||||||
{ id: 1, checked: false, value: 'Option 1' },
|
{ id: 1, checked: false, value: 'Option 1' },
|
||||||
{ id: 2, checked: false, value: 'Option 2' },
|
{ id: 2, checked: false, value: 'Option 2' },
|
||||||
@ -293,17 +295,18 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
|
|||||||
},
|
},
|
||||||
page: 5,
|
page: 5,
|
||||||
...calculatePosition(2, 0),
|
...calculatePosition(2, 0),
|
||||||
customText: '',
|
customText: '2',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: FieldType.RADIO,
|
type: FieldType.RADIO,
|
||||||
fieldMeta: {
|
fieldMeta: {
|
||||||
direction: 'vertical',
|
direction: 'vertical',
|
||||||
type: 'radio',
|
type: 'radio',
|
||||||
|
readOnly: true,
|
||||||
values: [
|
values: [
|
||||||
{ id: 1, checked: false, value: 'Option 1' },
|
{ id: 1, checked: false, value: 'Option 1' },
|
||||||
{ id: 2, checked: false, value: 'Option 2' },
|
{ id: 2, checked: false, value: 'Option 2' },
|
||||||
{ id: 3, checked: false, value: 'Option 3' },
|
{ id: 3, checked: true, value: 'Option 3' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
page: 5,
|
page: 5,
|
||||||
@ -338,7 +341,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
|
|||||||
values: [
|
values: [
|
||||||
{ id: 1, checked: false, value: 'Option 1' },
|
{ id: 1, checked: false, value: 'Option 1' },
|
||||||
{ id: 2, checked: true, value: 'Option 2' },
|
{ id: 2, checked: true, value: 'Option 2' },
|
||||||
{ id: 2, checked: true, value: 'Option 3' },
|
{ id: 3, checked: false, value: 'Option 3' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
page: 6,
|
page: 6,
|
||||||
@ -358,7 +361,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
|
|||||||
},
|
},
|
||||||
page: 6,
|
page: 6,
|
||||||
...calculatePosition(2, 0),
|
...calculatePosition(2, 0),
|
||||||
customText: '',
|
customText: toCheckboxCustomText([2]),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: FieldType.CHECKBOX,
|
type: FieldType.CHECKBOX,
|
||||||
@ -368,7 +371,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
|
|||||||
readOnly: true,
|
readOnly: true,
|
||||||
values: [
|
values: [
|
||||||
{ id: 1, checked: false, value: 'Option 1' },
|
{ id: 1, checked: false, value: 'Option 1' },
|
||||||
{ id: 2, checked: false, value: 'Option 2' },
|
{ id: 2, checked: true, value: 'Option 2' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
page: 6,
|
page: 6,
|
||||||
@ -445,11 +448,11 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
|
|||||||
fieldMeta: {
|
fieldMeta: {
|
||||||
values: [{ value: 'Option 1' }, { value: 'Option 2' }],
|
values: [{ value: 'Option 1' }, { value: 'Option 2' }],
|
||||||
type: 'dropdown',
|
type: 'dropdown',
|
||||||
defaultValue: 'Option 1',
|
defaultValue: 'Option 2',
|
||||||
},
|
},
|
||||||
page: 7,
|
page: 7,
|
||||||
...calculatePosition(1, 0),
|
...calculatePosition(1, 0),
|
||||||
customText: 'Option 1',
|
customText: 'Option 2',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: FieldType.DROPDOWN,
|
type: FieldType.DROPDOWN,
|
||||||
@ -460,13 +463,14 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
|
|||||||
},
|
},
|
||||||
page: 7,
|
page: 7,
|
||||||
...calculatePosition(2, 0),
|
...calculatePosition(2, 0),
|
||||||
customText: 'Option 1',
|
customText: 'Option 3',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: FieldType.DROPDOWN,
|
type: FieldType.DROPDOWN,
|
||||||
fieldMeta: {
|
fieldMeta: {
|
||||||
values: [{ value: 'Option 1' }, { value: 'Option 2' }, { value: 'Option 3' }],
|
values: [{ value: 'Option 1' }, { value: 'Option 2' }, { value: 'Option 3' }],
|
||||||
type: 'dropdown',
|
type: 'dropdown',
|
||||||
|
defaultValue: 'Option 1',
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
},
|
},
|
||||||
page: 7,
|
page: 7,
|
||||||
|
|||||||
@ -27,7 +27,7 @@ import type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/en
|
|||||||
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
|
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
|
||||||
import type { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types';
|
import type { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types';
|
||||||
|
|
||||||
import { formatAlignmentTestFields } from '../../../constants/field-alignment-pdf';
|
import { ALIGNMENT_TEST_FIELDS } from '../../../constants/field-alignment-pdf';
|
||||||
import { FIELD_META_TEST_FIELDS } from '../../../constants/field-meta-pdf';
|
import { FIELD_META_TEST_FIELDS } from '../../../constants/field-meta-pdf';
|
||||||
|
|
||||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||||
@ -490,7 +490,7 @@ test.describe('API V2 Envelopes', () => {
|
|||||||
// Step 6: Create fields for first PDF (alignment fields)
|
// Step 6: Create fields for first PDF (alignment fields)
|
||||||
const alignmentFieldsRequest = {
|
const alignmentFieldsRequest = {
|
||||||
envelopeId: createdEnvelope.id,
|
envelopeId: createdEnvelope.id,
|
||||||
data: formatAlignmentTestFields.map((field) => ({
|
data: ALIGNMENT_TEST_FIELDS.map((field) => ({
|
||||||
recipientId,
|
recipientId,
|
||||||
envelopeItemId: alignmentItem.id,
|
envelopeItemId: alignmentItem.id,
|
||||||
type: field.type,
|
type: field.type,
|
||||||
@ -547,7 +547,7 @@ test.describe('API V2 Envelopes', () => {
|
|||||||
expect(finalEnvelope.envelopeItems.length).toBe(2);
|
expect(finalEnvelope.envelopeItems.length).toBe(2);
|
||||||
expect(finalEnvelope.recipients.length).toBe(1);
|
expect(finalEnvelope.recipients.length).toBe(1);
|
||||||
expect(finalEnvelope.fields.length).toBe(
|
expect(finalEnvelope.fields.length).toBe(
|
||||||
formatAlignmentTestFields.length + FIELD_META_TEST_FIELDS.length,
|
ALIGNMENT_TEST_FIELDS.length + FIELD_META_TEST_FIELDS.length,
|
||||||
);
|
);
|
||||||
expect(finalEnvelope.title).toBe('Envelope Full Field Test');
|
expect(finalEnvelope.title).toBe('Envelope Full Field Test');
|
||||||
expect(finalEnvelope.type).toBe(EnvelopeType.DOCUMENT);
|
expect(finalEnvelope.type).toBe(EnvelopeType.DOCUMENT);
|
||||||
|
|||||||
@ -1,129 +0,0 @@
|
|||||||
import { type Page, expect, test } from '@playwright/test';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
import { seedUser } from '@documenso/prisma/seed/users';
|
|
||||||
|
|
||||||
import { apiSignin } from '../fixtures/authentication';
|
|
||||||
|
|
||||||
const SINGLE_PLACEHOLDER_PDF_PATH = path.join(
|
|
||||||
__dirname,
|
|
||||||
'../../../assets/project-proposal-single-recipient.pdf',
|
|
||||||
);
|
|
||||||
|
|
||||||
const MULTIPLE_PLACEHOLDER_PDF_PATH = path.join(
|
|
||||||
__dirname,
|
|
||||||
'../../../assets/project-proposal-multiple-fields-and-recipients.pdf',
|
|
||||||
);
|
|
||||||
|
|
||||||
const setupUserAndSignIn = async (page: Page) => {
|
|
||||||
const { user, team } = await seedUser();
|
|
||||||
|
|
||||||
await apiSignin({
|
|
||||||
page,
|
|
||||||
email: user.email,
|
|
||||||
redirectPath: `/t/${team.url}/documents`,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { user, team };
|
|
||||||
};
|
|
||||||
|
|
||||||
const uploadPdfAndContinue = async (page: Page, pdfPath: string, continueClicks: number = 1) => {
|
|
||||||
const fileInput = page.locator('input[type="file"]').nth(1);
|
|
||||||
await fileInput.waitFor({ state: 'attached' });
|
|
||||||
await fileInput.setInputFiles(pdfPath);
|
|
||||||
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
|
|
||||||
for (let i = 0; i < continueClicks; i++) {
|
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
test.describe('PDF Placeholders with single recipient', () => {
|
|
||||||
test('[AUTO_PLACING_FIELDS]: should automatically create recipients from PDF placeholders', async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await setupUserAndSignIn(page);
|
|
||||||
await uploadPdfAndContinue(page, SINGLE_PLACEHOLDER_PDF_PATH, 1);
|
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
|
||||||
await expect(page.getByPlaceholder('Email')).toHaveValue('recipient.1@documenso.com');
|
|
||||||
await expect(page.getByPlaceholder('Name')).toHaveValue('Recipient 1');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('[AUTO_PLACING_FIELDS]: should automatically place fields from PDF placeholders', async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await setupUserAndSignIn(page);
|
|
||||||
await uploadPdfAndContinue(page, SINGLE_PLACEHOLDER_PDF_PATH, 2);
|
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
|
||||||
|
|
||||||
await expect(page.locator('[data-field-type="SIGNATURE"]')).toBeVisible();
|
|
||||||
await expect(page.locator('[data-field-type="EMAIL"]')).toBeVisible();
|
|
||||||
await expect(page.locator('[data-field-type="NAME"]')).toBeVisible();
|
|
||||||
await expect(page.locator('[data-field-type="TEXT"]')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('[AUTO_PLACING_FIELDS]: should automatically configure fields from PDF placeholders', async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await setupUserAndSignIn(page);
|
|
||||||
await uploadPdfAndContinue(page, SINGLE_PLACEHOLDER_PDF_PATH, 2);
|
|
||||||
|
|
||||||
await page.getByText('Text').nth(1).click();
|
|
||||||
await page.getByRole('button', { name: 'Advanced settings' }).click();
|
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: 'Advanced settings' })).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page
|
|
||||||
.locator('div')
|
|
||||||
.filter({ hasText: /^Required field$/ })
|
|
||||||
.getByRole('switch'),
|
|
||||||
).toBeChecked();
|
|
||||||
|
|
||||||
await expect(page.getByRole('combobox')).toHaveText('Right');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('PDF Placeholders with multiple recipients', () => {
|
|
||||||
test('[AUTO_PLACING_FIELDS]: should automatically create recipients from PDF placeholders', async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await setupUserAndSignIn(page);
|
|
||||||
await uploadPdfAndContinue(page, MULTIPLE_PLACEHOLDER_PDF_PATH, 1);
|
|
||||||
|
|
||||||
await expect(page.getByTestId('signer-email-input').first()).toHaveValue(
|
|
||||||
'recipient.1@documenso.com',
|
|
||||||
);
|
|
||||||
await expect(page.getByLabel('Name').first()).toHaveValue('Recipient 1');
|
|
||||||
|
|
||||||
await expect(page.getByTestId('signer-email-input').nth(1)).toHaveValue(
|
|
||||||
'recipient.2@documenso.com',
|
|
||||||
);
|
|
||||||
await expect(page.getByLabel('Name').nth(1)).toHaveValue('Recipient 2');
|
|
||||||
|
|
||||||
await expect(page.getByTestId('signer-email-input').nth(2)).toHaveValue(
|
|
||||||
'recipient.3@documenso.com',
|
|
||||||
);
|
|
||||||
await expect(page.getByLabel('Name').nth(2)).toHaveValue('Recipient 3');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('[AUTO_PLACING_FIELDS]: should automatically create fields from PDF placeholders', async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await setupUserAndSignIn(page);
|
|
||||||
await uploadPdfAndContinue(page, MULTIPLE_PLACEHOLDER_PDF_PATH, 2);
|
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
|
||||||
|
|
||||||
await expect(page.locator('[data-field-type="SIGNATURE"]').first()).toBeVisible();
|
|
||||||
await expect(page.locator('[data-field-type="SIGNATURE"]').nth(1)).toBeVisible();
|
|
||||||
await expect(page.locator('[data-field-type="SIGNATURE"]').nth(2)).toBeVisible();
|
|
||||||
await expect(page.locator('[data-field-type="EMAIL"]').first()).toBeVisible();
|
|
||||||
await expect(page.locator('[data-field-type="EMAIL"]').nth(1)).toBeVisible();
|
|
||||||
await expect(page.locator('[data-field-type="NAME"]')).toBeVisible();
|
|
||||||
await expect(page.locator('[data-field-type="TEXT"]')).toBeVisible();
|
|
||||||
await expect(page.locator('[data-field-type="NUMBER"]')).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -21,7 +21,7 @@ import pixelMatch from 'pixelmatch';
|
|||||||
import { PNG } from 'pngjs';
|
import { PNG } from 'pngjs';
|
||||||
import type { TestInfo } from '@playwright/test';
|
import type { TestInfo } from '@playwright/test';
|
||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
import { DocumentStatus } from '@prisma/client';
|
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.js';
|
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.js';
|
||||||
@ -29,26 +29,218 @@ import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download';
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { seedAlignmentTestDocument } from '@documenso/prisma/seed/initial-seed';
|
import { seedAlignmentTestDocument } from '@documenso/prisma/seed/initial-seed';
|
||||||
import { seedUser } from '@documenso/prisma/seed/users';
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
|
||||||
import { apiSignin } from '../fixtures/authentication';
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
|
import type {
|
||||||
|
TCreateEnvelopePayload,
|
||||||
|
TCreateEnvelopeResponse,
|
||||||
|
} from '../../../trpc/server/envelope-router/create-envelope.types';
|
||||||
|
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../lib/constants/app';
|
||||||
|
import { createApiToken } from '../../../lib/server-only/public-api/create-api-token';
|
||||||
|
import { RecipientRole } from '../../../prisma/generated/types';
|
||||||
|
import { FIELD_META_TEST_FIELDS } from '../../constants/field-meta-pdf';
|
||||||
|
import { ALIGNMENT_TEST_FIELDS } from '../../constants/field-alignment-pdf';
|
||||||
|
import type { TDistributeEnvelopeRequest } from '../../../trpc/server/envelope-router/distribute-envelope.types';
|
||||||
|
import { isBase64Image } from '../../../lib/constants/signatures';
|
||||||
|
|
||||||
|
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||||
|
const baseUrl = `${WEBAPP_BASE_URL}/api/v2`;
|
||||||
|
|
||||||
test.describe.configure({ mode: 'parallel', timeout: 60000 });
|
test.describe.configure({ mode: 'parallel', timeout: 60000 });
|
||||||
|
|
||||||
test.skip('field placement visual regression', async ({ page }, testInfo) => {
|
test.skip('seed alignment test document', async ({ page }) => {
|
||||||
const { user, team } = await seedUser();
|
const user = await prisma.user.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
const envelope = await seedAlignmentTestDocument({
|
email: 'example@documenso.com',
|
||||||
userId: user.id,
|
},
|
||||||
teamId: team.id,
|
include: {
|
||||||
recipientName: user.name || '',
|
ownedOrganisations: {
|
||||||
recipientEmail: user.email,
|
include: {
|
||||||
insertFields: true,
|
teams: true,
|
||||||
status: DocumentStatus.PENDING,
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const token = envelope.recipients[0].token;
|
const userId = user.id;
|
||||||
|
const teamId = user.ownedOrganisations[0].teams[0].id;
|
||||||
|
|
||||||
const signUrl = `/sign/${token}`;
|
await seedAlignmentTestDocument({
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
recipientName: user.name || '',
|
||||||
|
recipientEmail: user.email,
|
||||||
|
insertFields: false,
|
||||||
|
status: DocumentStatus.DRAFT,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('field placement visual regression', async ({ page, request }, testInfo) => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
|
||||||
|
const { token } = await createApiToken({
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team.id,
|
||||||
|
tokenName: 'test',
|
||||||
|
expiresIn: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 1: Create initial envelope with Prisma (with first envelope item)
|
||||||
|
const alignmentPdf = fs.readFileSync(
|
||||||
|
path.join(__dirname, '../../../../assets/field-font-alignment.pdf'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const fieldMetaPdf = fs.readFileSync(path.join(__dirname, '../../../../assets/field-meta.pdf'));
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
const fieldMetaFields = FIELD_META_TEST_FIELDS.map((field) => ({
|
||||||
|
identifier: 'field-meta',
|
||||||
|
type: field.type,
|
||||||
|
page: field.page,
|
||||||
|
positionX: field.positionX,
|
||||||
|
positionY: field.positionY,
|
||||||
|
width: field.width,
|
||||||
|
height: field.height,
|
||||||
|
fieldMeta: field.fieldMeta,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const alignmentFields = ALIGNMENT_TEST_FIELDS.map((field) => ({
|
||||||
|
identifier: 'alignment-pdf',
|
||||||
|
type: field.type,
|
||||||
|
page: field.page,
|
||||||
|
positionX: field.positionX,
|
||||||
|
positionY: field.positionY,
|
||||||
|
width: field.width,
|
||||||
|
height: field.height,
|
||||||
|
fieldMeta: field.fieldMeta,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const createEnvelopePayload: TCreateEnvelopePayload = {
|
||||||
|
type: EnvelopeType.DOCUMENT,
|
||||||
|
title: 'Envelope Full Field Test',
|
||||||
|
recipients: [
|
||||||
|
{
|
||||||
|
email: user.email,
|
||||||
|
name: user.name || '',
|
||||||
|
role: RecipientRole.SIGNER,
|
||||||
|
fields: [...fieldMetaFields, ...alignmentFields],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
formData.append('payload', JSON.stringify(createEnvelopePayload));
|
||||||
|
|
||||||
|
formData.append('files', new File([alignmentPdf], 'alignment-pdf', { type: 'application/pdf' }));
|
||||||
|
formData.append('files', new File([fieldMetaPdf], 'field-meta', { type: 'application/pdf' }));
|
||||||
|
|
||||||
|
const createEnvelopeRequest = await request.post(`${baseUrl}/envelope/create`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
multipart: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(createEnvelopeRequest.ok()).toBeTruthy();
|
||||||
|
expect(createEnvelopeRequest.status()).toBe(200);
|
||||||
|
|
||||||
|
const { id: createdEnvelopeId }: TCreateEnvelopeResponse = await createEnvelopeRequest.json();
|
||||||
|
|
||||||
|
const envelope = await prisma.envelope.findUniqueOrThrow({
|
||||||
|
where: {
|
||||||
|
id: createdEnvelopeId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
recipients: true,
|
||||||
|
envelopeItems: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const recipientId = envelope.recipients[0].id;
|
||||||
|
const alignmentItem = envelope.envelopeItems.find((item: { order: number }) => item.order === 1);
|
||||||
|
const fieldMetaItem = envelope.envelopeItems.find((item: { order: number }) => item.order === 2);
|
||||||
|
|
||||||
|
expect(recipientId).toBeDefined();
|
||||||
|
expect(alignmentItem).toBeDefined();
|
||||||
|
expect(fieldMetaItem).toBeDefined();
|
||||||
|
|
||||||
|
if (!alignmentItem || !fieldMetaItem) {
|
||||||
|
throw new Error('Envelope items not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const distributeEnvelopeRequest = await request.post(`${baseUrl}/envelope/distribute`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
data: {
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
} satisfies TDistributeEnvelopeRequest,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(distributeEnvelopeRequest.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
const uninsertedFields = await prisma.field.findMany({
|
||||||
|
where: {
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
inserted: false,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
envelopeItem: {
|
||||||
|
select: {
|
||||||
|
title: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
uninsertedFields.map(async (field) => {
|
||||||
|
let foundField = ALIGNMENT_TEST_FIELDS.find(
|
||||||
|
(f) =>
|
||||||
|
field.page === f.page &&
|
||||||
|
field.envelopeItem.title === 'alignment-pdf' &&
|
||||||
|
Number(field.positionX).toFixed(2) === f.positionX.toFixed(2) &&
|
||||||
|
Number(field.positionY).toFixed(2) === f.positionY.toFixed(2) &&
|
||||||
|
Number(field.width).toFixed(2) === f.width.toFixed(2) &&
|
||||||
|
Number(field.height).toFixed(2) === f.height.toFixed(2),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!foundField) {
|
||||||
|
foundField = FIELD_META_TEST_FIELDS.find(
|
||||||
|
(f) =>
|
||||||
|
field.page === f.page &&
|
||||||
|
field.envelopeItem.title === 'field-meta' &&
|
||||||
|
Number(field.positionX).toFixed(2) === f.positionX.toFixed(2) &&
|
||||||
|
Number(field.positionY).toFixed(2) === f.positionY.toFixed(2) &&
|
||||||
|
Number(field.width).toFixed(2) === f.width.toFixed(2) &&
|
||||||
|
Number(field.height).toFixed(2) === f.height.toFixed(2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!foundField) {
|
||||||
|
throw new Error('Field not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.field.update({
|
||||||
|
where: {
|
||||||
|
id: field.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
inserted: true,
|
||||||
|
customText: foundField.customText,
|
||||||
|
signature: foundField.signature
|
||||||
|
? {
|
||||||
|
create: {
|
||||||
|
recipientId: envelope.recipients[0].id,
|
||||||
|
signatureImageAsBase64: isBase64Image(foundField.signature)
|
||||||
|
? foundField.signature
|
||||||
|
: null,
|
||||||
|
typedSignature: isBase64Image(foundField.signature) ? null : foundField.signature,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const recipientToken = envelope.recipients[0].token;
|
||||||
|
const signUrl = `/sign/${recipientToken}`;
|
||||||
|
|
||||||
await apiSignin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
@ -97,7 +289,7 @@ test.skip('field placement visual regression', async ({ page }, testInfo) => {
|
|||||||
const documentUrl = getEnvelopeItemPdfUrl({
|
const documentUrl = getEnvelopeItemPdfUrl({
|
||||||
type: 'download',
|
type: 'download',
|
||||||
envelopeItem: item,
|
envelopeItem: item,
|
||||||
token,
|
token: recipientToken,
|
||||||
version: 'signed',
|
version: 'signed',
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -289,7 +481,7 @@ const compareSignedPdfWithImages = async ({
|
|||||||
// Expect the certificate to NOT be blank. Since the storedImage is blank.
|
// Expect the certificate to NOT be blank. Since the storedImage is blank.
|
||||||
expect.soft(comparison).toBeGreaterThan(20000);
|
expect.soft(comparison).toBeGreaterThan(20000);
|
||||||
} else {
|
} else {
|
||||||
expect.soft(comparison).toEqual(0);
|
expect.soft(comparison).toBeLessThan(2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { seedTeamMember } from '@documenso/prisma/seed/teams';
|
|||||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||||
|
|
||||||
import { apiSignin } from '../fixtures/authentication';
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
|
import { expectTextToBeVisible } from '../fixtures/generic';
|
||||||
|
|
||||||
test.describe.configure({ mode: 'parallel' });
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
@ -81,20 +82,23 @@ test('[TEAMS]: can create a document inside a document folder', async ({ page })
|
|||||||
redirectPath: `/t/${team.url}/documents/f/${teamFolder.id}`,
|
redirectPath: `/t/${team.url}/documents/f/${teamFolder.id}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const fileInput = page.locator('input[type="file"]').nth(1);
|
// Upload document.
|
||||||
await fileInput.waitFor({ state: 'attached' });
|
const [fileChooser] = await Promise.all([
|
||||||
|
page.waitForEvent('filechooser'),
|
||||||
|
page.getByRole('button', { name: 'Document (Legacy)' }).click(),
|
||||||
|
]);
|
||||||
|
|
||||||
await fileInput.setInputFiles(
|
await fileChooser.setFiles(
|
||||||
path.join(__dirname, '../../../assets/documenso-supporter-pledge.pdf'),
|
path.join(__dirname, '../../../assets/documenso-supporter-pledge.pdf'),
|
||||||
);
|
);
|
||||||
|
|
||||||
await page.waitForTimeout(3000);
|
await page.waitForTimeout(3000);
|
||||||
|
|
||||||
await expect(page.getByText('documenso-supporter-pledge.pdf')).toBeVisible();
|
await expectTextToBeVisible(page, 'documenso-supporter-pledge.pdf');
|
||||||
|
|
||||||
await page.goto(`/t/${team.url}/documents/f/${teamFolder.id}`);
|
await page.goto(`/t/${team.url}/documents/f/${teamFolder.id}`);
|
||||||
|
|
||||||
await expect(page.getByText('documenso-supporter-pledge.pdf')).toBeVisible();
|
await expectTextToBeVisible(page, 'documenso-supporter-pledge.pdf');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('[TEAMS]: can pin a document folder', async ({ page }) => {
|
test('[TEAMS]: can pin a document folder', async ({ page }) => {
|
||||||
@ -368,7 +372,7 @@ test('[TEAMS]: can create a template inside a template folder', async ({ page })
|
|||||||
|
|
||||||
await expect(page.getByText('Team Client Templates')).toBeVisible();
|
await expect(page.getByText('Team Client Templates')).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'New Template' }).click();
|
await page.getByRole('button', { name: 'Template (Legacy)' }).click();
|
||||||
|
|
||||||
await page.getByText('Upload Template Document').click();
|
await page.getByText('Upload Template Document').click();
|
||||||
|
|
||||||
@ -382,11 +386,11 @@ test('[TEAMS]: can create a template inside a template folder', async ({ page })
|
|||||||
await page.waitForTimeout(3000);
|
await page.waitForTimeout(3000);
|
||||||
|
|
||||||
// Expect redirect.
|
// Expect redirect.
|
||||||
await expect(page.getByText('documenso-supporter-pledge.pdf')).toBeVisible();
|
await expectTextToBeVisible(page, 'documenso-supporter-pledge.pdf');
|
||||||
|
|
||||||
// Return to folder and verify file is visible.
|
// Return to folder and verify file is visible.
|
||||||
await page.goto(`/t/${team.url}/templates/f/${folder.id}`);
|
await page.goto(`/t/${team.url}/templates/f/${folder.id}`);
|
||||||
await expect(page.getByText('documenso-supporter-pledge.pdf')).toBeVisible();
|
await expectTextToBeVisible(page, 'documenso-supporter-pledge.pdf');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('[TEAMS]: can pin a template folder', async ({ page }) => {
|
test('[TEAMS]: can pin a template folder', async ({ page }) => {
|
||||||
@ -842,7 +846,7 @@ test('[TEAMS]: documents inherit folder visibility', async ({ page }) => {
|
|||||||
// Upload document.
|
// Upload document.
|
||||||
const [fileChooser] = await Promise.all([
|
const [fileChooser] = await Promise.all([
|
||||||
page.waitForEvent('filechooser'),
|
page.waitForEvent('filechooser'),
|
||||||
page.getByRole('button', { name: 'Upload Document' }).click(),
|
page.getByRole('button', { name: 'Document (Legacy)' }).click(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await fileChooser.setFiles(
|
await fileChooser.setFiles(
|
||||||
@ -851,7 +855,7 @@ test('[TEAMS]: documents inherit folder visibility', async ({ page }) => {
|
|||||||
|
|
||||||
await page.waitForTimeout(3000);
|
await page.waitForTimeout(3000);
|
||||||
|
|
||||||
await expect(page.getByText('documenso-supporter-pledge.pdf')).toBeVisible();
|
await expectTextToBeVisible(page, 'documenso-supporter-pledge.pdf');
|
||||||
|
|
||||||
await expect(page.getByRole('combobox').filter({ hasText: 'Admins only' })).toBeVisible();
|
await expect(page.getByRole('combobox').filter({ hasText: 'Admins only' })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
BIN
packages/app-tests/visual-regression/alignment-pdf-0.png
Normal file
|
After Width: | Height: | Size: 139 KiB |
BIN
packages/app-tests/visual-regression/alignment-pdf-1.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
packages/app-tests/visual-regression/alignment-pdf-2.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
packages/app-tests/visual-regression/alignment-pdf-3.png
Normal file
|
After Width: | Height: | Size: 117 KiB |
BIN
packages/app-tests/visual-regression/blank-certificate.png
Normal file
|
After Width: | Height: | Size: 144 KiB |
BIN
packages/app-tests/visual-regression/field-meta-pdf-0.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
packages/app-tests/visual-regression/field-meta-pdf-1.png
Normal file
|
After Width: | Height: | Size: 127 KiB |
BIN
packages/app-tests/visual-regression/field-meta-pdf-2.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
packages/app-tests/visual-regression/field-meta-pdf-3.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
packages/app-tests/visual-regression/field-meta-pdf-4.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
packages/app-tests/visual-regression/field-meta-pdf-5.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
packages/app-tests/visual-regression/field-meta-pdf-6.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
packages/app-tests/visual-regression/field-meta-pdf-7.png
Normal file
|
After Width: | Height: | Size: 117 KiB |
@ -1,4 +1,4 @@
|
|||||||
import { Trans } from '@lingui/react/macro';
|
import { Plural, Trans } from '@lingui/react/macro';
|
||||||
|
|
||||||
import { Heading, Img, Section, Text } from '../components';
|
import { Heading, Img, Section, Text } from '../components';
|
||||||
|
|
||||||
@ -46,7 +46,11 @@ export const TemplateAccessAuth2FA = ({
|
|||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Text className="mt-4 text-center text-sm text-slate-600">
|
<Text className="mt-4 text-center text-sm text-slate-600">
|
||||||
<Trans>This code will expire in {expiresInMinutes} minutes.</Trans>
|
<Plural
|
||||||
|
value={expiresInMinutes}
|
||||||
|
one="This code will expire in # minute."
|
||||||
|
other="This code will expire in # minutes."
|
||||||
|
/>
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text className="mt-4 text-center text-sm text-slate-500">
|
<Text className="mt-4 text-center text-sm text-slate-500">
|
||||||
|
|||||||
@ -11,7 +11,7 @@ export const validateNumberField = (
|
|||||||
|
|
||||||
const { minValue, maxValue, readOnly, required, numberFormat, fontSize } = fieldMeta || {};
|
const { minValue, maxValue, readOnly, required, numberFormat, fontSize } = fieldMeta || {};
|
||||||
|
|
||||||
if (numberFormat) {
|
if (numberFormat && value.length > 0) {
|
||||||
const foundRegex = numberFormatValues.find((item) => item.value === numberFormat)?.regex;
|
const foundRegex = numberFormatValues.find((item) => item.value === numberFormat)?.regex;
|
||||||
|
|
||||||
if (!foundRegex) {
|
if (!foundRegex) {
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import type { Recipient } from '@prisma/client';
|
import type { Field, Recipient } from '@prisma/client';
|
||||||
import { FieldType } from '@prisma/client';
|
import { FieldType } from '@prisma/client';
|
||||||
import { useFieldArray, useForm } from 'react-hook-form';
|
import { useFieldArray, useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@ -63,6 +63,8 @@ type UseEditorFieldsResponse = {
|
|||||||
// Selected recipient
|
// Selected recipient
|
||||||
selectedRecipient: Recipient | null;
|
selectedRecipient: Recipient | null;
|
||||||
setSelectedRecipient: (recipientId: number | null) => void;
|
setSelectedRecipient: (recipientId: number | null) => void;
|
||||||
|
|
||||||
|
resetForm: (fields?: Field[]) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useEditorFields = ({
|
export const useEditorFields = ({
|
||||||
@ -72,24 +74,30 @@ export const useEditorFields = ({
|
|||||||
const [selectedFieldFormId, setSelectedFieldFormId] = useState<string | null>(null);
|
const [selectedFieldFormId, setSelectedFieldFormId] = useState<string | null>(null);
|
||||||
const [selectedRecipientId, setSelectedRecipientId] = useState<number | null>(null);
|
const [selectedRecipientId, setSelectedRecipientId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const generateDefaultValues = (fields?: Field[]) => {
|
||||||
|
const formFields = (fields || envelope.fields).map(
|
||||||
|
(field): TLocalField => ({
|
||||||
|
id: field.id,
|
||||||
|
formId: nanoid(),
|
||||||
|
envelopeItemId: field.envelopeItemId,
|
||||||
|
page: field.page,
|
||||||
|
type: field.type,
|
||||||
|
positionX: Number(field.positionX),
|
||||||
|
positionY: Number(field.positionY),
|
||||||
|
width: Number(field.width),
|
||||||
|
height: Number(field.height),
|
||||||
|
recipientId: field.recipientId,
|
||||||
|
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
fields: formFields,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const form = useForm<TEditorFieldsFormSchema>({
|
const form = useForm<TEditorFieldsFormSchema>({
|
||||||
defaultValues: {
|
defaultValues: generateDefaultValues(),
|
||||||
fields: envelope.fields.map(
|
|
||||||
(field): TLocalField => ({
|
|
||||||
id: field.id,
|
|
||||||
formId: nanoid(),
|
|
||||||
envelopeItemId: field.envelopeItemId,
|
|
||||||
page: field.page,
|
|
||||||
type: field.type,
|
|
||||||
positionX: Number(field.positionX),
|
|
||||||
positionY: Number(field.positionY),
|
|
||||||
width: Number(field.width),
|
|
||||||
height: Number(field.height),
|
|
||||||
recipientId: field.recipientId,
|
|
||||||
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
resolver: zodResolver(ZEditorFieldsFormSchema),
|
resolver: zodResolver(ZEditorFieldsFormSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -272,6 +280,10 @@ export const useEditorFields = ({
|
|||||||
setSelectedRecipientId(foundRecipient?.id ?? null);
|
setSelectedRecipientId(foundRecipient?.id ?? null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resetForm = (fields?: Field[]) => {
|
||||||
|
form.reset(generateDefaultValues(fields));
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Core state
|
// Core state
|
||||||
localFields,
|
localFields,
|
||||||
@ -295,6 +307,8 @@ export const useEditorFields = ({
|
|||||||
// Selected recipient
|
// Selected recipient
|
||||||
selectedRecipient,
|
selectedRecipient,
|
||||||
setSelectedRecipient,
|
setSelectedRecipient,
|
||||||
|
|
||||||
|
resetForm,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -107,6 +107,10 @@ export function usePageRenderer(renderFunction: RenderFunction) {
|
|||||||
stage: stage.current,
|
stage: stage.current,
|
||||||
pageLayer: pageLayer.current,
|
pageLayer: pageLayer.current,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void document.fonts.ready.then(function () {
|
||||||
|
pageLayer.current?.batchDraw();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@ -30,6 +30,8 @@ type EnvelopeRenderItem = TEnvelope['envelopeItems'][number];
|
|||||||
type EnvelopeRenderProviderValue = {
|
type EnvelopeRenderProviderValue = {
|
||||||
getPdfBuffer: (envelopeItemId: string) => FileData | null;
|
getPdfBuffer: (envelopeItemId: string) => FileData | null;
|
||||||
envelopeItems: EnvelopeRenderItem[];
|
envelopeItems: EnvelopeRenderItem[];
|
||||||
|
envelopeStatus: TEnvelope['status'];
|
||||||
|
envelopeType: TEnvelope['type'];
|
||||||
currentEnvelopeItem: EnvelopeRenderItem | null;
|
currentEnvelopeItem: EnvelopeRenderItem | null;
|
||||||
setCurrentEnvelopeItem: (envelopeItemId: string) => void;
|
setCurrentEnvelopeItem: (envelopeItemId: string) => void;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
@ -44,7 +46,7 @@ type EnvelopeRenderProviderValue = {
|
|||||||
interface EnvelopeRenderProviderProps {
|
interface EnvelopeRenderProviderProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
|
||||||
envelope: Pick<TEnvelope, 'envelopeItems'>;
|
envelope: Pick<TEnvelope, 'envelopeItems' | 'status' | 'type'>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional fields which are passed down to renderers for custom rendering needs.
|
* Optional fields which are passed down to renderers for custom rendering needs.
|
||||||
@ -100,7 +102,7 @@ export const EnvelopeRenderProvider = ({
|
|||||||
// Indexed by documentDataId.
|
// Indexed by documentDataId.
|
||||||
const [files, setFiles] = useState<Record<string, FileData>>({});
|
const [files, setFiles] = useState<Record<string, FileData>>({});
|
||||||
|
|
||||||
const [currentItem, setItem] = useState<EnvelopeRenderItem | null>(null);
|
const [currentItem, setCurrentItem] = useState<EnvelopeRenderItem | null>(null);
|
||||||
|
|
||||||
const [renderError, setRenderError] = useState<boolean>(false);
|
const [renderError, setRenderError] = useState<boolean>(false);
|
||||||
|
|
||||||
@ -163,11 +165,15 @@ export const EnvelopeRenderProvider = ({
|
|||||||
const setCurrentEnvelopeItem = (envelopeItemId: string) => {
|
const setCurrentEnvelopeItem = (envelopeItemId: string) => {
|
||||||
const foundItem = envelope.envelopeItems.find((item) => item.id === envelopeItemId);
|
const foundItem = envelope.envelopeItems.find((item) => item.id === envelopeItemId);
|
||||||
|
|
||||||
setItem(foundItem ?? null);
|
setCurrentItem(foundItem ?? null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Set the selected item to the first item if none is set.
|
// Set the selected item to the first item if none is set.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (currentItem && !envelopeItems.some((item) => item.id === currentItem.id)) {
|
||||||
|
setCurrentItem(null);
|
||||||
|
}
|
||||||
|
|
||||||
if (!currentItem && envelopeItems.length > 0) {
|
if (!currentItem && envelopeItems.length > 0) {
|
||||||
setCurrentEnvelopeItem(envelopeItems[0].id);
|
setCurrentEnvelopeItem(envelopeItems[0].id);
|
||||||
}
|
}
|
||||||
@ -203,6 +209,8 @@ export const EnvelopeRenderProvider = ({
|
|||||||
value={{
|
value={{
|
||||||
getPdfBuffer,
|
getPdfBuffer,
|
||||||
envelopeItems,
|
envelopeItems,
|
||||||
|
envelopeStatus: envelope.status,
|
||||||
|
envelopeType: envelope.type,
|
||||||
currentEnvelopeItem: currentItem,
|
currentEnvelopeItem: currentItem,
|
||||||
setCurrentEnvelopeItem,
|
setCurrentEnvelopeItem,
|
||||||
fields: fields ?? [],
|
fields: fields ?? [],
|
||||||
|
|||||||
@ -15,5 +15,3 @@ export const API_V2_BETA_URL = '/api/v2-beta';
|
|||||||
export const API_V2_URL = '/api/v2';
|
export const API_V2_URL = '/api/v2';
|
||||||
|
|
||||||
export const SUPPORT_EMAIL = env('NEXT_PUBLIC_SUPPORT_EMAIL') ?? 'support@documenso.com';
|
export const SUPPORT_EMAIL = env('NEXT_PUBLIC_SUPPORT_EMAIL') ?? 'support@documenso.com';
|
||||||
|
|
||||||
export const IS_ENVELOPES_ENABLED = env('NEXT_PUBLIC_FEATURE_ENVELOPES_ENABLED') === 'true';
|
|
||||||
|
|||||||
@ -32,6 +32,7 @@ export type JobDefinition<Name extends string = string, Schema = any> = {
|
|||||||
name: string;
|
name: string;
|
||||||
version: string;
|
version: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
optimizeParallelism?: boolean;
|
||||||
trigger: {
|
trigger: {
|
||||||
name: Name;
|
name: Name;
|
||||||
schema?: z.ZodType<Schema>;
|
schema?: z.ZodType<Schema>;
|
||||||
|
|||||||
@ -40,6 +40,7 @@ export class InngestJobProvider extends BaseJobProvider {
|
|||||||
{
|
{
|
||||||
id: job.id,
|
id: job.id,
|
||||||
name: job.name,
|
name: job.name,
|
||||||
|
optimizeParallelism: job.optimizeParallelism ?? false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
event: job.trigger.name,
|
event: job.trigger.name,
|
||||||
|
|||||||
@ -189,29 +189,65 @@ export const run = async ({
|
|||||||
settings,
|
settings,
|
||||||
});
|
});
|
||||||
|
|
||||||
const newDocumentData = await Promise.all(
|
// !: The commented out code is our desired implementation but we're seemingly
|
||||||
envelopeItems.map(async (envelopeItem) =>
|
// !: running into issues with inngest parallelism in production.
|
||||||
io.runTask(`decorate-and-sign-envelope-item-${envelopeItem.id}`, async () => {
|
// !: Until this is resolved we will do this sequentially which is slower but
|
||||||
const envelopeItemFields = envelope.envelopeItems.find(
|
// !: will actually work.
|
||||||
(item) => item.id === envelopeItem.id,
|
// const decoratePromises: Array<Promise<{ oldDocumentDataId: string; newDocumentDataId: string }>> =
|
||||||
)?.field;
|
// [];
|
||||||
|
|
||||||
if (!envelopeItemFields) {
|
// for (const envelopeItem of envelopeItems) {
|
||||||
throw new Error(`Envelope item fields not found for envelope item ${envelopeItem.id}`);
|
// const task = io.runTask(`decorate-${envelopeItem.id}`, async () => {
|
||||||
}
|
// const envelopeItemFields = envelope.envelopeItems.find(
|
||||||
|
// (item) => item.id === envelopeItem.id,
|
||||||
|
// )?.field;
|
||||||
|
|
||||||
return decorateAndSignPdf({
|
// if (!envelopeItemFields) {
|
||||||
envelope,
|
// throw new Error(`Envelope item fields not found for envelope item ${envelopeItem.id}`);
|
||||||
envelopeItem,
|
// }
|
||||||
envelopeItemFields,
|
|
||||||
isRejected,
|
// return decorateAndSignPdf({
|
||||||
rejectionReason,
|
// envelope,
|
||||||
certificateData,
|
// envelopeItem,
|
||||||
auditLogData,
|
// envelopeItemFields,
|
||||||
});
|
// isRejected,
|
||||||
}),
|
// rejectionReason,
|
||||||
),
|
// certificateData,
|
||||||
);
|
// auditLogData,
|
||||||
|
// });
|
||||||
|
// });
|
||||||
|
|
||||||
|
// decoratePromises.push(task);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const newDocumentData = await Promise.all(decoratePromises);
|
||||||
|
|
||||||
|
// TODO: Remove once parallelization is working
|
||||||
|
const newDocumentData: Array<{ oldDocumentDataId: string; newDocumentDataId: string }> = [];
|
||||||
|
|
||||||
|
for (const envelopeItem of envelopeItems) {
|
||||||
|
const result = await io.runTask(`decorate-${envelopeItem.id}`, async () => {
|
||||||
|
const envelopeItemFields = envelope.envelopeItems.find(
|
||||||
|
(item) => item.id === envelopeItem.id,
|
||||||
|
)?.field;
|
||||||
|
|
||||||
|
if (!envelopeItemFields) {
|
||||||
|
throw new Error(`Envelope item fields not found for envelope item ${envelopeItem.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return decorateAndSignPdf({
|
||||||
|
envelope,
|
||||||
|
envelopeItem,
|
||||||
|
envelopeItemFields,
|
||||||
|
isRejected,
|
||||||
|
rejectionReason,
|
||||||
|
certificateData,
|
||||||
|
auditLogData,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
newDocumentData.push(result);
|
||||||
|
}
|
||||||
|
|
||||||
const postHog = PostHogServerClient();
|
const postHog = PostHogServerClient();
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,7 @@ export const SEAL_DOCUMENT_JOB_DEFINITION = {
|
|||||||
id: SEAL_DOCUMENT_JOB_DEFINITION_ID,
|
id: SEAL_DOCUMENT_JOB_DEFINITION_ID,
|
||||||
name: 'Seal Document',
|
name: 'Seal Document',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
|
optimizeParallelism: true,
|
||||||
trigger: {
|
trigger: {
|
||||||
name: SEAL_DOCUMENT_JOB_DEFINITION_ID,
|
name: SEAL_DOCUMENT_JOB_DEFINITION_ID,
|
||||||
schema: SEAL_DOCUMENT_JOB_DEFINITION_SCHEMA,
|
schema: SEAL_DOCUMENT_JOB_DEFINITION_SCHEMA,
|
||||||
|
|||||||
@ -0,0 +1,363 @@
|
|||||||
|
import type { DocumentStatus } from '@prisma/client';
|
||||||
|
import { EnvelopeType } from '@prisma/client';
|
||||||
|
|
||||||
|
import type { DateRange } from '@documenso/lib/types/search-params';
|
||||||
|
import { kyselyPrisma, sql } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export type OrganisationSummary = {
|
||||||
|
totalTeams: number;
|
||||||
|
totalMembers: number;
|
||||||
|
totalDocuments: number;
|
||||||
|
activeDocuments: number;
|
||||||
|
completedDocuments: number;
|
||||||
|
volumeThisPeriod: number;
|
||||||
|
volumeAllTime: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrganisationDetailedInsights = {
|
||||||
|
teams: TeamInsights[];
|
||||||
|
users: UserInsights[];
|
||||||
|
documents: DocumentInsights[];
|
||||||
|
totalPages: number;
|
||||||
|
summary?: OrganisationSummary;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TeamInsights = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
memberCount: number;
|
||||||
|
documentCount: number;
|
||||||
|
createdAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserInsights = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
documentCount: number;
|
||||||
|
signedDocumentCount: number;
|
||||||
|
createdAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DocumentInsights = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: DocumentStatus;
|
||||||
|
teamName: string;
|
||||||
|
createdAt: Date;
|
||||||
|
completedAt: Date | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetOrganisationDetailedInsightsOptions = {
|
||||||
|
organisationId: string;
|
||||||
|
page?: number;
|
||||||
|
perPage?: number;
|
||||||
|
dateRange?: DateRange;
|
||||||
|
view: 'teams' | 'users' | 'documents';
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getOrganisationDetailedInsights({
|
||||||
|
organisationId,
|
||||||
|
page = 1,
|
||||||
|
perPage = 10,
|
||||||
|
dateRange = 'last30days',
|
||||||
|
view,
|
||||||
|
}: GetOrganisationDetailedInsightsOptions): Promise<OrganisationDetailedInsights> {
|
||||||
|
const offset = Math.max(page - 1, 0) * perPage;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
let createdAtFrom: Date | null = null;
|
||||||
|
|
||||||
|
switch (dateRange) {
|
||||||
|
case 'last30days': {
|
||||||
|
createdAtFrom = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'last90days': {
|
||||||
|
createdAtFrom = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'lastYear': {
|
||||||
|
createdAtFrom = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'allTime':
|
||||||
|
default:
|
||||||
|
createdAtFrom = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryData = await getOrganisationSummary(organisationId, createdAtFrom);
|
||||||
|
|
||||||
|
const viewData = await (async () => {
|
||||||
|
switch (view) {
|
||||||
|
case 'teams':
|
||||||
|
return await getTeamInsights(organisationId, offset, perPage, createdAtFrom);
|
||||||
|
case 'users':
|
||||||
|
return await getUserInsights(organisationId, offset, perPage, createdAtFrom);
|
||||||
|
case 'documents':
|
||||||
|
return await getDocumentInsights(organisationId, offset, perPage, createdAtFrom);
|
||||||
|
default:
|
||||||
|
throw new Error(`Invalid view: ${view}`);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return {
|
||||||
|
...viewData,
|
||||||
|
summary: summaryData,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTeamInsights(
|
||||||
|
organisationId: string,
|
||||||
|
offset: number,
|
||||||
|
perPage: number,
|
||||||
|
createdAtFrom: Date | null,
|
||||||
|
): Promise<OrganisationDetailedInsights> {
|
||||||
|
const teamsQuery = kyselyPrisma.$kysely
|
||||||
|
.selectFrom('Team as t')
|
||||||
|
.leftJoin('Envelope as e', (join) =>
|
||||||
|
join
|
||||||
|
.onRef('t.id', '=', 'e.teamId')
|
||||||
|
.on('e.deletedAt', 'is', null)
|
||||||
|
.on('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
|
||||||
|
)
|
||||||
|
.leftJoin('TeamGroup as tg', 'tg.teamId', 't.id')
|
||||||
|
.leftJoin('OrganisationGroup as og', 'og.id', 'tg.organisationGroupId')
|
||||||
|
.leftJoin('OrganisationGroupMember as ogm', 'ogm.groupId', 'og.id')
|
||||||
|
.leftJoin('OrganisationMember as om', 'om.id', 'ogm.organisationMemberId')
|
||||||
|
.where('t.organisationId', '=', organisationId)
|
||||||
|
.select([
|
||||||
|
't.id as id',
|
||||||
|
't.name as name',
|
||||||
|
't.createdAt as createdAt',
|
||||||
|
sql<number>`COUNT(DISTINCT om."userId")`.as('memberCount'),
|
||||||
|
(createdAtFrom
|
||||||
|
? sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND e."createdAt" >= ${createdAtFrom} THEN e.id END)`
|
||||||
|
: sql<number>`COUNT(DISTINCT e.id)`
|
||||||
|
).as('documentCount'),
|
||||||
|
])
|
||||||
|
.groupBy(['t.id', 't.name', 't.createdAt'])
|
||||||
|
.orderBy('documentCount', 'desc')
|
||||||
|
.limit(perPage)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
const countQuery = kyselyPrisma.$kysely
|
||||||
|
.selectFrom('Team as t')
|
||||||
|
.where('t.organisationId', '=', organisationId)
|
||||||
|
.select(({ fn }) => [fn.countAll().as('count')]);
|
||||||
|
|
||||||
|
const [teams, countResult] = await Promise.all([teamsQuery.execute(), countQuery.execute()]);
|
||||||
|
const count = Number(countResult[0]?.count || 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
teams: teams as TeamInsights[],
|
||||||
|
users: [],
|
||||||
|
documents: [],
|
||||||
|
totalPages: Math.ceil(Number(count) / perPage),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUserInsights(
|
||||||
|
organisationId: string,
|
||||||
|
offset: number,
|
||||||
|
perPage: number,
|
||||||
|
createdAtFrom: Date | null,
|
||||||
|
): Promise<OrganisationDetailedInsights> {
|
||||||
|
const usersBase = kyselyPrisma.$kysely
|
||||||
|
.selectFrom('OrganisationMember as om')
|
||||||
|
.innerJoin('User as u', 'u.id', 'om.userId')
|
||||||
|
.where('om.organisationId', '=', organisationId)
|
||||||
|
.leftJoin('Envelope as e', (join) =>
|
||||||
|
join
|
||||||
|
.onRef('e.userId', '=', 'u.id')
|
||||||
|
.on('e.deletedAt', 'is', null)
|
||||||
|
.on('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
|
||||||
|
)
|
||||||
|
.leftJoin('Team as td', (join) =>
|
||||||
|
join.onRef('td.id', '=', 'e.teamId').on('td.organisationId', '=', organisationId),
|
||||||
|
)
|
||||||
|
.leftJoin('Recipient as r', (join) =>
|
||||||
|
join.onRef('r.email', '=', 'u.email').on('r.signedAt', 'is not', null),
|
||||||
|
)
|
||||||
|
.leftJoin('Envelope as se', (join) =>
|
||||||
|
join
|
||||||
|
.onRef('se.id', '=', 'r.envelopeId')
|
||||||
|
.on('se.deletedAt', 'is', null)
|
||||||
|
.on('se.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
|
||||||
|
)
|
||||||
|
.leftJoin('Team as ts', (join) =>
|
||||||
|
join.onRef('ts.id', '=', 'se.teamId').on('ts.organisationId', '=', organisationId),
|
||||||
|
);
|
||||||
|
|
||||||
|
const usersQuery = usersBase
|
||||||
|
.select([
|
||||||
|
'u.id as id',
|
||||||
|
'u.name as name',
|
||||||
|
'u.email as email',
|
||||||
|
'u.createdAt as createdAt',
|
||||||
|
(createdAtFrom
|
||||||
|
? sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND td.id IS NOT NULL AND e."createdAt" >= ${createdAtFrom} THEN e.id END)`
|
||||||
|
: sql<number>`COUNT(DISTINCT CASE WHEN td.id IS NOT NULL THEN e.id END)`
|
||||||
|
).as('documentCount'),
|
||||||
|
(createdAtFrom
|
||||||
|
? sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND td.id IS NOT NULL AND e.status = 'COMPLETED' AND e."createdAt" >= ${createdAtFrom} THEN e.id END)`
|
||||||
|
: sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND td.id IS NOT NULL AND e.status = 'COMPLETED' THEN e.id END)`
|
||||||
|
).as('signedDocumentCount'),
|
||||||
|
])
|
||||||
|
.groupBy(['u.id', 'u.name', 'u.email', 'u.createdAt'])
|
||||||
|
.orderBy('u.createdAt', 'desc')
|
||||||
|
.limit(perPage)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
const countQuery = kyselyPrisma.$kysely
|
||||||
|
.selectFrom('OrganisationMember as om')
|
||||||
|
.innerJoin('User as u', 'u.id', 'om.userId')
|
||||||
|
.where('om.organisationId', '=', organisationId)
|
||||||
|
.select(({ fn }) => [fn.countAll().as('count')]);
|
||||||
|
|
||||||
|
const [users, countResult] = await Promise.all([usersQuery.execute(), countQuery.execute()]);
|
||||||
|
const count = Number(countResult[0]?.count || 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
teams: [],
|
||||||
|
users: users as UserInsights[],
|
||||||
|
documents: [],
|
||||||
|
totalPages: Math.ceil(Number(count) / perPage),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDocumentInsights(
|
||||||
|
organisationId: string,
|
||||||
|
offset: number,
|
||||||
|
perPage: number,
|
||||||
|
createdAtFrom: Date | null,
|
||||||
|
): Promise<OrganisationDetailedInsights> {
|
||||||
|
let documentsQuery = kyselyPrisma.$kysely
|
||||||
|
.selectFrom('Envelope as e')
|
||||||
|
.innerJoin('Team as t', 'e.teamId', 't.id')
|
||||||
|
.where('t.organisationId', '=', organisationId)
|
||||||
|
.where('e.deletedAt', 'is', null)
|
||||||
|
.where(() => sql`e.type = ${EnvelopeType.DOCUMENT}::"EnvelopeType"`);
|
||||||
|
|
||||||
|
if (createdAtFrom) {
|
||||||
|
documentsQuery = documentsQuery.where('e.createdAt', '>=', createdAtFrom);
|
||||||
|
}
|
||||||
|
|
||||||
|
documentsQuery = documentsQuery
|
||||||
|
.select([
|
||||||
|
'e.id as id',
|
||||||
|
'e.title as title',
|
||||||
|
'e.status as status',
|
||||||
|
'e.createdAt as createdAt',
|
||||||
|
'e.completedAt as completedAt',
|
||||||
|
't.name as teamName',
|
||||||
|
])
|
||||||
|
.orderBy('e.createdAt', 'desc')
|
||||||
|
.limit(perPage)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
let countQuery = kyselyPrisma.$kysely
|
||||||
|
.selectFrom('Envelope as e')
|
||||||
|
.innerJoin('Team as t', 'e.teamId', 't.id')
|
||||||
|
.where('t.organisationId', '=', organisationId)
|
||||||
|
.where('e.deletedAt', 'is', null)
|
||||||
|
.where(() => sql`e.type = ${EnvelopeType.DOCUMENT}::"EnvelopeType"`);
|
||||||
|
|
||||||
|
if (createdAtFrom) {
|
||||||
|
countQuery = countQuery.where('e.createdAt', '>=', createdAtFrom);
|
||||||
|
}
|
||||||
|
|
||||||
|
countQuery = countQuery.select(({ fn }) => [fn.countAll().as('count')]);
|
||||||
|
|
||||||
|
const [documents, countResult] = await Promise.all([
|
||||||
|
documentsQuery.execute(),
|
||||||
|
countQuery.execute(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const count = Number((countResult[0] as { count: number })?.count || 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
teams: [],
|
||||||
|
users: [],
|
||||||
|
documents: documents.map((doc) => ({
|
||||||
|
...doc,
|
||||||
|
id: String((doc as { id: number }).id),
|
||||||
|
})) as DocumentInsights[],
|
||||||
|
totalPages: Math.ceil(Number(count) / perPage),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOrganisationSummary(
|
||||||
|
organisationId: string,
|
||||||
|
createdAtFrom: Date | null,
|
||||||
|
): Promise<OrganisationSummary> {
|
||||||
|
const summaryQuery = kyselyPrisma.$kysely
|
||||||
|
.selectFrom('Organisation as o')
|
||||||
|
.where('o.id', '=', organisationId)
|
||||||
|
.select([
|
||||||
|
sql<number>`(SELECT COUNT(DISTINCT t2.id) FROM "Team" AS t2 WHERE t2."organisationId" = o.id)`.as(
|
||||||
|
'totalTeams',
|
||||||
|
),
|
||||||
|
sql<number>`(SELECT COUNT(DISTINCT om2."userId") FROM "OrganisationMember" AS om2 WHERE om2."organisationId" = o.id)`.as(
|
||||||
|
'totalMembers',
|
||||||
|
),
|
||||||
|
sql<number>`(
|
||||||
|
SELECT COUNT(DISTINCT e2.id)
|
||||||
|
FROM "Envelope" AS e2
|
||||||
|
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
|
||||||
|
WHERE t2."organisationId" = o.id AND e2."deletedAt" IS NULL AND e2.type = 'DOCUMENT'
|
||||||
|
)`.as('totalDocuments'),
|
||||||
|
sql<number>`(
|
||||||
|
SELECT COUNT(DISTINCT e2.id)
|
||||||
|
FROM "Envelope" AS e2
|
||||||
|
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
|
||||||
|
WHERE t2."organisationId" = o.id AND e2."deletedAt" IS NULL AND e2.type = 'DOCUMENT' AND e2.status IN ('DRAFT', 'PENDING')
|
||||||
|
)`.as('activeDocuments'),
|
||||||
|
sql<number>`(
|
||||||
|
SELECT COUNT(DISTINCT e2.id)
|
||||||
|
FROM "Envelope" AS e2
|
||||||
|
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
|
||||||
|
WHERE t2."organisationId" = o.id AND e2."deletedAt" IS NULL AND e2.type = 'DOCUMENT' AND e2.status = 'COMPLETED'
|
||||||
|
)`.as('completedDocuments'),
|
||||||
|
(createdAtFrom
|
||||||
|
? sql<number>`(
|
||||||
|
SELECT COUNT(DISTINCT e2.id)
|
||||||
|
FROM "Envelope" AS e2
|
||||||
|
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
|
||||||
|
WHERE t2."organisationId" = o.id
|
||||||
|
AND e2."deletedAt" IS NULL
|
||||||
|
AND e2.type = 'DOCUMENT'
|
||||||
|
AND e2.status = 'COMPLETED'
|
||||||
|
AND e2."createdAt" >= ${createdAtFrom}
|
||||||
|
)`
|
||||||
|
: sql<number>`(
|
||||||
|
SELECT COUNT(DISTINCT e2.id)
|
||||||
|
FROM "Envelope" AS e2
|
||||||
|
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
|
||||||
|
WHERE t2."organisationId" = o.id
|
||||||
|
AND e2."deletedAt" IS NULL
|
||||||
|
AND e2.type = 'DOCUMENT'
|
||||||
|
AND e2.status = 'COMPLETED'
|
||||||
|
)`
|
||||||
|
).as('volumeThisPeriod'),
|
||||||
|
sql<number>`(
|
||||||
|
SELECT COUNT(DISTINCT e2.id)
|
||||||
|
FROM "Envelope" AS e2
|
||||||
|
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
|
||||||
|
WHERE t2."organisationId" = o.id AND e2."deletedAt" IS NULL AND e2.type = 'DOCUMENT' AND e2.status = 'COMPLETED'
|
||||||
|
)`.as('volumeAllTime'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await summaryQuery.executeTakeFirst();
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalTeams: Number(result?.totalTeams || 0),
|
||||||
|
totalMembers: Number(result?.totalMembers || 0),
|
||||||
|
totalDocuments: Number(result?.totalDocuments || 0),
|
||||||
|
activeDocuments: Number(result?.activeDocuments || 0),
|
||||||
|
completedDocuments: Number(result?.completedDocuments || 0),
|
||||||
|
volumeThisPeriod: Number(result?.volumeThisPeriod || 0),
|
||||||
|
volumeAllTime: Number(result?.volumeAllTime || 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,13 +1,17 @@
|
|||||||
import { DocumentStatus, EnvelopeType, SubscriptionStatus } from '@prisma/client';
|
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||||
|
|
||||||
|
import type { DateRange } from '@documenso/lib/types/search-params';
|
||||||
import { kyselyPrisma, sql } from '@documenso/prisma';
|
import { kyselyPrisma, sql } from '@documenso/prisma';
|
||||||
|
|
||||||
export type SigningVolume = {
|
export type OrganisationInsights = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
signingVolume: number;
|
signingVolume: number;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
planId: string;
|
customerId: string | null;
|
||||||
|
subscriptionStatus?: string;
|
||||||
|
teamCount?: number;
|
||||||
|
memberCount?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetSigningVolumeOptions = {
|
export type GetSigningVolumeOptions = {
|
||||||
@ -28,28 +32,26 @@ export async function getSigningVolume({
|
|||||||
const offset = Math.max(page - 1, 0) * perPage;
|
const offset = Math.max(page - 1, 0) * perPage;
|
||||||
|
|
||||||
let findQuery = kyselyPrisma.$kysely
|
let findQuery = kyselyPrisma.$kysely
|
||||||
.selectFrom('Subscription as s')
|
.selectFrom('Organisation as o')
|
||||||
.innerJoin('Organisation as o', 's.organisationId', 'o.id')
|
|
||||||
.leftJoin('Team as t', 'o.id', 't.organisationId')
|
.leftJoin('Team as t', 'o.id', 't.organisationId')
|
||||||
.leftJoin('Envelope as e', (join) =>
|
.leftJoin('Envelope as e', (join) =>
|
||||||
join
|
join
|
||||||
.onRef('t.id', '=', 'e.teamId')
|
.onRef('t.id', '=', 'e.teamId')
|
||||||
.on('e.status', '=', sql.lit(DocumentStatus.COMPLETED))
|
.on('e.status', '=', sql.lit(DocumentStatus.COMPLETED))
|
||||||
.on('e.deletedAt', 'is', null),
|
.on('e.deletedAt', 'is', null)
|
||||||
|
.on('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
|
||||||
)
|
)
|
||||||
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
|
|
||||||
.where((eb) =>
|
.where((eb) =>
|
||||||
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
|
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
|
||||||
)
|
)
|
||||||
.where('e.type', '=', EnvelopeType.DOCUMENT)
|
|
||||||
.select([
|
.select([
|
||||||
's.id as id',
|
'o.id as id',
|
||||||
's.createdAt as createdAt',
|
'o.createdAt as createdAt',
|
||||||
's.planId as planId',
|
'o.customerId as customerId',
|
||||||
sql<string>`COALESCE(o.name, 'Unknown')`.as('name'),
|
sql<string>`COALESCE(o.name, 'Unknown')`.as('name'),
|
||||||
sql<number>`COUNT(DISTINCT e.id)`.as('signingVolume'),
|
sql<number>`COUNT(DISTINCT e.id)`.as('signingVolume'),
|
||||||
])
|
])
|
||||||
.groupBy(['s.id', 'o.name']);
|
.groupBy(['o.id', 'o.name', 'o.customerId']);
|
||||||
|
|
||||||
switch (sortBy) {
|
switch (sortBy) {
|
||||||
case 'name':
|
case 'name':
|
||||||
@ -68,19 +70,127 @@ export async function getSigningVolume({
|
|||||||
findQuery = findQuery.limit(perPage).offset(offset);
|
findQuery = findQuery.limit(perPage).offset(offset);
|
||||||
|
|
||||||
const countQuery = kyselyPrisma.$kysely
|
const countQuery = kyselyPrisma.$kysely
|
||||||
.selectFrom('Subscription as s')
|
.selectFrom('Organisation as o')
|
||||||
.innerJoin('Organisation as o', 's.organisationId', 'o.id')
|
|
||||||
.leftJoin('Team as t', 'o.id', 't.organisationId')
|
.leftJoin('Team as t', 'o.id', 't.organisationId')
|
||||||
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
|
|
||||||
.where((eb) =>
|
.where((eb) =>
|
||||||
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
|
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
|
||||||
)
|
)
|
||||||
.select(({ fn }) => [fn.countAll().as('count')]);
|
.select(() => [sql<number>`COUNT(DISTINCT o.id)`.as('count')]);
|
||||||
|
|
||||||
const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]);
|
const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
leaderboard: results,
|
organisations: results,
|
||||||
|
totalPages: Math.ceil(Number(count) / perPage),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetOrganisationInsightsOptions = GetSigningVolumeOptions & {
|
||||||
|
dateRange?: DateRange;
|
||||||
|
startDate?: Date;
|
||||||
|
endDate?: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getOrganisationInsights({
|
||||||
|
search = '',
|
||||||
|
page = 1,
|
||||||
|
perPage = 10,
|
||||||
|
sortBy = 'signingVolume',
|
||||||
|
sortOrder = 'desc',
|
||||||
|
dateRange = 'last30days',
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
}: GetOrganisationInsightsOptions) {
|
||||||
|
const offset = Math.max(page - 1, 0) * perPage;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
let dateCondition = sql`1=1`;
|
||||||
|
|
||||||
|
if (startDate && endDate) {
|
||||||
|
dateCondition = sql`e."createdAt" >= ${startDate} AND e."createdAt" <= ${endDate}`;
|
||||||
|
} else {
|
||||||
|
switch (dateRange) {
|
||||||
|
case 'last30days': {
|
||||||
|
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||||
|
dateCondition = sql`e."createdAt" >= ${thirtyDaysAgo}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'last90days': {
|
||||||
|
const ninetyDaysAgo = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
|
||||||
|
dateCondition = sql`e."createdAt" >= ${ninetyDaysAgo}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'lastYear': {
|
||||||
|
const oneYearAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
|
||||||
|
dateCondition = sql`e."createdAt" >= ${oneYearAgo}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'allTime':
|
||||||
|
default:
|
||||||
|
dateCondition = sql`1=1`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let findQuery = kyselyPrisma.$kysely
|
||||||
|
.selectFrom('Organisation as o')
|
||||||
|
.leftJoin('Team as t', 'o.id', 't.organisationId')
|
||||||
|
.leftJoin('Envelope as e', (join) =>
|
||||||
|
join
|
||||||
|
.onRef('t.id', '=', 'e.teamId')
|
||||||
|
.on('e.status', '=', sql.lit(DocumentStatus.COMPLETED))
|
||||||
|
.on('e.deletedAt', 'is', null)
|
||||||
|
.on('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
|
||||||
|
)
|
||||||
|
.leftJoin('OrganisationMember as om', 'o.id', 'om.organisationId')
|
||||||
|
.leftJoin('Subscription as s', 'o.id', 's.organisationId')
|
||||||
|
.where((eb) =>
|
||||||
|
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
|
||||||
|
)
|
||||||
|
.select([
|
||||||
|
'o.id as id',
|
||||||
|
'o.createdAt as createdAt',
|
||||||
|
'o.customerId as customerId',
|
||||||
|
sql<string>`COALESCE(o.name, 'Unknown')`.as('name'),
|
||||||
|
sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND ${dateCondition} THEN e.id END)`.as(
|
||||||
|
'signingVolume',
|
||||||
|
),
|
||||||
|
sql<number>`GREATEST(COUNT(DISTINCT t.id), 1)`.as('teamCount'),
|
||||||
|
sql<number>`COUNT(DISTINCT om."userId")`.as('memberCount'),
|
||||||
|
sql<string>`CASE WHEN s.status IS NOT NULL THEN s.status ELSE NULL END`.as(
|
||||||
|
'subscriptionStatus',
|
||||||
|
),
|
||||||
|
])
|
||||||
|
.groupBy(['o.id', 'o.name', 'o.customerId', 's.status']);
|
||||||
|
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'name':
|
||||||
|
findQuery = findQuery.orderBy('name', sortOrder);
|
||||||
|
break;
|
||||||
|
case 'createdAt':
|
||||||
|
findQuery = findQuery.orderBy('createdAt', sortOrder);
|
||||||
|
break;
|
||||||
|
case 'signingVolume':
|
||||||
|
findQuery = findQuery.orderBy('signingVolume', sortOrder);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
findQuery = findQuery.orderBy('signingVolume', 'desc');
|
||||||
|
}
|
||||||
|
|
||||||
|
findQuery = findQuery.limit(perPage).offset(offset);
|
||||||
|
|
||||||
|
const countQuery = kyselyPrisma.$kysely
|
||||||
|
.selectFrom('Organisation as o')
|
||||||
|
.leftJoin('Team as t', 'o.id', 't.organisationId')
|
||||||
|
.where((eb) =>
|
||||||
|
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
|
||||||
|
)
|
||||||
|
.select(() => [sql<number>`COUNT(DISTINCT o.id)`.as('count')]);
|
||||||
|
|
||||||
|
const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
organisations: results,
|
||||||
totalPages: Math.ceil(Number(count) / perPage),
|
totalPages: Math.ceil(Number(count) / perPage),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,7 +19,7 @@ import { prisma } from '@documenso/prisma';
|
|||||||
|
|
||||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
import { jobs } from '../../jobs/client';
|
import { jobs } from '../../jobs/client';
|
||||||
import type { TRecipientAccessAuth, TRecipientActionAuth } from '../../types/document-auth';
|
import type { TRecipientAccessAuth } from '../../types/document-auth';
|
||||||
import { DocumentAuth } from '../../types/document-auth';
|
import { DocumentAuth } from '../../types/document-auth';
|
||||||
import {
|
import {
|
||||||
ZWebhookDocumentSchema,
|
ZWebhookDocumentSchema,
|
||||||
@ -37,7 +37,6 @@ export type CompleteDocumentWithTokenOptions = {
|
|||||||
token: string;
|
token: string;
|
||||||
id: EnvelopeIdOptions;
|
id: EnvelopeIdOptions;
|
||||||
userId?: number;
|
userId?: number;
|
||||||
authOptions?: TRecipientActionAuth;
|
|
||||||
accessAuthOptions?: TRecipientAccessAuth;
|
accessAuthOptions?: TRecipientAccessAuth;
|
||||||
requestMetadata?: RequestMetadata;
|
requestMetadata?: RequestMetadata;
|
||||||
nextSigner?: {
|
nextSigner?: {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import type { DocumentData, Envelope, EnvelopeItem } from '@prisma/client';
|
import type { DocumentData, Envelope, EnvelopeItem, Field } from '@prisma/client';
|
||||||
import {
|
import {
|
||||||
DocumentSigningOrder,
|
DocumentSigningOrder,
|
||||||
DocumentStatus,
|
DocumentStatus,
|
||||||
@ -24,7 +24,9 @@ import {
|
|||||||
ZCheckboxFieldMeta,
|
ZCheckboxFieldMeta,
|
||||||
ZDropdownFieldMeta,
|
ZDropdownFieldMeta,
|
||||||
ZFieldAndMetaSchema,
|
ZFieldAndMetaSchema,
|
||||||
|
ZNumberFieldMeta,
|
||||||
ZRadioFieldMeta,
|
ZRadioFieldMeta,
|
||||||
|
ZTextFieldMeta,
|
||||||
} from '../../types/field-meta';
|
} from '../../types/field-meta';
|
||||||
import {
|
import {
|
||||||
ZWebhookDocumentSchema,
|
ZWebhookDocumentSchema,
|
||||||
@ -182,80 +184,19 @@ export const sendDocument = async ({
|
|||||||
// Validate and autoinsert fields for V2 envelopes.
|
// Validate and autoinsert fields for V2 envelopes.
|
||||||
if (envelope.internalVersion === 2) {
|
if (envelope.internalVersion === 2) {
|
||||||
for (const unknownField of envelope.fields) {
|
for (const unknownField of envelope.fields) {
|
||||||
const parsedField = ZFieldAndMetaSchema.safeParse(unknownField);
|
const recipient = envelope.recipients.find((r) => r.id === unknownField.recipientId);
|
||||||
|
|
||||||
if (parsedField.error) {
|
if (!recipient) {
|
||||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||||
message: 'One or more fields have invalid metadata. Error: ' + parsedField.error.message,
|
message: 'Recipient not found',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const field = parsedField.data;
|
const fieldToAutoInsert = extractFieldAutoInsertValues(unknownField);
|
||||||
const fieldId = unknownField.id;
|
|
||||||
|
|
||||||
if (field.type === FieldType.RADIO) {
|
// Only auto-insert fields if the recipient has not been sent the document yet.
|
||||||
const { values = [] } = ZRadioFieldMeta.parse(field.fieldMeta);
|
if (fieldToAutoInsert && recipient.sendStatus !== SendStatus.SENT) {
|
||||||
|
fieldsToAutoInsert.push(fieldToAutoInsert);
|
||||||
const checkedItemIndex = values.findIndex((value) => value.checked);
|
|
||||||
|
|
||||||
if (checkedItemIndex !== -1) {
|
|
||||||
fieldsToAutoInsert.push({
|
|
||||||
fieldId,
|
|
||||||
customText: toRadioCustomText(checkedItemIndex),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (field.type === FieldType.DROPDOWN) {
|
|
||||||
const { defaultValue, values = [] } = ZDropdownFieldMeta.parse(field.fieldMeta);
|
|
||||||
|
|
||||||
if (defaultValue && values.some((value) => value.value === defaultValue)) {
|
|
||||||
fieldsToAutoInsert.push({
|
|
||||||
fieldId,
|
|
||||||
customText: defaultValue,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (field.type === FieldType.CHECKBOX) {
|
|
||||||
const {
|
|
||||||
values = [],
|
|
||||||
validationRule,
|
|
||||||
validationLength,
|
|
||||||
} = ZCheckboxFieldMeta.parse(field.fieldMeta);
|
|
||||||
|
|
||||||
const checkedIndices: number[] = [];
|
|
||||||
|
|
||||||
values.forEach((value, i) => {
|
|
||||||
if (value.checked) {
|
|
||||||
checkedIndices.push(i);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let isValid = true;
|
|
||||||
|
|
||||||
if (validationRule && validationLength) {
|
|
||||||
const validation = checkboxValidationSigns.find((sign) => sign.label === validationRule);
|
|
||||||
|
|
||||||
if (!validation) {
|
|
||||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
|
||||||
message: 'Invalid checkbox validation rule',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
isValid = validateCheckboxLength(
|
|
||||||
checkedIndices.length,
|
|
||||||
validation.value,
|
|
||||||
validationLength,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isValid && checkedIndices.length > 0) {
|
|
||||||
fieldsToAutoInsert.push({
|
|
||||||
fieldId,
|
|
||||||
customText: toCheckboxCustomText(checkedIndices),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -275,6 +216,7 @@ export const sendDocument = async ({
|
|||||||
if (envelope.internalVersion === 2) {
|
if (envelope.internalVersion === 2) {
|
||||||
const autoInsertedFields = await Promise.all(
|
const autoInsertedFields = await Promise.all(
|
||||||
fieldsToAutoInsert.map(async (field) => {
|
fieldsToAutoInsert.map(async (field) => {
|
||||||
|
// Warning: Only auto-insert fields if the recipient has not been sent the document yet.
|
||||||
return await tx.field.update({
|
return await tx.field.update({
|
||||||
where: {
|
where: {
|
||||||
id: field.fieldId,
|
id: field.fieldId,
|
||||||
@ -387,3 +329,113 @@ const injectFormValuesIntoDocument = async (
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the auto insertion values for a given field.
|
||||||
|
*
|
||||||
|
* If field is not auto insertable, returns `null`.
|
||||||
|
*/
|
||||||
|
export const extractFieldAutoInsertValues = (
|
||||||
|
unknownField: Field,
|
||||||
|
): { fieldId: number; customText: string } | null => {
|
||||||
|
const parsedField = ZFieldAndMetaSchema.safeParse(unknownField);
|
||||||
|
|
||||||
|
if (parsedField.error) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||||
|
message: 'One or more fields have invalid metadata. Error: ' + parsedField.error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const field = parsedField.data;
|
||||||
|
const fieldId = unknownField.id;
|
||||||
|
|
||||||
|
// Auto insert text fields with prefilled values.
|
||||||
|
if (field.type === FieldType.TEXT) {
|
||||||
|
const { text } = ZTextFieldMeta.parse(field.fieldMeta);
|
||||||
|
|
||||||
|
if (text) {
|
||||||
|
return {
|
||||||
|
fieldId,
|
||||||
|
customText: text,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto insert number fields with prefilled values.
|
||||||
|
if (field.type === FieldType.NUMBER) {
|
||||||
|
const { value } = ZNumberFieldMeta.parse(field.fieldMeta);
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
return {
|
||||||
|
fieldId,
|
||||||
|
customText: value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto insert radio fields with the pre-checked value.
|
||||||
|
if (field.type === FieldType.RADIO) {
|
||||||
|
const { values = [] } = ZRadioFieldMeta.parse(field.fieldMeta);
|
||||||
|
|
||||||
|
const checkedItemIndex = values.findIndex((value) => value.checked);
|
||||||
|
|
||||||
|
if (checkedItemIndex !== -1) {
|
||||||
|
return {
|
||||||
|
fieldId,
|
||||||
|
customText: toRadioCustomText(checkedItemIndex),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto insert dropdown fields with the default value.
|
||||||
|
if (field.type === FieldType.DROPDOWN) {
|
||||||
|
const { defaultValue, values = [] } = ZDropdownFieldMeta.parse(field.fieldMeta);
|
||||||
|
|
||||||
|
if (defaultValue && values.some((value) => value.value === defaultValue)) {
|
||||||
|
return {
|
||||||
|
fieldId,
|
||||||
|
customText: defaultValue,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto insert checkbox fields with the pre-checked values.
|
||||||
|
if (field.type === FieldType.CHECKBOX) {
|
||||||
|
const {
|
||||||
|
values = [],
|
||||||
|
validationRule,
|
||||||
|
validationLength,
|
||||||
|
} = ZCheckboxFieldMeta.parse(field.fieldMeta);
|
||||||
|
|
||||||
|
const checkedIndices: number[] = [];
|
||||||
|
|
||||||
|
values.forEach((value, i) => {
|
||||||
|
if (value.checked) {
|
||||||
|
checkedIndices.push(i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let isValid = true;
|
||||||
|
|
||||||
|
if (validationRule && validationLength) {
|
||||||
|
const validation = checkboxValidationSigns.find((sign) => sign.label === validationRule);
|
||||||
|
|
||||||
|
if (!validation) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||||
|
message: 'Invalid checkbox validation rule',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
isValid = validateCheckboxLength(checkedIndices.length, validation.value, validationLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isValid && checkedIndices.length > 0) {
|
||||||
|
return {
|
||||||
|
fieldId,
|
||||||
|
customText: toCheckboxCustomText(checkedIndices),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|||||||
@ -10,7 +10,6 @@ import {
|
|||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { insertFieldsFromPlaceholdersInPDF } from '@documenso/lib/server-only/pdf/auto-place-fields';
|
|
||||||
import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
|
import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
|
||||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
@ -257,7 +256,7 @@ export const createEnvelope = async ({
|
|||||||
? await incrementDocumentId().then((v) => v.formattedDocumentId)
|
? await incrementDocumentId().then((v) => v.formattedDocumentId)
|
||||||
: await incrementTemplateId().then((v) => v.formattedTemplateId);
|
: await incrementTemplateId().then((v) => v.formattedTemplateId);
|
||||||
|
|
||||||
const createdEnvelope = await prisma.$transaction(async (tx) => {
|
return await prisma.$transaction(async (tx) => {
|
||||||
const envelope = await tx.envelope.create({
|
const envelope = await tx.envelope.create({
|
||||||
data: {
|
data: {
|
||||||
id: prefixedId('envelope'),
|
id: prefixedId('envelope'),
|
||||||
@ -377,12 +376,8 @@ export const createEnvelope = async ({
|
|||||||
recipients: true,
|
recipients: true,
|
||||||
fields: true,
|
fields: true,
|
||||||
folder: true,
|
folder: true,
|
||||||
|
envelopeItems: true,
|
||||||
envelopeAttachments: true,
|
envelopeAttachments: true,
|
||||||
envelopeItems: {
|
|
||||||
include: {
|
|
||||||
documentData: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -418,51 +413,4 @@ export const createEnvelope = async ({
|
|||||||
|
|
||||||
return createdEnvelope;
|
return createdEnvelope;
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const envelopeItem of createdEnvelope.envelopeItems) {
|
|
||||||
const buffer = await getFileServerSide(envelopeItem.documentData);
|
|
||||||
|
|
||||||
// Use normalized PDF if normalizePdf was true, otherwise use original
|
|
||||||
const pdfToProcess = normalizePdf
|
|
||||||
? await makeNormalizedPdf(Buffer.from(buffer))
|
|
||||||
: Buffer.from(buffer);
|
|
||||||
|
|
||||||
await insertFieldsFromPlaceholdersInPDF(
|
|
||||||
pdfToProcess,
|
|
||||||
userId,
|
|
||||||
teamId,
|
|
||||||
{
|
|
||||||
type: 'envelopeId',
|
|
||||||
id: createdEnvelope.id,
|
|
||||||
},
|
|
||||||
requestMetadata,
|
|
||||||
envelopeItem.id,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalEnvelope = await prisma.envelope.findFirst({
|
|
||||||
where: {
|
|
||||||
id: createdEnvelope.id,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
documentMeta: true,
|
|
||||||
recipients: true,
|
|
||||||
fields: true,
|
|
||||||
folder: true,
|
|
||||||
envelopeAttachments: true,
|
|
||||||
envelopeItems: {
|
|
||||||
include: {
|
|
||||||
documentData: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!finalEnvelope) {
|
|
||||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
|
||||||
message: 'Envelope not found',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return finalEnvelope;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { prisma } from '@documenso/prisma';
|
|||||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
import { DocumentAccessAuth, type TDocumentAuthMethods } from '../../types/document-auth';
|
import { DocumentAccessAuth, type TDocumentAuthMethods } from '../../types/document-auth';
|
||||||
import { extractDocumentAuthMethods } from '../../utils/document-auth';
|
import { extractDocumentAuthMethods } from '../../utils/document-auth';
|
||||||
|
import { extractFieldAutoInsertValues } from '../document/send-document';
|
||||||
import { getTeamSettings } from '../team/get-team-settings';
|
import { getTeamSettings } from '../team/get-team-settings';
|
||||||
import type { EnvelopeForSigningResponse } from './get-envelope-for-recipient-signing';
|
import type { EnvelopeForSigningResponse } from './get-envelope-for-recipient-signing';
|
||||||
import { ZEnvelopeForSigningResponse } from './get-envelope-for-recipient-signing';
|
import { ZEnvelopeForSigningResponse } from './get-envelope-for-recipient-signing';
|
||||||
@ -144,6 +145,19 @@ export const getEnvelopeForDirectTemplateSigning = async ({
|
|||||||
recipient: {
|
recipient: {
|
||||||
...recipient,
|
...recipient,
|
||||||
directToken: envelope.directLink?.token || '',
|
directToken: envelope.directLink?.token || '',
|
||||||
|
fields: recipient.fields.map((field) => {
|
||||||
|
const autoInsertValue = extractFieldAutoInsertValues(field);
|
||||||
|
|
||||||
|
if (!autoInsertValue) {
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...field,
|
||||||
|
inserted: true,
|
||||||
|
customText: autoInsertValue.customText,
|
||||||
|
};
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
recipientSignature: null,
|
recipientSignature: null,
|
||||||
isRecipientsTurn: true,
|
isRecipientsTurn: true,
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import UserSchema from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
|
|||||||
|
|
||||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
import type { TDocumentAuthMethods } from '../../types/document-auth';
|
import type { TDocumentAuthMethods } from '../../types/document-auth';
|
||||||
import { ZFieldSchema } from '../../types/field';
|
import { ZEnvelopeFieldSchema, ZFieldSchema } from '../../types/field';
|
||||||
import { ZRecipientLiteSchema } from '../../types/recipient';
|
import { ZRecipientLiteSchema } from '../../types/recipient';
|
||||||
import { isRecipientAuthorized } from '../document/is-recipient-authorized';
|
import { isRecipientAuthorized } from '../document/is-recipient-authorized';
|
||||||
import { getTeamSettings } from '../team/get-team-settings';
|
import { getTeamSettings } from '../team/get-team-settings';
|
||||||
@ -63,9 +63,11 @@ export const ZEnvelopeForSigningResponse = z.object({
|
|||||||
rejectionReason: true,
|
rejectionReason: true,
|
||||||
})
|
})
|
||||||
.extend({
|
.extend({
|
||||||
fields: ZFieldSchema.omit({
|
fields: ZEnvelopeFieldSchema.extend({
|
||||||
documentId: true,
|
signature: SignatureSchema.pick({
|
||||||
templateId: true,
|
signatureImageAsBase64: true,
|
||||||
|
typedSignature: true,
|
||||||
|
}).nullish(),
|
||||||
}).array(),
|
}).array(),
|
||||||
})
|
})
|
||||||
.array(),
|
.array(),
|
||||||
|
|||||||
@ -129,7 +129,7 @@ export const setFieldsForTemplate = async ({
|
|||||||
if (field.type === FieldType.NUMBER && field.fieldMeta) {
|
if (field.type === FieldType.NUMBER && field.fieldMeta) {
|
||||||
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
|
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
|
||||||
const errors = validateNumberField(
|
const errors = validateNumberField(
|
||||||
String(numberFieldParsedMeta.value),
|
String(numberFieldParsedMeta.value || ''),
|
||||||
numberFieldParsedMeta,
|
numberFieldParsedMeta,
|
||||||
);
|
);
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
|
|||||||
@ -88,11 +88,13 @@ export const addUserToOrganisation = async ({
|
|||||||
organisationId,
|
organisationId,
|
||||||
organisationGroups,
|
organisationGroups,
|
||||||
organisationMemberRole,
|
organisationMemberRole,
|
||||||
|
bypassEmail = false,
|
||||||
}: {
|
}: {
|
||||||
userId: number;
|
userId: number;
|
||||||
organisationId: string;
|
organisationId: string;
|
||||||
organisationGroups: OrganisationGroup[];
|
organisationGroups: OrganisationGroup[];
|
||||||
organisationMemberRole: OrganisationMemberRole;
|
organisationMemberRole: OrganisationMemberRole;
|
||||||
|
bypassEmail?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const organisationGroupToUse = organisationGroups.find(
|
const organisationGroupToUse = organisationGroups.find(
|
||||||
(group) =>
|
(group) =>
|
||||||
@ -122,13 +124,15 @@ export const addUserToOrganisation = async ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await jobs.triggerJob({
|
if (!bypassEmail) {
|
||||||
name: 'send.organisation-member-joined.email',
|
await jobs.triggerJob({
|
||||||
payload: {
|
name: 'send.organisation-member-joined.email',
|
||||||
organisationId,
|
payload: {
|
||||||
memberUserId: userId,
|
organisationId,
|
||||||
},
|
memberUserId: userId,
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,347 +0,0 @@
|
|||||||
import { PDFDocument, rgb } from '@cantoo/pdf-lib';
|
|
||||||
import type { Recipient } from '@prisma/client';
|
|
||||||
import PDFParser from 'pdf2json';
|
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
||||||
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
|
||||||
import { createEnvelopeFields } from '@documenso/lib/server-only/field/create-envelope-fields';
|
|
||||||
import { type TFieldAndMeta, ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
|
|
||||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
|
||||||
import type { EnvelopeIdOptions } from '@documenso/lib/utils/envelope';
|
|
||||||
import { prisma } from '@documenso/prisma';
|
|
||||||
|
|
||||||
import { getPageSize } from './get-page-size';
|
|
||||||
import {
|
|
||||||
determineRecipientsForPlaceholders,
|
|
||||||
extractRecipientPlaceholder,
|
|
||||||
findRecipientByPlaceholder,
|
|
||||||
parseFieldMetaFromPlaceholder,
|
|
||||||
parseFieldTypeFromPlaceholder,
|
|
||||||
} from './helpers';
|
|
||||||
|
|
||||||
const PLACEHOLDER_REGEX = /{{([^}]+)}}/g;
|
|
||||||
const DEFAULT_FIELD_HEIGHT_PERCENT = 2;
|
|
||||||
const WIDTH_ADJUSTMENT_FACTOR = 0.1;
|
|
||||||
const MIN_HEIGHT_THRESHOLD = 0.01;
|
|
||||||
|
|
||||||
type TextPosition = {
|
|
||||||
text: string;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
w: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CharIndexMapping = {
|
|
||||||
textPositionIndex: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type PlaceholderInfo = {
|
|
||||||
placeholder: string;
|
|
||||||
recipient: string;
|
|
||||||
fieldAndMeta: TFieldAndMeta;
|
|
||||||
page: number;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
pageWidth: number;
|
|
||||||
pageHeight: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type FieldToCreate = TFieldAndMeta & {
|
|
||||||
envelopeItemId?: string;
|
|
||||||
recipientId: number;
|
|
||||||
pageNumber: number;
|
|
||||||
pageX: number;
|
|
||||||
pageY: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const extractPlaceholdersFromPDF = async (pdf: Buffer): Promise<PlaceholderInfo[]> => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const parser = new PDFParser(null, true);
|
|
||||||
|
|
||||||
parser.on('pdfParser_dataError', (errData) => {
|
|
||||||
reject(errData);
|
|
||||||
});
|
|
||||||
|
|
||||||
parser.on('pdfParser_dataReady', (pdfData) => {
|
|
||||||
const placeholders: PlaceholderInfo[] = [];
|
|
||||||
|
|
||||||
pdfData.Pages.forEach((page, pageIndex) => {
|
|
||||||
/*
|
|
||||||
pdf2json returns the PDF page content as an array of characters.
|
|
||||||
We need to concatenate the characters to get the full text.
|
|
||||||
We also need to get the position of the text so we can place the placeholders in the correct position.
|
|
||||||
|
|
||||||
Page dimensions from PDF2JSON are in "page units" (relative coordinates)
|
|
||||||
*/
|
|
||||||
let pageText = '';
|
|
||||||
const textPositions: TextPosition[] = [];
|
|
||||||
const charIndexMappings: CharIndexMapping[] = [];
|
|
||||||
|
|
||||||
page.Texts.forEach((text) => {
|
|
||||||
/*
|
|
||||||
R is an array of objects containing each character, its position and styling information.
|
|
||||||
The decodedText stores the characters, without any other information.
|
|
||||||
|
|
||||||
textPositions stores each character and its position on the page.
|
|
||||||
*/
|
|
||||||
const decodedText = text.R.map((run) => decodeURIComponent(run.T)).join('');
|
|
||||||
|
|
||||||
/*
|
|
||||||
For each character in the decodedText, we store its position in the textPositions array.
|
|
||||||
This allows us to quickly find the position of a character in the textPositions array by its index.
|
|
||||||
*/
|
|
||||||
for (let i = 0; i < decodedText.length; i++) {
|
|
||||||
charIndexMappings.push({
|
|
||||||
textPositionIndex: textPositions.length,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
pageText += decodedText;
|
|
||||||
|
|
||||||
textPositions.push({
|
|
||||||
text: decodedText,
|
|
||||||
x: text.x,
|
|
||||||
y: text.y,
|
|
||||||
w: text.w || 0,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const placeholderMatches = pageText.matchAll(PLACEHOLDER_REGEX);
|
|
||||||
|
|
||||||
/*
|
|
||||||
A placeholder match has the following format:
|
|
||||||
|
|
||||||
[
|
|
||||||
'{{fieldType,recipient,fieldMeta}}',
|
|
||||||
'fieldType,recipient,fieldMeta',
|
|
||||||
'index: <number>',
|
|
||||||
'input: <pdf-text>'
|
|
||||||
]
|
|
||||||
*/
|
|
||||||
for (const placeholderMatch of placeholderMatches) {
|
|
||||||
const placeholder = placeholderMatch[0];
|
|
||||||
const placeholderData = placeholderMatch[1].split(',').map((property) => property.trim());
|
|
||||||
|
|
||||||
const [fieldTypeString, recipient, ...fieldMetaData] = placeholderData;
|
|
||||||
|
|
||||||
const rawFieldMeta = Object.fromEntries(
|
|
||||||
fieldMetaData.map((property) => property.split('=')),
|
|
||||||
);
|
|
||||||
|
|
||||||
const fieldType = parseFieldTypeFromPlaceholder(fieldTypeString);
|
|
||||||
const parsedFieldMeta = parseFieldMetaFromPlaceholder(rawFieldMeta, fieldType);
|
|
||||||
|
|
||||||
const fieldAndMeta: TFieldAndMeta = ZFieldAndMetaSchema.parse({
|
|
||||||
type: fieldType,
|
|
||||||
fieldMeta: parsedFieldMeta,
|
|
||||||
});
|
|
||||||
|
|
||||||
/*
|
|
||||||
Find the position of where the placeholder starts and ends in the text.
|
|
||||||
|
|
||||||
Then find the position of the characters in the textPositions array.
|
|
||||||
This allows us to quickly find the position of a character in the textPositions array by its index.
|
|
||||||
*/
|
|
||||||
const placeholderEndCharIndex = placeholderMatch.index + placeholder.length;
|
|
||||||
|
|
||||||
/*
|
|
||||||
Get the index of the placeholder's first and last character in the textPositions array.
|
|
||||||
Used to retrieve the character information from the textPositions array.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
startTextPosIndex - 1
|
|
||||||
endTextPosIndex - 40
|
|
||||||
*/
|
|
||||||
const startTextPosIndex = charIndexMappings[placeholderMatch.index].textPositionIndex;
|
|
||||||
const endTextPosIndex = charIndexMappings[placeholderEndCharIndex - 1].textPositionIndex;
|
|
||||||
|
|
||||||
/*
|
|
||||||
Get the placeholder's first and last character information from the textPositions array.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
placeholderStart = { text: '{', x: 100, y: 100, w: 100 }
|
|
||||||
placeholderEnd = { text: '}', x: 200, y: 100, w: 100 }
|
|
||||||
*/
|
|
||||||
const placeholderStart = textPositions[startTextPosIndex];
|
|
||||||
const placeholderEnd = textPositions[endTextPosIndex];
|
|
||||||
|
|
||||||
const width =
|
|
||||||
placeholderEnd.x + placeholderEnd.w * WIDTH_ADJUSTMENT_FACTOR - placeholderStart.x;
|
|
||||||
|
|
||||||
placeholders.push({
|
|
||||||
placeholder,
|
|
||||||
recipient,
|
|
||||||
fieldAndMeta,
|
|
||||||
page: pageIndex + 1,
|
|
||||||
x: placeholderStart.x,
|
|
||||||
y: placeholderStart.y,
|
|
||||||
width,
|
|
||||||
height: 1,
|
|
||||||
pageWidth: page.Width,
|
|
||||||
pageHeight: page.Height,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
resolve(placeholders);
|
|
||||||
});
|
|
||||||
|
|
||||||
parser.parseBuffer(pdf);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const removePlaceholdersFromPDF = async (pdf: Buffer): Promise<Buffer> => {
|
|
||||||
const placeholders = await extractPlaceholdersFromPDF(pdf);
|
|
||||||
|
|
||||||
const pdfDoc = await PDFDocument.load(new Uint8Array(pdf));
|
|
||||||
const pages = pdfDoc.getPages();
|
|
||||||
|
|
||||||
for (const placeholder of placeholders) {
|
|
||||||
const pageIndex = placeholder.page - 1;
|
|
||||||
const page = pages[pageIndex];
|
|
||||||
|
|
||||||
const { width: pdfLibPageWidth, height: pdfLibPageHeight } = getPageSize(page);
|
|
||||||
|
|
||||||
/*
|
|
||||||
Convert PDF2JSON coordinates to pdf-lib coordinates:
|
|
||||||
|
|
||||||
PDF2JSON uses relative "page units":
|
|
||||||
- x, y, width, height are in page units
|
|
||||||
- Page dimensions (Width, Height) are also in page units
|
|
||||||
|
|
||||||
pdf-lib uses absolute points (1 point = 1/72 inch):
|
|
||||||
- Need to convert from page units to points
|
|
||||||
- Y-axis in pdf-lib is bottom-up (origin at bottom-left)
|
|
||||||
- Y-axis in PDF2JSON is top-down (origin at top-left)
|
|
||||||
*/
|
|
||||||
|
|
||||||
const xPoints = (placeholder.x / placeholder.pageWidth) * pdfLibPageWidth;
|
|
||||||
const yPoints = pdfLibPageHeight - (placeholder.y / placeholder.pageHeight) * pdfLibPageHeight;
|
|
||||||
const widthPoints = (placeholder.width / placeholder.pageWidth) * pdfLibPageWidth;
|
|
||||||
const heightPoints = (placeholder.height / placeholder.pageHeight) * pdfLibPageHeight;
|
|
||||||
|
|
||||||
page.drawRectangle({
|
|
||||||
x: xPoints,
|
|
||||||
y: yPoints - heightPoints, // Adjust for height since y is at baseline
|
|
||||||
width: widthPoints,
|
|
||||||
height: heightPoints,
|
|
||||||
color: rgb(1, 1, 1),
|
|
||||||
borderColor: rgb(1, 1, 1),
|
|
||||||
borderWidth: 2,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const modifiedPdfBytes = await pdfDoc.save();
|
|
||||||
|
|
||||||
return Buffer.from(modifiedPdfBytes);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const insertFieldsFromPlaceholdersInPDF = async (
|
|
||||||
pdf: Buffer,
|
|
||||||
userId: number,
|
|
||||||
teamId: number,
|
|
||||||
envelopeId: EnvelopeIdOptions,
|
|
||||||
requestMetadata: ApiRequestMetadata,
|
|
||||||
envelopeItemId?: string,
|
|
||||||
recipients?: Pick<Recipient, 'id' | 'email'>[],
|
|
||||||
): Promise<Buffer> => {
|
|
||||||
const placeholders = await extractPlaceholdersFromPDF(pdf);
|
|
||||||
|
|
||||||
if (placeholders.length === 0) {
|
|
||||||
return pdf;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
A structure that maps the recipient index to the recipient name.
|
|
||||||
Example: 1 => 'Recipient 1'
|
|
||||||
*/
|
|
||||||
const recipientPlaceholders = new Map<number, string>();
|
|
||||||
|
|
||||||
for (const placeholder of placeholders) {
|
|
||||||
const { name, recipientIndex } = extractRecipientPlaceholder(placeholder.recipient);
|
|
||||||
|
|
||||||
recipientPlaceholders.set(recipientIndex, name);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
|
||||||
id: envelopeId,
|
|
||||||
userId,
|
|
||||||
teamId,
|
|
||||||
type: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const envelope = await prisma.envelope.findFirst({
|
|
||||||
where: envelopeWhereInput,
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
type: true,
|
|
||||||
secondaryId: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!envelope) {
|
|
||||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
|
||||||
message: 'Envelope not found',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const createdRecipients = await determineRecipientsForPlaceholders(
|
|
||||||
recipients,
|
|
||||||
recipientPlaceholders,
|
|
||||||
envelope,
|
|
||||||
userId,
|
|
||||||
teamId,
|
|
||||||
requestMetadata,
|
|
||||||
);
|
|
||||||
|
|
||||||
const fieldsToCreate: FieldToCreate[] = [];
|
|
||||||
|
|
||||||
for (const placeholder of placeholders) {
|
|
||||||
/*
|
|
||||||
Convert PDF2JSON coordinates to percentage-based coordinates (0-100)
|
|
||||||
The UI expects positionX and positionY as percentages, not absolute points
|
|
||||||
PDF2JSON uses relative coordinates: x/pageWidth and y/pageHeight give us the percentage
|
|
||||||
*/
|
|
||||||
const xPercent = (placeholder.x / placeholder.pageWidth) * 100;
|
|
||||||
const yPercent = (placeholder.y / placeholder.pageHeight) * 100;
|
|
||||||
|
|
||||||
const widthPercent = (placeholder.width / placeholder.pageWidth) * 100;
|
|
||||||
const heightPercent = (placeholder.height / placeholder.pageHeight) * 100;
|
|
||||||
|
|
||||||
const recipient = findRecipientByPlaceholder(
|
|
||||||
placeholder.recipient,
|
|
||||||
placeholder.placeholder,
|
|
||||||
recipients,
|
|
||||||
createdRecipients,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Default height percentage if too small (use 2% as a reasonable default)
|
|
||||||
const finalHeightPercent =
|
|
||||||
heightPercent > MIN_HEIGHT_THRESHOLD ? heightPercent : DEFAULT_FIELD_HEIGHT_PERCENT;
|
|
||||||
|
|
||||||
fieldsToCreate.push({
|
|
||||||
...placeholder.fieldAndMeta,
|
|
||||||
envelopeItemId,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
pageNumber: placeholder.page,
|
|
||||||
pageX: xPercent,
|
|
||||||
pageY: yPercent,
|
|
||||||
width: widthPercent,
|
|
||||||
height: finalHeightPercent,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await createEnvelopeFields({
|
|
||||||
userId,
|
|
||||||
teamId,
|
|
||||||
id: envelopeId,
|
|
||||||
fields: fieldsToCreate,
|
|
||||||
requestMetadata,
|
|
||||||
});
|
|
||||||
|
|
||||||
return pdf;
|
|
||||||
};
|
|
||||||