mirror of
https://github.com/documenso/documenso.git
synced 2026-09-29 16:24:30 +10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a18a04d911 |
@@ -122,7 +122,7 @@ export const EnvelopeItemEditDialog = ({
|
||||
|
||||
toast({
|
||||
title: t`Failed to read file`,
|
||||
description: t`The file is not a valid PDF or is password protected.`,
|
||||
description: t`The file is not a valid PDF.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
|
||||
@@ -25,6 +26,7 @@ import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
@@ -32,6 +34,7 @@ import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
|
||||
import { AlertTriangleIcon, FileTextIcon, PencilIcon, SparklesIcon } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useRevalidator, useSearchParams } from 'react-router';
|
||||
import { isDeepEqual } from 'remeda';
|
||||
import { match } from 'ts-pattern';
|
||||
@@ -84,6 +87,7 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
const { currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isAiFieldDialogOpen, setIsAiFieldDialogOpen] = useState(false);
|
||||
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
|
||||
@@ -96,6 +100,34 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
|
||||
const selectedField = useMemo(() => structuredClone(editorFields.selectedField), [editorFields.selectedField]);
|
||||
|
||||
const [copiedField, setCopiedField] = useState<TLocalField | null>(null);
|
||||
|
||||
useHotkeys(['ctrl+c', 'meta+c'], (event) => {
|
||||
// Keep the native copy when the user has selected text.
|
||||
if (!selectedField || window.getSelection()?.toString()) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
setCopiedField(selectedField);
|
||||
|
||||
toast({
|
||||
title: _(msg`Copied field`),
|
||||
description: _(msg`Copied field to clipboard`),
|
||||
});
|
||||
});
|
||||
|
||||
useHotkeys(['ctrl+v', 'meta+v'], (event) => {
|
||||
if (!copiedField) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
// Paste the next copy offset from the last one, so repeated pastes do not stack.
|
||||
setCopiedField(editorFields.duplicateField(copiedField));
|
||||
});
|
||||
|
||||
/**
|
||||
* Debounce the fields used for overlap detection so we don't recompute on every
|
||||
* small drag/resize movement, which is expensive on large field counts and can
|
||||
|
||||
@@ -12,7 +12,7 @@ import { ChevronDownIcon, ChevronsUpDownIcon, ChevronUpIcon } from 'lucide-react
|
||||
import { useMemo } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
|
||||
type OrderByColumn = 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports' | 'teamCount' | 'totalCount';
|
||||
type OrderByColumn = 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports' | 'totalCount';
|
||||
type OrderByDirection = 'asc' | 'desc';
|
||||
|
||||
const parseOrderByColumn = (value: string | undefined): OrderByColumn | undefined => {
|
||||
@@ -21,7 +21,6 @@ const parseOrderByColumn = (value: string | undefined): OrderByColumn | undefine
|
||||
value === 'emailCount' ||
|
||||
value === 'apiCount' ||
|
||||
value === 'emailReports' ||
|
||||
value === 'teamCount' ||
|
||||
value === 'totalCount'
|
||||
) {
|
||||
return value;
|
||||
@@ -179,11 +178,6 @@ export const AdminOrganisationStatsTable = ({ displayMode = 'usage' }: AdminOrga
|
||||
accessorKey: 'originalClaimId',
|
||||
cell: ({ row }) => <span className="text-muted-foreground text-sm">{row.original.originalClaimId ?? '—'}</span>,
|
||||
},
|
||||
{
|
||||
header: () => sortableHeader(t`Teams`, 'teamCount'),
|
||||
accessorKey: 'teamCount',
|
||||
cell: ({ row }) => row.original.teamCount,
|
||||
},
|
||||
{
|
||||
header: t`Period`,
|
||||
accessorKey: 'period',
|
||||
@@ -246,9 +240,6 @@ export const AdminOrganisationStatsTable = ({ displayMode = 'usage' }: AdminOrga
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-24 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-10 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-16 rounded-full" />
|
||||
</TableCell>
|
||||
|
||||
@@ -92,7 +92,7 @@ export const getUploadErrorMessage = (code: string): ToastMessageDescriptor => {
|
||||
.with(AppErrorCode.TOO_MANY_REQUESTS, () => FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE)
|
||||
.with('INVALID_DOCUMENT_FILE', () => ({
|
||||
title: msg`Error`,
|
||||
description: msg`The file is not a valid PDF or is password protected.`,
|
||||
description: msg`You cannot upload encrypted PDFs.`,
|
||||
}))
|
||||
.with(AppErrorCode.LIMIT_EXCEEDED, () => ({
|
||||
title: msg`Error`,
|
||||
|
||||
Generated
+4
-4
@@ -15,7 +15,7 @@
|
||||
"dependencies": {
|
||||
"@ai-sdk/google-vertex": "5.0.48",
|
||||
"@documenso/prisma": "*",
|
||||
"@libpdf/core": "^0.5.1",
|
||||
"@libpdf/core": "^0.4.2",
|
||||
"@lingui/conf": "^5.6.0",
|
||||
"@lingui/core": "^5.6.0",
|
||||
"@marsidev/react-turnstile": "^1.5.0",
|
||||
@@ -4480,9 +4480,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@libpdf/core": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.5.1.tgz",
|
||||
"integrity": "sha512-q+y4AEk9ngqyC1pdX/hNScnfh0ROr4vSiqX4C9CmuBzhtuVukbfTesXCaGvHZ3pksi8AJYDPGQ/0HzSeHZfi3A==",
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.4.2.tgz",
|
||||
"integrity": "sha512-lbkIqLDZCCxjLpiC+8/Xvaru/ME7iVVoihl9tLqbp/CDUWZNF0q3u7s2tBJB9wRW/SzUWID6YPFvBWws770hrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^2.2.0",
|
||||
|
||||
+1
-1
@@ -106,7 +106,7 @@
|
||||
"dependencies": {
|
||||
"@ai-sdk/google-vertex": "5.0.48",
|
||||
"@documenso/prisma": "*",
|
||||
"@libpdf/core": "^0.5.1",
|
||||
"@libpdf/core": "^0.4.2",
|
||||
"@lingui/conf": "^5.6.0",
|
||||
"@lingui/core": "^5.6.0",
|
||||
"@prisma/extension-read-replicas": "^0.4.1",
|
||||
|
||||
@@ -633,6 +633,65 @@ const assertDuplicateDeleteFieldPersistedInDatabase = async ({
|
||||
expect(envelope.fields[0].type).toBe(FieldType.SIGNATURE);
|
||||
};
|
||||
|
||||
// --- Copy and paste fields flow ---
|
||||
|
||||
type TCopyPasteFlowResult = {
|
||||
externalId: string;
|
||||
};
|
||||
|
||||
const runCopyPasteFieldFlow = async (surface: TEnvelopeEditorSurface): Promise<TCopyPasteFlowResult> => {
|
||||
const externalId = `e2e-copy-paste-${nanoid()}`;
|
||||
const root = surface.root;
|
||||
|
||||
await updateExternalId(surface, externalId);
|
||||
await setupRecipientsForFieldPlacement(surface);
|
||||
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
await expect(root.locator('.konva-container canvas').first()).toBeVisible();
|
||||
|
||||
await placeFieldOnPdf(root, 'Signature', { x: 150, y: 150 });
|
||||
await selectFieldOnCanvas(root, { x: 150, y: 150 });
|
||||
|
||||
await root.keyboard.press('ControlOrMeta+c');
|
||||
await root.keyboard.press('ControlOrMeta+v');
|
||||
await root.keyboard.press('ControlOrMeta+v');
|
||||
|
||||
await expect.poll(async () => getKonvaElementCountForPage(root, 1, '.field-group')).toBe(3);
|
||||
|
||||
// Navigate away and back to persist changes.
|
||||
await clickEnvelopeEditorStep(root, 'upload');
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
await expect.poll(async () => getKonvaElementCountForPage(root, 1, '.field-group')).toBe(3);
|
||||
|
||||
return { externalId };
|
||||
};
|
||||
|
||||
const assertCopyPasteFieldPersistedInDatabase = async ({
|
||||
surface,
|
||||
externalId,
|
||||
}: {
|
||||
surface: TEnvelopeEditorSurface;
|
||||
externalId: string;
|
||||
}) => {
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
externalId,
|
||||
userId: surface.userId,
|
||||
teamId: surface.teamId,
|
||||
type: surface.envelopeType,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { fields: true },
|
||||
});
|
||||
|
||||
expect(envelope.fields).toHaveLength(3);
|
||||
expect(envelope.fields.every((field) => field.type === FieldType.SIGNATURE)).toBe(true);
|
||||
|
||||
// Each paste is offset from the previous one, so no two fields share a position.
|
||||
const positions = new Set(envelope.fields.map((field) => `${field.positionX}:${field.positionY}`));
|
||||
expect(positions.size).toBe(3);
|
||||
};
|
||||
|
||||
// --- Change field type flow ---
|
||||
|
||||
type TChangeFieldTypeFlowResult = {
|
||||
@@ -902,6 +961,16 @@ test.describe('document editor', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('copy and paste fields with keyboard shortcuts', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const result = await runCopyPasteFieldFlow(surface);
|
||||
|
||||
await assertCopyPasteFieldPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('place and configure all 10 field types', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const result = await runAllFieldTypesFlow(surface);
|
||||
@@ -964,6 +1033,16 @@ test.describe('template editor', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('copy and paste fields with keyboard shortcuts', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
const result = await runCopyPasteFieldFlow(surface);
|
||||
|
||||
await assertCopyPasteFieldPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('place and configure all 10 field types', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
const result = await runAllFieldTypesFlow(surface);
|
||||
|
||||
@@ -9,18 +9,14 @@ export const normalizePdf = async (pdf: Buffer, options: { flattenForm?: boolean
|
||||
console.error(`PDF normalization error: ${e.message}`);
|
||||
|
||||
throw new AppError('INVALID_DOCUMENT_FILE', {
|
||||
message: 'The document is not a valid PDF or is password protected',
|
||||
message: 'The document is not a valid PDF',
|
||||
});
|
||||
});
|
||||
|
||||
if (pdfDoc.isEncrypted) {
|
||||
if (!pdfDoc.isAuthenticated) {
|
||||
throw new AppError('INVALID_DOCUMENT_FILE', {
|
||||
message: 'The document is password protected',
|
||||
});
|
||||
}
|
||||
|
||||
pdfDoc.removeProtection({ ignorePermissions: true });
|
||||
throw new AppError('INVALID_DOCUMENT_FILE', {
|
||||
message: 'The document is encrypted',
|
||||
});
|
||||
}
|
||||
|
||||
pdfDoc.flattenLayers();
|
||||
|
||||
@@ -32,7 +32,7 @@ type FindOrganisationStatsOptions = {
|
||||
claimId?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
orderByColumn?: 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports' | 'teamCount' | 'totalCount';
|
||||
orderByColumn?: 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports' | 'totalCount';
|
||||
orderByDirection?: 'asc' | 'desc';
|
||||
};
|
||||
|
||||
@@ -95,12 +95,6 @@ export const findOrganisationStats = async ({
|
||||
'OrganisationClaim.documentQuota as documentQuota',
|
||||
'OrganisationClaim.emailQuota as emailQuota',
|
||||
'OrganisationClaim.apiQuota as apiQuota',
|
||||
// Teams have no monthly history, so this is the current count for every period.
|
||||
eb
|
||||
.selectFrom('Team')
|
||||
.whereRef('Team.organisationId', '=', 'Organisation.id')
|
||||
.select(sql<number>`count("Team"."id")`.as('count'))
|
||||
.as('teamCount'),
|
||||
totalCountExpression.as('totalCount'),
|
||||
eb.fn.countAll().over().as('totalRows'),
|
||||
])
|
||||
@@ -110,7 +104,6 @@ export const findOrganisationStats = async ({
|
||||
.with('emailCount', () => qb.orderBy('OrganisationMonthlyStat.emailCount', orderByDirection))
|
||||
.with('apiCount', () => qb.orderBy('OrganisationMonthlyStat.apiCount', orderByDirection))
|
||||
.with('emailReports', () => qb.orderBy('OrganisationMonthlyStat.emailReports', orderByDirection))
|
||||
.with('teamCount', () => qb.orderBy('teamCount', orderByDirection))
|
||||
.with('totalCount', () => qb.orderBy(totalCountExpression, orderByDirection))
|
||||
.with(undefined, () =>
|
||||
// Default ordering mirrors the desired SQL: email, api, document descending.
|
||||
@@ -139,7 +132,6 @@ export const findOrganisationStats = async ({
|
||||
emailCount: Number(row.emailCount),
|
||||
apiCount: Number(row.apiCount),
|
||||
emailReports: Number(row.emailReports),
|
||||
teamCount: Number(row.teamCount),
|
||||
documentQuota: row.documentQuota === null ? null : Number(row.documentQuota),
|
||||
emailQuota: row.emailQuota === null ? null : Number(row.emailQuota),
|
||||
apiQuota: row.apiQuota === null ? null : Number(row.apiQuota),
|
||||
|
||||
@@ -9,7 +9,7 @@ export const ZFindOrganisationStatsRequestSchema = ZFindSearchParamsSchema.exten
|
||||
.optional(),
|
||||
claimId: z.string().describe('Filter stats by the original subscription claim ID.').optional(),
|
||||
orderByColumn: z
|
||||
.enum(['documentCount', 'emailCount', 'apiCount', 'emailReports', 'teamCount', 'totalCount'])
|
||||
.enum(['documentCount', 'emailCount', 'apiCount', 'emailReports', 'totalCount'])
|
||||
.describe('The column to sort by.')
|
||||
.optional(),
|
||||
orderByDirection: z.enum(['asc', 'desc']).describe('Sort direction.').default('desc'),
|
||||
@@ -27,7 +27,6 @@ export const ZFindOrganisationStatsResponseSchema = ZFindResultResponse.extend({
|
||||
emailCount: z.number(),
|
||||
apiCount: z.number(),
|
||||
emailReports: z.number(),
|
||||
teamCount: z.number(),
|
||||
documentQuota: z.number().nullable(),
|
||||
emailQuota: z.number().nullable(),
|
||||
apiQuota: z.number().nullable(),
|
||||
|
||||
Reference in New Issue
Block a user