mirror of
https://github.com/documenso/documenso.git
synced 2026-10-02 17:54:28 +10:00
fix: tests
This commit is contained in:
@@ -113,6 +113,12 @@ export const EditorContentImageSettings = ({ formId, dataContentId }: EditorCont
|
||||
onKeyDown: (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
|
||||
// The dropzone runs its own handler after this one unless the
|
||||
// event is stopped, and that would open its own picker as well
|
||||
// as the dialog's.
|
||||
event.stopPropagation();
|
||||
|
||||
void ContentImageUploadDialog.call({ formId });
|
||||
}
|
||||
},
|
||||
|
||||
+13
-2
@@ -146,9 +146,20 @@ export const useEnvelopeCanvasMarquee = ({ onSelect, onEmptyClick }: UseEnvelope
|
||||
window.removeEventListener('touchcancel', onWindowPointerUp);
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the current press started on an empty area of the stage.
|
||||
*
|
||||
* Releasing a resize handle without dragging it reaches the stage as a
|
||||
* click on the stage itself. Only a press which started on the empty page
|
||||
* makes an empty click, otherwise that release would deselect the item.
|
||||
*/
|
||||
let isPressOnEmptyStage = false;
|
||||
|
||||
stage.on('mousedown.marquee touchstart.marquee', (e) => {
|
||||
isPressOnEmptyStage = e.target === stage;
|
||||
|
||||
// Do nothing if the pointer is down on a shape.
|
||||
if (e.target !== stage) {
|
||||
if (!isPressOnEmptyStage) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -178,7 +189,7 @@ export const useEnvelopeCanvasMarquee = ({ onSelect, onEmptyClick }: UseEnvelope
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.target === stage) {
|
||||
if (e.target === stage && isPressOnEmptyStage) {
|
||||
onEmptyClickRef.current();
|
||||
}
|
||||
});
|
||||
|
||||
Binary file not shown.
@@ -46,9 +46,18 @@ export const CONTENT_ALIGNMENT_PAGES = {
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The page is A4, so a square in page units needs the width scaled by this.
|
||||
* The size of the PDF's pages in points, the unit contents are drawn in.
|
||||
*
|
||||
* Positions are percentages of the page's width and height, which are not the
|
||||
* same length, so anything which has to keep its shape (a square, a rotation)
|
||||
* is worked out in points.
|
||||
*/
|
||||
const A4_ASPECT = 210 / 297;
|
||||
const PAGE_SIZE = { width: 595.92, height: 842.88 };
|
||||
|
||||
const toPointsX = (percent: number) => (percent / 100) * PAGE_SIZE.width;
|
||||
const toPointsY = (percent: number) => (percent / 100) * PAGE_SIZE.height;
|
||||
const toPercentX = (points: number) => (points / PAGE_SIZE.width) * 100;
|
||||
const toPercentY = (points: number) => (points / PAGE_SIZE.height) * 100;
|
||||
|
||||
/**
|
||||
* The grid printed on every page of the PDF, as percentages of the page.
|
||||
@@ -59,7 +68,8 @@ export const CONTENT_ALIGNMENT_GRID = {
|
||||
columnWidth: 28,
|
||||
columnGap: 1,
|
||||
rowHeight: 7,
|
||||
rowGap: 1,
|
||||
// Holds each box's label, which the PDF prints just above the box.
|
||||
rowGap: 2,
|
||||
};
|
||||
|
||||
const cell = (row: number, column: number, { rows = 1, columns = 1 }: { rows?: number; columns?: number } = {}) => {
|
||||
@@ -73,33 +83,78 @@ const cell = (row: number, column: number, { rows = 1, columns = 1 }: { rows?: n
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* The box of a grid cell as printed on the PDF, for anything else placed on
|
||||
* it, e.g. the seed's signature field.
|
||||
*/
|
||||
export const getContentAlignmentCell = cell;
|
||||
|
||||
type Box = ReturnType<typeof cell>;
|
||||
|
||||
/**
|
||||
* A box half the size of a cell, placed at the cell's center.
|
||||
* The box of a content which can be rotated, i.e. a shape or an image.
|
||||
*/
|
||||
type RotatableBox = Box & { rotation?: number };
|
||||
|
||||
/**
|
||||
* A box half the size of a cell, rotated by `rotation` and centered in the
|
||||
* cell.
|
||||
*
|
||||
* Contents rotate about their top left corner (see `ZContentRotationSchema`),
|
||||
* so a rotated box placed at a cell's top left would sweep outside the cell.
|
||||
* Starting from the center keeps every rotation within the cell.
|
||||
* so that corner is placed wherever the rotation needs it for the box's center
|
||||
* to land on the cell's center. Throws if the rotated box would not fit in the
|
||||
* cell, since it would then reach into the neighbouring cells.
|
||||
*/
|
||||
const halfCell = (row: number, column: number, options: { rows?: number; columns?: number } = {}) => {
|
||||
const rotatedHalfCell = (
|
||||
row: number,
|
||||
column: number,
|
||||
rotation: number,
|
||||
options: { rows?: number; columns?: number } = {},
|
||||
): RotatableBox => {
|
||||
const box = cell(row, column, options);
|
||||
|
||||
const width = box.width / 2;
|
||||
const height = box.height / 2;
|
||||
|
||||
// Half the box's width and height, in points.
|
||||
const halfWidth = toPointsX(width) / 2;
|
||||
const halfHeight = toPointsY(height) / 2;
|
||||
|
||||
const radians = (rotation * Math.PI) / 180;
|
||||
const cos = Math.cos(radians);
|
||||
const sin = Math.sin(radians);
|
||||
|
||||
// Where the box's center ends up, relative to its top left corner.
|
||||
const centerX = halfWidth * cos - halfHeight * sin;
|
||||
const centerY = halfWidth * sin + halfHeight * cos;
|
||||
|
||||
// How far the rotated box reaches from its center along each axis.
|
||||
const reachX = Math.abs(halfWidth * cos) + Math.abs(halfHeight * sin);
|
||||
const reachY = Math.abs(halfWidth * sin) + Math.abs(halfHeight * cos);
|
||||
|
||||
const cellWidth = toPointsX(box.width);
|
||||
const cellHeight = toPointsY(box.height);
|
||||
|
||||
if (reachX * 2 > cellWidth || reachY * 2 > cellHeight) {
|
||||
throw new Error(`A half size box rotated by ${rotation} degrees does not fit in cell ${row}, ${column}`);
|
||||
}
|
||||
|
||||
return {
|
||||
positionX: box.positionX + box.width / 2,
|
||||
positionY: box.positionY + box.height / 2,
|
||||
width: box.width / 2,
|
||||
height: box.height / 2,
|
||||
positionX: box.positionX + toPercentX(cellWidth / 2 - centerX),
|
||||
positionY: box.positionY + toPercentY(cellHeight / 2 - centerY),
|
||||
width,
|
||||
height,
|
||||
rotation,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* A square box in page units, as tall as the given number of rows.
|
||||
* A square box as tall as the given number of rows.
|
||||
*/
|
||||
const square = (row: number, column: number, rows: number) => {
|
||||
const square = (row: number, column: number, rows: number): Box => {
|
||||
const box = cell(row, column, { rows });
|
||||
|
||||
return { ...box, width: box.height * A4_ASPECT };
|
||||
return { ...box, width: toPercentX(toPointsY(box.height)) };
|
||||
};
|
||||
|
||||
// --- Builders ---
|
||||
@@ -108,22 +163,14 @@ type Overrides<T> = Partial<Omit<T, 'type'>>;
|
||||
|
||||
const text = (box: Box, meta: Overrides<TContentTextMeta> & { text: string }): ContentTestData => ({
|
||||
page: CONTENT_ALIGNMENT_PAGES.text,
|
||||
contentMeta: { type: EnvelopeContentType.TEXT, page: CONTENT_ALIGNMENT_PAGES.text, rotation: 0, ...box, ...meta },
|
||||
contentMeta: { type: EnvelopeContentType.TEXT, page: CONTENT_ALIGNMENT_PAGES.text, ...box, ...meta },
|
||||
});
|
||||
|
||||
type LinePoints = { x1: number; y1: number; x2: number; y2: number };
|
||||
|
||||
const horizontal = (box: Box): LinePoints => ({
|
||||
x1: box.positionX,
|
||||
x2: box.positionX + box.width,
|
||||
y1: box.positionY + box.height / 2,
|
||||
y2: box.positionY + box.height / 2,
|
||||
});
|
||||
type LineMeta = { strokeWidth: number; strokeColor?: string; strokeStyle?: 'solid' | 'dashed' | 'dotted' };
|
||||
|
||||
const line = (
|
||||
points: LinePoints,
|
||||
meta: { strokeWidth: number; strokeColor?: string; strokeStyle?: 'solid' | 'dashed' | 'dotted' },
|
||||
): ContentTestData => ({
|
||||
const line = (points: LinePoints, meta: LineMeta): ContentTestData => ({
|
||||
page: CONTENT_ALIGNMENT_PAGES.line,
|
||||
contentMeta: {
|
||||
type: EnvelopeContentType.LINE,
|
||||
@@ -135,7 +182,21 @@ const line = (
|
||||
},
|
||||
});
|
||||
|
||||
const rectangle = (box: Box, meta: Overrides<TContentShapeMeta> = {}): ContentTestData => ({
|
||||
/**
|
||||
* A horizontal line across the middle of a box.
|
||||
*
|
||||
* Round line ends reach past the line's points by half its width, so the
|
||||
* points are pulled in by that much to keep the whole line within its box.
|
||||
* Otherwise thick lines in neighbouring columns join across the gap.
|
||||
*/
|
||||
const horizontalLine = (box: Box, meta: LineMeta): ContentTestData => {
|
||||
const inset = toPercentX(meta.strokeWidth / 2);
|
||||
const y = box.positionY + box.height / 2;
|
||||
|
||||
return line({ x1: box.positionX + inset, y1: y, x2: box.positionX + box.width - inset, y2: y }, meta);
|
||||
};
|
||||
|
||||
const rectangle = (box: RotatableBox, meta: Overrides<TContentShapeMeta> = {}): ContentTestData => ({
|
||||
page: CONTENT_ALIGNMENT_PAGES.shape,
|
||||
contentMeta: {
|
||||
type: EnvelopeContentType.SHAPE,
|
||||
@@ -155,7 +216,6 @@ const highlight = (box: Box, meta: Overrides<TContentHighlightMeta> = {}): Conte
|
||||
contentMeta: {
|
||||
type: EnvelopeContentType.HIGHLIGHT,
|
||||
page: CONTENT_ALIGNMENT_PAGES.highlight,
|
||||
rotation: 0,
|
||||
color: '#ffe600',
|
||||
fillOpacity: 0.4,
|
||||
...box,
|
||||
@@ -163,7 +223,7 @@ const highlight = (box: Box, meta: Overrides<TContentHighlightMeta> = {}): Conte
|
||||
},
|
||||
});
|
||||
|
||||
const image = (box: Box, meta: Overrides<TContentImageMeta> = {}): ContentTestData => ({
|
||||
const image = (box: RotatableBox, meta: Overrides<TContentImageMeta> = {}): ContentTestData => ({
|
||||
page: CONTENT_ALIGNMENT_PAGES.image,
|
||||
image: 'logo',
|
||||
contentMeta: { type: EnvelopeContentType.IMAGE, page: CONTENT_ALIGNMENT_PAGES.image, rotation: 0, ...box, ...meta },
|
||||
@@ -184,30 +244,26 @@ const TEXT_CONTENTS: ContentTestData[] = [
|
||||
text(cell(3, 1), { text: 'Size 14', fontSize: 14, color: '#d00000' }),
|
||||
text(cell(3, 2), { text: 'Size 24 cropped to the box', fontSize: 24 }),
|
||||
|
||||
text(cell(4, 0), { text: 'Two lines of text wrap here', lineHeight: 1.6 }),
|
||||
// Long enough to wrap, so the line height shows in the gap between the lines.
|
||||
text(cell(4, 0), { text: 'Two lines of text, wrapped and spaced apart by the line height', lineHeight: 1.6 }),
|
||||
text(cell(4, 1), { text: 'Spaced', letterSpacing: 3 }),
|
||||
text(cell(4, 2, { rows: 2 }), {
|
||||
text: 'A longer paragraph of text which wraps onto several lines within its box, centered both ways.',
|
||||
textAlign: 'center',
|
||||
verticalAlign: 'middle',
|
||||
}),
|
||||
|
||||
// Rotation pivots on the box's top left corner, so a 90 degree rotation
|
||||
// hangs down the left edge of its cell. Inset it so it stays inside.
|
||||
text(cell(6, 0, { rows: 2 }), { text: 'Rotated 15', rotation: 15 }),
|
||||
text({ ...cell(6, 1, { rows: 2 }), positionX: cell(6, 1).positionX + 8 }, { text: 'Rotated 90', rotation: 90 }),
|
||||
];
|
||||
|
||||
// --- Page 2: Lines ---
|
||||
|
||||
const LINE_CONTENTS: ContentTestData[] = [
|
||||
line(horizontal(cell(0, 0)), { strokeWidth: 1, strokeStyle: 'solid' }),
|
||||
line(horizontal(cell(0, 1)), { strokeWidth: 2, strokeStyle: 'dashed' }),
|
||||
line(horizontal(cell(0, 2)), { strokeWidth: 3, strokeStyle: 'dotted' }),
|
||||
horizontalLine(cell(0, 0), { strokeWidth: 1, strokeStyle: 'solid' }),
|
||||
horizontalLine(cell(0, 1), { strokeWidth: 2, strokeStyle: 'dashed' }),
|
||||
horizontalLine(cell(0, 2), { strokeWidth: 3, strokeStyle: 'dotted' }),
|
||||
|
||||
line(horizontal(cell(1, 0)), { strokeWidth: 0.5 }),
|
||||
line(horizontal(cell(1, 1)), { strokeWidth: 5 }),
|
||||
line(horizontal(cell(1, 2)), { strokeWidth: 10 }),
|
||||
horizontalLine(cell(1, 0), { strokeWidth: 0.5 }),
|
||||
horizontalLine(cell(1, 1), { strokeWidth: 5 }),
|
||||
horizontalLine(cell(1, 2), { strokeWidth: 10 }),
|
||||
|
||||
// Diagonals across the full width, corner to corner.
|
||||
...(() => {
|
||||
@@ -313,11 +369,10 @@ const SHAPE_CONTENTS: ContentTestData[] = [
|
||||
rectangle(square(2, 0, 2), { strokeWidth: 2 }),
|
||||
rectangle(cell(2, 1, { columns: 2 }), { strokeWidth: 1, fillColor: '#e5e7eb', fillOpacity: 1 }),
|
||||
|
||||
// Rotation pivots on the box's top left corner. Half sized boxes placed
|
||||
// towards the right of their cells keep the swept shape within the cell.
|
||||
rectangle(halfCell(4, 0, { rows: 2 }), { strokeWidth: 2, strokeColor: '#d00000', rotation: 15 }),
|
||||
rectangle(halfCell(4, 1, { rows: 2 }), { strokeWidth: 2, strokeColor: '#00aa55', rotation: 45 }),
|
||||
rectangle(halfCell(4, 2, { rows: 2 }), { strokeWidth: 2, strokeColor: '#0055aa', rotation: 90 }),
|
||||
// Each rotated shape is centered in its cell, clear of the stacking row.
|
||||
rectangle(rotatedHalfCell(4, 0, 15, { rows: 2 }), { strokeWidth: 2, strokeColor: '#d00000' }),
|
||||
rectangle(rotatedHalfCell(4, 1, 45, { rows: 2 }), { strokeWidth: 2, strokeColor: '#00aa55' }),
|
||||
rectangle(rotatedHalfCell(4, 2, 90, { rows: 2 }), { strokeWidth: 2, strokeColor: '#0055aa' }),
|
||||
|
||||
// Stacking. Each cell holds two overlapping filled rectangles: the first
|
||||
// (top left) and the second (offset to the bottom right). The rectangles
|
||||
@@ -342,9 +397,6 @@ const HIGHLIGHT_CONTENTS: ContentTestData[] = [
|
||||
highlight(cell(1, 2), { fillOpacity: 1 }),
|
||||
|
||||
highlight(cell(2, 0, { columns: 3 })),
|
||||
|
||||
highlight(halfCell(3, 0, { rows: 2 }), { rotation: 15 }),
|
||||
highlight(halfCell(3, 1, { rows: 2 }), { rotation: 45 }),
|
||||
];
|
||||
|
||||
// --- Page 5: Images ---
|
||||
@@ -357,14 +409,13 @@ const IMAGE_CONTENTS: ContentTestData[] = [
|
||||
|
||||
image(cell(3, 0, { columns: 3, rows: 2 })),
|
||||
|
||||
// Rotation pivots on the box's top left corner. Half sized boxes placed
|
||||
// towards the right of their cells keep the swept image within the cell.
|
||||
image(halfCell(5, 0, { rows: 2 }), { rotation: 15 }),
|
||||
image(halfCell(5, 1, { rows: 2 }), { rotation: 45 }),
|
||||
image(halfCell(5, 2, { rows: 2 }), { rotation: 90 }),
|
||||
// Each rotated image is centered in its cell, clear of its neighbours.
|
||||
image(rotatedHalfCell(5, 0, 15, { rows: 2 })),
|
||||
image(rotatedHalfCell(5, 1, 45, { rows: 2 })),
|
||||
image(rotatedHalfCell(5, 2, 90, { rows: 2 })),
|
||||
|
||||
image(halfCell(7, 0, { rows: 2 }), { rotation: 180 }),
|
||||
image(halfCell(7, 1, { rows: 2 }), { rotation: 270 }),
|
||||
image(rotatedHalfCell(7, 0, 180, { rows: 2 })),
|
||||
image(rotatedHalfCell(7, 1, 270, { rows: 2 })),
|
||||
];
|
||||
|
||||
export const CONTENT_ALIGNMENT_TEST_CONTENTS: ContentTestData[] = [
|
||||
|
||||
@@ -25,10 +25,11 @@ test.describe.configure({
|
||||
* transform.
|
||||
*/
|
||||
|
||||
const textMeta = (overrides: Partial<TEnvelopeContentMetaInput> = {}): TEnvelopeContentMetaInput => ({
|
||||
type TextMetaInput = Extract<TEnvelopeContentMetaInput, { type: EnvelopeContentType.TEXT }>;
|
||||
|
||||
const textMeta = (overrides: Partial<Omit<TextMetaInput, 'type'>> = {}): TEnvelopeContentMetaInput => ({
|
||||
type: EnvelopeContentType.TEXT,
|
||||
page: 1,
|
||||
rotation: 0,
|
||||
positionX: 10,
|
||||
positionY: 10,
|
||||
width: 20,
|
||||
@@ -67,7 +68,7 @@ const setupEnvelope = async (page: Page, type: 'document' | 'template' = 'docume
|
||||
const envelopeRecord =
|
||||
type === 'document'
|
||||
? await seedBlankDocument(user, team.id, { internalVersion: 2 })
|
||||
: await seedBlankTemplate(user, team.id, { internalVersion: 2 });
|
||||
: await seedBlankTemplate(user, team.id, { createTemplateOptions: { internalVersion: 2 } });
|
||||
|
||||
await apiSignin({ page, email: user.email, redirectPath: `/t/${team.url}/documents` });
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ test.describe.configure({
|
||||
const textMeta = (index: number, overrides: { zIndex?: number } = {}): TEnvelopeContentMetaInput => ({
|
||||
type: EnvelopeContentType.TEXT,
|
||||
page: 1,
|
||||
rotation: 0,
|
||||
positionX: 10,
|
||||
positionY: 10,
|
||||
width: 20,
|
||||
|
||||
@@ -179,7 +179,6 @@ const buildAndDistributeEnvelopeWithContents = async ({
|
||||
: {
|
||||
type: EnvelopeContentType.TEXT,
|
||||
page: 1,
|
||||
rotation: 0,
|
||||
zIndex: 0,
|
||||
positionX: 10,
|
||||
positionY: 10,
|
||||
|
||||
@@ -16,7 +16,7 @@ import type {
|
||||
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
|
||||
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
|
||||
import { type APIRequestContext, expect, test } from '@playwright/test';
|
||||
import { EnvelopeType, type Team } from '@prisma/client';
|
||||
import { EnvelopeType, FieldType, RecipientRole, type Team } from '@prisma/client';
|
||||
|
||||
import { createGifLabelledAsPng, createImageFile, type TestImageFile } from '../../fixtures/contents';
|
||||
|
||||
@@ -378,6 +378,62 @@ test.describe('Create envelope with contents', () => {
|
||||
});
|
||||
}
|
||||
|
||||
// A file is picked by its position counted from 0, so any other number is
|
||||
// refused rather than read as some other file (-1 used to mean the last).
|
||||
for (const { identifier, description } of [
|
||||
{ identifier: -1, description: 'a negative file index' },
|
||||
{ identifier: 0.5, description: 'a fractional file index' },
|
||||
]) {
|
||||
test(`rejects a content on ${description}`, async ({ request }) => {
|
||||
const externalId = `e2e-contents-bad-index-${nanoid()}`;
|
||||
|
||||
const res = await createEnvelopeWithContents({
|
||||
request,
|
||||
token,
|
||||
payload: { externalId },
|
||||
contents: [{ identifier, contentMeta: textMeta('Bad index') }],
|
||||
});
|
||||
|
||||
expect(res.status()).toBe(400);
|
||||
expect(await prisma.envelope.count({ where: { externalId } })).toBe(0);
|
||||
});
|
||||
|
||||
test(`rejects a field on ${description}`, async ({ request }) => {
|
||||
const externalId = `e2e-fields-bad-index-${nanoid()}`;
|
||||
|
||||
const res = await createEnvelopeWithContents({
|
||||
request,
|
||||
token,
|
||||
payload: {
|
||||
externalId,
|
||||
recipients: [
|
||||
{
|
||||
email: `signer-${nanoid()}@test.documenso.com`,
|
||||
name: 'Signer',
|
||||
role: RecipientRole.SIGNER,
|
||||
fields: [
|
||||
{
|
||||
type: FieldType.SIGNATURE,
|
||||
identifier,
|
||||
page: 1,
|
||||
positionX: 10,
|
||||
positionY: 10,
|
||||
width: 10,
|
||||
height: 5,
|
||||
fieldMeta: { type: 'signature', overflow: 'crop' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
contents: [],
|
||||
});
|
||||
|
||||
expect(res.status()).toBe(400);
|
||||
expect(await prisma.envelope.count({ where: { externalId } })).toBe(0);
|
||||
});
|
||||
}
|
||||
|
||||
test('rejects an image which is not really a PNG, JPEG or WebP', async ({ request }) => {
|
||||
const externalId = `e2e-contents-format-${nanoid()}`;
|
||||
const image = createGifLabelledAsPng(`format-${nanoid()}.png`);
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
import type Konva from 'konva';
|
||||
|
||||
import {
|
||||
getPageCanvas,
|
||||
interactWithCanvasPastActionBar,
|
||||
placeContentOnPdf,
|
||||
selectEditorTab,
|
||||
} from '../fixtures/contents';
|
||||
import { clickAddMyselfButton, clickEnvelopeEditorStep, openDocumentEnvelopeEditor } from '../fixtures/envelope-editor';
|
||||
import { getKonvaTransformerNodeCountForPage } from '../fixtures/konva';
|
||||
|
||||
/**
|
||||
* A selected item's resize handles reach a little into the item, so they are
|
||||
* easy to grab. Pressing and releasing one without dragging used to count as
|
||||
* a click on the empty page, which deselected the item.
|
||||
*/
|
||||
|
||||
const getCanvasBox = async (page: Page) => {
|
||||
const box = await getPageCanvas(page).boundingBox();
|
||||
|
||||
if (!box) {
|
||||
throw new Error('The page canvas is not rendered');
|
||||
}
|
||||
|
||||
return box;
|
||||
};
|
||||
|
||||
/**
|
||||
* Press and release the selection's bottom middle resize handle without
|
||||
* moving, just inside the selected item.
|
||||
*/
|
||||
const clickResizeHandle = async (page: Page) => {
|
||||
const handle = await page.evaluate(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const konva: typeof Konva = (window as unknown as { Konva: typeof Konva }).Konva;
|
||||
|
||||
const anchor = konva.stages.find((stage) => stage.attrs.id === 'page-1')?.findOne('.bottom-center');
|
||||
|
||||
if (!anchor) {
|
||||
throw new Error('The selection has no bottom middle resize handle');
|
||||
}
|
||||
|
||||
const rect = anchor.getClientRect();
|
||||
|
||||
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
|
||||
});
|
||||
|
||||
const canvas = await getCanvasBox(page);
|
||||
|
||||
await interactWithCanvasPastActionBar(page, async () => {
|
||||
await page.mouse.move(canvas.x + handle.x + 10, canvas.y + handle.y - 5);
|
||||
await page.mouse.down();
|
||||
await page.mouse.up();
|
||||
});
|
||||
};
|
||||
|
||||
const expectSelectionKept = async (page: Page) => {
|
||||
// Nothing should change, so give anything which would deselect a moment.
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
expect(await getKonvaTransformerNodeCountForPage(page, 1)).toBe(1);
|
||||
};
|
||||
|
||||
/**
|
||||
* An empty area of the page still deselects.
|
||||
*/
|
||||
const expectEmptyClickDeselects = async (page: Page) => {
|
||||
const canvas = await getCanvasBox(page);
|
||||
|
||||
await page.mouse.click(canvas.x + 400, canvas.y + 700);
|
||||
|
||||
await expect.poll(async () => await getKonvaTransformerNodeCountForPage(page, 1)).toBe(0);
|
||||
};
|
||||
|
||||
test('clicking a content resize handle without dragging keeps the content selected', async ({ page }) => {
|
||||
await openDocumentEnvelopeEditor(page);
|
||||
await clickEnvelopeEditorStep(page, 'addFields');
|
||||
await expect(getPageCanvas(page)).toBeVisible();
|
||||
await selectEditorTab(page, 'Contents');
|
||||
|
||||
// A newly placed content is selected.
|
||||
await placeContentOnPdf(page, 'Text', { x: 160, y: 160 });
|
||||
await expect.poll(async () => await getKonvaTransformerNodeCountForPage(page, 1)).toBe(1);
|
||||
|
||||
await clickResizeHandle(page);
|
||||
await expectSelectionKept(page);
|
||||
|
||||
await expectEmptyClickDeselects(page);
|
||||
});
|
||||
|
||||
test('clicking a field resize handle without dragging keeps the field selected', async ({ page }) => {
|
||||
await openDocumentEnvelopeEditor(page);
|
||||
await clickAddMyselfButton(page);
|
||||
await clickEnvelopeEditorStep(page, 'addFields');
|
||||
await expect(getPageCanvas(page)).toBeVisible();
|
||||
|
||||
// A newly placed field is selected.
|
||||
await page.getByRole('button', { name: 'Signature', exact: true }).click();
|
||||
await getPageCanvas(page).click({ position: { x: 160, y: 160 } });
|
||||
await expect.poll(async () => await getKonvaTransformerNodeCountForPage(page, 1)).toBe(1);
|
||||
|
||||
await clickResizeHandle(page);
|
||||
await expectSelectionKept(page);
|
||||
|
||||
await expectEmptyClickDeselects(page);
|
||||
});
|
||||
@@ -71,7 +71,9 @@ test('a selected content survives zooming', async ({ page }) => {
|
||||
|
||||
await placeContentOnPdf(root, 'Text', { x: 160, y: 160 });
|
||||
|
||||
await selectContentOnCanvas(root, { x: 170, y: 170 });
|
||||
// Clicked in the middle, where it was placed. Placing already selects it,
|
||||
// and near its edges a click lands on the handles of that selection.
|
||||
await selectContentOnCanvas(root, { x: 160, y: 160 });
|
||||
|
||||
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(1);
|
||||
await expect(root.getByTestId('envelope-canvas-action-bar')).toBeVisible();
|
||||
|
||||
+73
-11
@@ -2,7 +2,7 @@ import { APP_CONTENT_IMAGE_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/en
|
||||
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { createCanvas } from '@napi-rs/canvas';
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
import { expect, type FileChooser, type Page, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
getContentActionButton,
|
||||
@@ -52,6 +52,15 @@ const uploadThroughDialog = async (page: Page, buffer: Buffer) => {
|
||||
await (await fileChooser).setFiles({ name: 'logo.png', mimeType: 'image/png', buffer });
|
||||
};
|
||||
|
||||
/**
|
||||
* The upload dialog, including while it is hidden.
|
||||
*
|
||||
* While the native picker is open the dialog hides its content and shows only
|
||||
* its overlay. Role locators skip hidden elements unless told otherwise, so
|
||||
* without `includeHidden` an open dialog would look closed.
|
||||
*/
|
||||
const getUploadDialog = (page: Page) => page.getByRole('dialog', { includeHidden: true });
|
||||
|
||||
test('a dropped file which cannot be used shows why', async ({ page }) => {
|
||||
await openContentsEditor(page);
|
||||
|
||||
@@ -147,53 +156,106 @@ test('the upload lands on the content it was opened for', async ({ page }) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* Dismissing the native picker leaves the dialog open on its "Choose image"
|
||||
* button. Pressing that must reopen the picker even though the dialog's
|
||||
* state has not changed.
|
||||
* Dismissing the native picker closes the dialog, so picking again means
|
||||
* opening it again. That must start a fresh picker rather than reuse the
|
||||
* dismissed dialog's state.
|
||||
*/
|
||||
test('the picker can be reopened from the dialog after being dismissed', async ({ page }) => {
|
||||
test('the picker can be reopened after being dismissed', async ({ page }) => {
|
||||
const surface = await openContentsEditor(page);
|
||||
|
||||
await placeContentOnPdf(page, 'Image', { x: 200, y: 200 });
|
||||
await waitForContentsAutosave(surface);
|
||||
|
||||
// Open the picker and leave it unanswered: the dialog stays on "picking".
|
||||
const dialog = getUploadDialog(page);
|
||||
|
||||
// Nothing matches before the dialog opens, so what matches below is the
|
||||
// upload dialog rather than some other hidden dialog.
|
||||
await expect(dialog).toHaveCount(0);
|
||||
|
||||
const firstChooser = page.waitForEvent('filechooser');
|
||||
|
||||
await getContentActionButton(page, 'Upload image').click();
|
||||
await firstChooser;
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Choose image' })).toBeVisible();
|
||||
await expect(dialog).toHaveCount(1);
|
||||
|
||||
// Playwright cannot dismiss a native picker, but the browser reports a
|
||||
// dismissal as a `cancel` event on the input.
|
||||
await page.locator('input[id^="content-image-input-"]').dispatchEvent('cancel');
|
||||
await expect(dialog).toHaveCount(0);
|
||||
|
||||
// Pressing the dialog's button opens a fresh picker.
|
||||
const secondChooser = page.waitForEvent('filechooser');
|
||||
|
||||
await page.getByRole('button', { name: 'Choose image' }).click();
|
||||
await getContentActionButton(page, 'Upload image').click();
|
||||
|
||||
await (await secondChooser).setFiles({ name: 'logo.png', mimeType: 'image/png', buffer: await createPng() });
|
||||
|
||||
await expect(getContentActionButton(page, 'Replace image')).toBeVisible();
|
||||
});
|
||||
|
||||
/**
|
||||
* The drop area opens the upload dialog from the keyboard too. Only the
|
||||
* dialog's picker may open: the dropzone used to open its own picker as well,
|
||||
* so a keyboard user got two.
|
||||
*/
|
||||
for (const key of ['Enter', 'Space']) {
|
||||
test(`pressing ${key} on the drop area opens a single file picker`, async ({ page }) => {
|
||||
await openContentsEditor(page);
|
||||
|
||||
await placeContentOnPdf(page, 'Image', { x: 200, y: 200 });
|
||||
|
||||
const choosers: FileChooser[] = [];
|
||||
|
||||
page.on('filechooser', (chooser) => choosers.push(chooser));
|
||||
|
||||
await page.locator('div[role="button"]', { hasText: 'Click to upload or drag and drop' }).focus();
|
||||
await page.keyboard.press(key);
|
||||
|
||||
// Give a second picker time to open, as it used to.
|
||||
await expect.poll(() => choosers.length).toBeGreaterThan(0);
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
expect(choosers).toHaveLength(1);
|
||||
|
||||
// The one picker is the upload dialog's, so the file goes through it.
|
||||
expect(await choosers[0].element().getAttribute('id')).toMatch(/^content-image-input-/);
|
||||
|
||||
await choosers[0].setFiles({ name: 'logo.png', mimeType: 'image/png', buffer: await createPng() });
|
||||
|
||||
await expect(getContentActionButton(page, 'Replace image')).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
test('cancelling the picker closes the dialog', async ({ page }) => {
|
||||
const surface = await openContentsEditor(page);
|
||||
|
||||
await placeContentOnPdf(page, 'Image', { x: 200, y: 200 });
|
||||
await waitForContentsAutosave(surface);
|
||||
|
||||
const dialog = getUploadDialog(page);
|
||||
|
||||
// Nothing matches before the dialog opens, so what matches below is the
|
||||
// upload dialog rather than some other hidden dialog.
|
||||
await expect(dialog).toHaveCount(0);
|
||||
|
||||
const chooser = page.waitForEvent('filechooser');
|
||||
|
||||
await getContentActionButton(page, 'Upload image').click();
|
||||
await chooser;
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
// While the picker is open only the dialog's overlay shows, which still
|
||||
// blocks the editor. A trial click checks that without clicking.
|
||||
await expect(dialog).toHaveCount(1);
|
||||
await expect(getPageCanvas(page).click({ position: { x: 50, y: 50 }, trial: true, timeout: 1500 })).rejects.toThrow();
|
||||
|
||||
// Playwright cannot dismiss a native picker, but the browser reports a
|
||||
// dismissal as a `cancel` event on the input, which is what the dialog
|
||||
// listens for.
|
||||
await page.locator('input[id^="content-image-input-"]').dispatchEvent('cancel');
|
||||
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||
// The dialog is gone and the editor can be used again.
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await getPageCanvas(page).click({ position: { x: 50, y: 50 }, trial: true });
|
||||
await expect(getContentActionButton(page, 'Upload image')).toBeVisible();
|
||||
});
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ const runUploadReplaceRemoveFlow = async (surface: TEnvelopeEditorSurface) => {
|
||||
await uploadImage(root, wide);
|
||||
await waitForUploadToLand(root);
|
||||
|
||||
await expect(root.getByText('wide-logo.png')).toBeVisible();
|
||||
// The settings panel shows the image's size and dimensions.
|
||||
await expect(root.getByText('400 * 200')).toBeVisible();
|
||||
|
||||
const [withWide] = await getContentGroupsForPage(root);
|
||||
@@ -157,7 +157,7 @@ const runUploadReplaceRemoveFlow = async (surface: TEnvelopeEditorSurface) => {
|
||||
await uploadImage(root, tall);
|
||||
await waitForUploadToLand(root);
|
||||
|
||||
await expect(root.getByText('tall-logo.jpg')).toBeVisible();
|
||||
await expect(root.getByText('100 * 300')).toBeVisible();
|
||||
|
||||
const [withTall] = await getContentGroupsForPage(root);
|
||||
|
||||
@@ -224,7 +224,7 @@ const runActionBarUploadFlow = async (surface: TEnvelopeEditorSurface) => {
|
||||
|
||||
await (await fileChooser).setFiles({ name: huge.name, mimeType: huge.mimeType, buffer: huge.buffer });
|
||||
|
||||
await expect(root.getByText('huge-logo.png')).toBeVisible();
|
||||
await expect(root.getByText('2000 * 2000')).toBeVisible();
|
||||
await expect(getContentActionButton(root, 'Replace image')).toBeVisible();
|
||||
|
||||
const pageSize = await getPageSize(root);
|
||||
@@ -333,7 +333,7 @@ const runSendGuardFlow = async (surface: TEnvelopeEditorSurface) => {
|
||||
|
||||
await uploadImage(root, logo);
|
||||
await waitForUploadToLand(root);
|
||||
await expect(root.getByText('logo.png')).toBeVisible();
|
||||
await expect(root.getByText('120 * 60')).toBeVisible();
|
||||
|
||||
await waitForContentsAutosave(surface);
|
||||
|
||||
|
||||
@@ -122,7 +122,12 @@ test('duplicate is hidden at the limit and existing contents stay removable', as
|
||||
const [content] = await getContentGroupsForPage(root);
|
||||
const { scale } = await getPageSize(root);
|
||||
|
||||
await selectContentOnCanvas(root, { x: (content.rect.x + 4) * scale, y: (content.rect.y + 4) * scale });
|
||||
// Clicked in the middle. Placing already selects it, and near its edges a
|
||||
// click lands on the handles of that selection.
|
||||
await selectContentOnCanvas(root, {
|
||||
x: (content.rect.x + content.rect.width / 2) * scale,
|
||||
y: (content.rect.y + content.rect.height / 2) * scale,
|
||||
});
|
||||
|
||||
// Duplicating would add a content, so it is gone; removing is still offered.
|
||||
await expect(getContentActionButton(root, 'Duplicate')).toHaveCount(0);
|
||||
|
||||
@@ -31,7 +31,6 @@ const seedTiedContents = async (envelopeId: string) => {
|
||||
contentMeta: ZEnvelopeContentMetaSchema.parse({
|
||||
type: EnvelopeContentType.TEXT,
|
||||
page: 1,
|
||||
rotation: 0,
|
||||
positionX,
|
||||
positionY: 20,
|
||||
width: 10,
|
||||
|
||||
@@ -48,7 +48,6 @@ const seedContent = async (envelopeId: string, envelopeItemId: string, page: num
|
||||
contentMeta: ZEnvelopeContentMetaSchema.parse({
|
||||
type: EnvelopeContentType.TEXT,
|
||||
page,
|
||||
rotation: 0,
|
||||
positionX: 10,
|
||||
positionY: 10,
|
||||
width: 20,
|
||||
@@ -71,6 +70,10 @@ test('does not recreate contents removed by a pdf replacement', async ({ page })
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const { root, envelopeId } = surface;
|
||||
|
||||
if (!envelopeId) {
|
||||
throw new Error('Expected the document editor surface to have an envelopeId');
|
||||
}
|
||||
|
||||
// A three page PDF so a content can live on a page the replacement drops.
|
||||
await replacePdf(root, multiPagePdfBuffer, 'multi-page.pdf');
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ const seedStaticContents = async (envelopeId: string, envelopeItemId: string) =>
|
||||
contentMeta: ZEnvelopeContentMetaSchema.parse({
|
||||
type: EnvelopeContentType.TEXT,
|
||||
page: 1,
|
||||
rotation: 0,
|
||||
zIndex: 0,
|
||||
positionX: 10,
|
||||
positionY: 10,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createDataContentImage } from '@documenso/lib/server-only/data-content/
|
||||
import { EnvelopeContentType, ZEnvelopeContentMetaSchema } from '@documenso/lib/types/envelope-content-meta';
|
||||
import { generateDatabaseId } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedBlankDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { createCanvas } from '@napi-rs/canvas';
|
||||
import { expect, test } from '@playwright/test';
|
||||
@@ -13,6 +13,10 @@ import { apiSignin } from '../fixtures/authentication';
|
||||
* A page and everything drawn on it appear together. The page image is held
|
||||
* back until the content images are loaded too, so a page is never shown in a
|
||||
* half drawn state.
|
||||
*
|
||||
* Contents are only drawn in the browser while the document is a draft. Once
|
||||
* it is sent they are part of the PDF, so the signing page loads no content
|
||||
* images at all.
|
||||
*/
|
||||
|
||||
const IMAGE_LOAD_MS = 4000;
|
||||
@@ -20,16 +24,13 @@ const IMAGE_LOAD_MS = 4000;
|
||||
test('a page waiting on its content images shows a loader', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const document = await seedPendingDocument(user, team.id, [user], { internalVersion: 2 });
|
||||
const document = await seedBlankDocument(user, team.id, { internalVersion: 2 });
|
||||
|
||||
const envelope = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: document.id },
|
||||
include: { envelopeItems: true, recipients: true },
|
||||
include: { envelopeItems: true },
|
||||
});
|
||||
|
||||
// The seeded fields carry no `fieldMeta`, which the v2 signer rejects.
|
||||
await prisma.field.deleteMany({ where: { envelopeId: envelope.id } });
|
||||
|
||||
const png = await createCanvas(60, 30).encode('png');
|
||||
|
||||
const dataContent = await createDataContentImage({
|
||||
@@ -61,7 +62,11 @@ test('a page waiting on its content images shows a loader', async ({ page }) =>
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: user.email, redirectPath: `/sign/${envelope.recipients[0].token}` });
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents/${envelope.id}/edit?step=addFields`,
|
||||
});
|
||||
|
||||
const pageLoader = page.getByTestId('page-loader').first();
|
||||
const pageImage = page.locator('img[alt=""]').first();
|
||||
|
||||
@@ -21,7 +21,6 @@ const multiPagePdf = fs.readFileSync(path.join(__dirname, '../../../../assets/fi
|
||||
const textMetaOnPage = (page: number) => ({
|
||||
type: EnvelopeContentType.TEXT,
|
||||
page,
|
||||
rotation: 0,
|
||||
positionX: 10,
|
||||
positionY: 10,
|
||||
width: 20,
|
||||
@@ -95,43 +94,6 @@ test('replacing a PDF drops contents which fall beyond the new page count', asyn
|
||||
expect(contentMeta.page).toBe(1);
|
||||
});
|
||||
|
||||
test('replacing a PDF keeps a content which has no page of its own', async () => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const document = await seedBlankDocument(user, team.id, { internalVersion: 2 });
|
||||
|
||||
await replacePdf(document.id, multiPagePdf, 'multi-page.pdf');
|
||||
|
||||
const envelope = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: document.id },
|
||||
include: { envelopeItems: true },
|
||||
});
|
||||
|
||||
// A content without a page belongs to the first page, so it always fits.
|
||||
await prisma.envelopeContent.create({
|
||||
data: {
|
||||
id: generateDatabaseId('envelope_content'),
|
||||
envelopeId: envelope.id,
|
||||
envelopeItemId: envelope.envelopeItems[0].id,
|
||||
contentMeta: ZEnvelopeContentMetaSchema.parse({
|
||||
type: EnvelopeContentType.TEXT,
|
||||
rotation: 0,
|
||||
positionX: 10,
|
||||
positionY: 10,
|
||||
width: 20,
|
||||
height: 6,
|
||||
text: 'No page',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await replacePdf(document.id, singlePagePdf, 'single-page.pdf');
|
||||
|
||||
const contents = await prisma.envelopeContent.findMany({ where: { envelopeId: envelope.id } });
|
||||
|
||||
expect(contents).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('replacing a PDF keeps contents which still fit', async () => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
|
||||
@@ -251,10 +251,13 @@ export const uploadImage = async (root: Page, file: TestImageFile) => {
|
||||
|
||||
/**
|
||||
* Wait for a successful upload's dialog to close.
|
||||
*
|
||||
* Hidden dialogs count too: while its picker is open the dialog hides its
|
||||
* content, and a dialog stuck like that has not finished.
|
||||
*/
|
||||
export const waitForUploadToLand = async (root: Page) => {
|
||||
await expect(root.getByTestId('content-image-uploading')).toHaveCount(0);
|
||||
await expect(root.getByRole('dialog')).toHaveCount(0);
|
||||
await expect(root.getByRole('dialog', { includeHidden: true })).toHaveCount(0);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -35,7 +35,7 @@ const createPng = async () => {
|
||||
const seedTemplateWithImageContent = async (page: Page) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const template = await seedBlankTemplate(user, team.id, { internalVersion: 2 });
|
||||
const template = await seedBlankTemplate(user, team.id, { createTemplateOptions: { internalVersion: 2 } });
|
||||
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: template.id },
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin, apiSignout, checkSessionValid } from '../fixtures/authentication';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test('[USER] can reset password via forgot password', async ({ page }: { page: Page }) => {
|
||||
@@ -14,9 +17,9 @@ test('[USER] can reset password via forgot password', async ({ page }: { page: P
|
||||
password: oldPassword,
|
||||
});
|
||||
|
||||
await page.goto('http://localhost:3000/signin');
|
||||
await page.goto(`${WEBAPP_BASE_URL}/signin`);
|
||||
await page.getByRole('link', { name: 'Forgot your password?' }).click();
|
||||
await expect(page).toHaveURL('http://localhost:3000/forgot-password');
|
||||
await expect(page).toHaveURL(`${WEBAPP_BASE_URL}/forgot-password`);
|
||||
|
||||
await page.getByRole('textbox', { name: 'Email' }).click();
|
||||
await page.getByRole('textbox', { name: 'Email' }).fill(user.email);
|
||||
@@ -37,7 +40,7 @@ test('[USER] can reset password via forgot password', async ({ page }: { page: P
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`http://localhost:3000/reset-password/${foundToken.token}`);
|
||||
await page.goto(`${WEBAPP_BASE_URL}/reset-password/${foundToken.token}`);
|
||||
|
||||
// Assert that password cannot be same as old password.
|
||||
await page.getByLabel('Password', { exact: true }).fill(oldPassword);
|
||||
@@ -131,9 +134,9 @@ test('[USER] password reset invalidates all sessions', async ({ page }: { page:
|
||||
|
||||
await page.context().clearCookies();
|
||||
|
||||
await page.goto('http://localhost:3000/signin');
|
||||
await page.goto(`${WEBAPP_BASE_URL}/signin`);
|
||||
await page.getByRole('link', { name: 'Forgot your password?' }).click();
|
||||
await expect(page).toHaveURL('http://localhost:3000/forgot-password');
|
||||
await expect(page).toHaveURL(`${WEBAPP_BASE_URL}/forgot-password`);
|
||||
await page.getByRole('textbox', { name: 'Email' }).fill(user.email);
|
||||
await page.getByRole('button', { name: 'Reset Password' }).click();
|
||||
await expect(page.locator('body')).toContainText('Reset email sent', {
|
||||
@@ -144,7 +147,7 @@ test('[USER] password reset invalidates all sessions', async ({ page }: { page:
|
||||
where: { userId: user.id },
|
||||
});
|
||||
|
||||
await page.goto(`http://localhost:3000/reset-password/${foundToken.token}`);
|
||||
await page.goto(`${WEBAPP_BASE_URL}/reset-password/${foundToken.token}`);
|
||||
await page.getByLabel('Password', { exact: true }).fill(newPassword);
|
||||
await page.getByLabel('Repeat Password').fill(newPassword);
|
||||
await page.getByRole('button', { name: 'Reset Password' }).click();
|
||||
@@ -152,8 +155,8 @@ test('[USER] password reset invalidates all sessions', async ({ page }: { page:
|
||||
|
||||
await page.context().addCookies(initialCookies);
|
||||
|
||||
await page.goto('http://localhost:3000/settings/profile');
|
||||
await expect(page).toHaveURL('http://localhost:3000/signin');
|
||||
await page.goto(`${WEBAPP_BASE_URL}/settings/profile`);
|
||||
await expect(page).toHaveURL(`${WEBAPP_BASE_URL}/signin`);
|
||||
|
||||
expect(await checkSessionValid(page)).toBe(false);
|
||||
|
||||
@@ -197,7 +200,7 @@ test('[USER] password update invalidates other sessions but keeps current', asyn
|
||||
|
||||
expect(await checkSessionValid(page)).toBe(true);
|
||||
|
||||
await page.goto('http://localhost:3000/settings/security');
|
||||
await page.goto(`${WEBAPP_BASE_URL}/settings/security`);
|
||||
await page.getByLabel('Current password').fill(oldPassword);
|
||||
await page.getByLabel('New password').fill(newPassword);
|
||||
await page.getByLabel('Repeat password').fill(newPassword);
|
||||
@@ -208,13 +211,13 @@ test('[USER] password update invalidates other sessions but keeps current', asyn
|
||||
|
||||
await page.context().clearCookies();
|
||||
await page.context().addCookies(initialCookies);
|
||||
await page.goto('http://localhost:3000/settings/profile');
|
||||
await expect(page).toHaveURL('http://localhost:3000/signin');
|
||||
await page.goto(`${WEBAPP_BASE_URL}/settings/profile`);
|
||||
await expect(page).toHaveURL(`${WEBAPP_BASE_URL}/signin`);
|
||||
expect(await checkSessionValid(page)).toBe(false);
|
||||
|
||||
await page.context().clearCookies();
|
||||
await page.context().addCookies(finalCookies);
|
||||
await page.goto('http://localhost:3000/settings/security');
|
||||
await expect(page).toHaveURL('http://localhost:3000/settings/security');
|
||||
await page.goto(`${WEBAPP_BASE_URL}/settings/security`);
|
||||
await expect(page).toHaveURL(`${WEBAPP_BASE_URL}/settings/security`);
|
||||
expect(await checkSessionValid(page)).toBe(true);
|
||||
});
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 217 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 301 KiB |
@@ -40,7 +40,7 @@ const rectangle = {
|
||||
|
||||
describe('generatePartialSignedPdf', () => {
|
||||
it('produces a valid PDF when there is nothing to draw', async () => {
|
||||
const output = await generatePartialSignedPdf({ pdfData: examplePdf });
|
||||
const output = await generatePartialSignedPdf({ pdfData: examplePdf, fields: [] });
|
||||
|
||||
const pdf = await PDF.load(output);
|
||||
|
||||
@@ -48,7 +48,7 @@ describe('generatePartialSignedPdf', () => {
|
||||
});
|
||||
|
||||
it('draws the contents onto the page', async () => {
|
||||
const output = await generatePartialSignedPdf({ pdfData: examplePdf, contents: [rectangle] });
|
||||
const output = await generatePartialSignedPdf({ pdfData: examplePdf, fields: [], contents: [rectangle] });
|
||||
|
||||
// The overlay is added as a new XObject, so the file must grow and must
|
||||
// still be a valid single page PDF.
|
||||
|
||||
@@ -8,6 +8,7 @@ import sharp from 'sharp';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { EnvelopeContentShapeType, EnvelopeContentType } from '../../types/envelope-content-meta';
|
||||
import type { ContentImageSource } from '../../universal/content-renderer/content-renderer';
|
||||
import { insertPageOverlays } from './insert-page-overlay';
|
||||
|
||||
/**
|
||||
@@ -38,6 +39,7 @@ const RED_RECTANGLE_OVERLAY: Overlay = {
|
||||
zIndex: 0,
|
||||
rotation: 0,
|
||||
...RECT,
|
||||
fillColor: null,
|
||||
fillOpacity: 0,
|
||||
strokeWidth: STROKE_WIDTH,
|
||||
strokeColor: '#ff0000',
|
||||
@@ -78,7 +80,9 @@ const createBlueImageOverlay = async (): Promise<Overlay> => {
|
||||
dataContentId: 'data_blue',
|
||||
},
|
||||
],
|
||||
images: new Map([['data_blue', new SkiaImage(Buffer.from(png))]]),
|
||||
// On the server a skia image stands in for a browser image, passed the
|
||||
// same way `loadContentImages` passes it.
|
||||
images: new Map([['data_blue', new SkiaImage(Buffer.from(png)) as unknown as ContentImageSource]]),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -47,7 +47,6 @@ type DocumentToSeed = {
|
||||
|
||||
export const seedDocuments = async (documents: DocumentToSeed[]) => {
|
||||
await Promise.all(
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
documents.map(async (document, i) =>
|
||||
match(document.type)
|
||||
.with(DocumentStatus.DRAFT, async () =>
|
||||
@@ -67,7 +66,13 @@ export const seedDocuments = async (documents: DocumentToSeed[]) => {
|
||||
key: i,
|
||||
createDocumentOptions: document.documentOptions,
|
||||
}),
|
||||
),
|
||||
)
|
||||
// Ending the match returns the seeding promise for `Promise.all` to
|
||||
// wait on. Without it the match object itself is returned, and the
|
||||
// documents are still being written when this resolves.
|
||||
.otherwise(() => {
|
||||
throw new Error(`Seeding ${document.type} documents is not supported`);
|
||||
}),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from 'node:path';
|
||||
import {
|
||||
CONTENT_ALIGNMENT_LOGO_PNG,
|
||||
CONTENT_ALIGNMENT_TEST_CONTENTS,
|
||||
getContentAlignmentCell,
|
||||
} from '@documenso/app-tests/constants/content-alignment-pdf';
|
||||
import { ALIGNMENT_TEST_FIELDS } from '@documenso/app-tests/constants/field-alignment-pdf';
|
||||
import { FIELD_META_TEST_FIELDS } from '@documenso/app-tests/constants/field-meta-pdf';
|
||||
@@ -551,16 +552,13 @@ export const seedContentAlignmentTestDocument = async ({
|
||||
const recipientId = recipients[0].id;
|
||||
const envelopeItemId = envelopeItems[0].id;
|
||||
|
||||
// The one field, in the spare cell of the text page's rotation row.
|
||||
// The one field, in the free row below the text page's grid.
|
||||
await prisma.field.create({
|
||||
data: {
|
||||
type: 'SIGNATURE',
|
||||
fieldMeta: { type: 'signature', overflow: 'auto' },
|
||||
page: 1,
|
||||
positionX: 66,
|
||||
positionY: 60,
|
||||
width: 28,
|
||||
height: 7,
|
||||
...getContentAlignmentCell(6, 0),
|
||||
recipientId,
|
||||
envelopeItemId,
|
||||
envelopeId: id,
|
||||
|
||||
@@ -53,8 +53,10 @@ export const ZCreateEnvelopePayloadSchema = z.object({
|
||||
fields: ZEnvelopeFieldAndMetaSchema.and(
|
||||
z.object({
|
||||
identifier: z
|
||||
.union([z.string(), z.number()])
|
||||
.describe('Either the filename or the index of the file that was uploaded to attach the field to.')
|
||||
.union([z.string(), z.number().int().min(0)])
|
||||
.describe(
|
||||
'Either the filename or the zero-based index of the file that was uploaded to attach the field to.',
|
||||
)
|
||||
.optional(),
|
||||
page: ZFieldPageNumberSchema,
|
||||
positionX: ZClampedFieldPositionXSchema,
|
||||
@@ -71,8 +73,8 @@ export const ZCreateEnvelopePayloadSchema = z.object({
|
||||
contents: z
|
||||
.object({
|
||||
identifier: z
|
||||
.union([z.string(), z.number()])
|
||||
.describe('Either the filename or the index of the file that was uploaded to attach the content to.')
|
||||
.union([z.string(), z.number().int().min(0)])
|
||||
.describe('Either the filename or the zero-based index of the file that was uploaded to attach the content to.')
|
||||
.optional(),
|
||||
contentMeta: ZEnvelopeContentMetaSchema.describe('All the properties of the content being placed.'),
|
||||
imageIndex: z
|
||||
|
||||
@@ -22,7 +22,8 @@ const GRID = {
|
||||
columnWidth: 28,
|
||||
columnGap: 1,
|
||||
rowHeight: 7,
|
||||
rowGap: 1,
|
||||
// Holds each box's label, which sits just above the box.
|
||||
rowGap: 2,
|
||||
};
|
||||
|
||||
const cellLeft = (column) => GRID.startX + column * (GRID.columnWidth + GRID.columnGap);
|
||||
@@ -34,8 +35,11 @@ function makeCell(row, column, label, { rows = 1, columns = 1 } = {}) {
|
||||
const width = columns * GRID.columnWidth + (columns - 1) * GRID.columnGap;
|
||||
const height = rows * GRID.rowHeight + (rows - 1) * GRID.rowGap;
|
||||
|
||||
// The label is anchored to the top of its box and lifted by its own height
|
||||
// (see `.label`), so it sits in the gap above the box rather than reaching
|
||||
// into the row above.
|
||||
return `
|
||||
<div class="label" style="left:${left}%;top:${top - 1.4}%;">${label}</div>
|
||||
<div class="label" style="left:${left}%;top:${top}%;">${label}</div>
|
||||
<div class="box" style="left:${left}%;top:${top}%;width:${width}%;height:${height}%;"></div>`;
|
||||
}
|
||||
|
||||
@@ -72,10 +76,6 @@ function makeTextPage() {
|
||||
content += makeCell(4, 1, 'LETTER_SPACING_3');
|
||||
content += makeCell(4, 2, 'WRAPPED', { rows: 2 });
|
||||
|
||||
content += makeRowLabel(6, 'ROTATION');
|
||||
content += makeCell(6, 0, 'ROTATED_15', { rows: 2 });
|
||||
content += makeCell(6, 1, 'ROTATED_90', { rows: 2, columns: 1 });
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
@@ -161,10 +161,6 @@ function makeHighlightPage() {
|
||||
content += makeCell(2, 0, 'HIGHLIGHT OVER THIS PRINTED TEXT', { columns: 3 });
|
||||
content += `<div class="body-text" style="left:${cellLeft(0) + 1}%;top:${cellTop(2) + 2}%;">The quick brown fox jumps over the lazy dog. A highlight is drawn over this line.</div>`;
|
||||
|
||||
content += makeRowLabel(3, 'ROTATION');
|
||||
content += makeCell(3, 0, 'ROTATED_15', { rows: 2 });
|
||||
content += makeCell(3, 1, 'ROTATED_45', { rows: 2 });
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
@@ -210,7 +206,7 @@ const html = `<!DOCTYPE html>
|
||||
.row-label { position: absolute; font-size: 7px; font-weight: bold; color: #666; width: 6%; }
|
||||
.body-text { position: absolute; font-size: 11px; color: #333; }
|
||||
.box { position: absolute; border: 1px solid #999; box-sizing: border-box; }
|
||||
.label { position: absolute; font-size: 7px; color: #c00; font-weight: bold; z-index: 9999; }
|
||||
.label { position: absolute; font-size: 7px; line-height: 1; white-space: nowrap; color: #c00; font-weight: bold; z-index: 9999; transform: translateY(calc(-100% - 4px)); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user