mirror of
https://github.com/documenso/documenso.git
synced 2026-07-25 17:35:05 +10:00
feat: add bulk document selection and move functionality (#2387)
This PR introduces bulk actions for documents, allowing users to select multiple envelopes and perform actions such as moving or deleting 1 or more documents simultaneously.
This commit is contained in:
@@ -4730,252 +4730,6 @@ test.describe('Document API V2', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Envelope item delete endpoint', () => {
|
||||
test('should block unauthorized access to envelope item delete endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const envelopeItem = await prisma.envelopeItem.findFirstOrThrow({
|
||||
where: { envelopeId: doc.id },
|
||||
});
|
||||
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/item/delete`, {
|
||||
headers: { Authorization: `Bearer ${tokenB}` },
|
||||
data: {
|
||||
envelopeId: doc.id,
|
||||
envelopeItemId: envelopeItem.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should allow authorized access to envelope item delete endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const envelopeItem = await prisma.envelopeItem.findFirstOrThrow({
|
||||
where: { envelopeId: doc.id },
|
||||
});
|
||||
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/item/delete`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
envelopeId: doc.id,
|
||||
envelopeItemId: envelopeItem.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Envelope attachment find endpoint', () => {
|
||||
test('should block unauthorized access to envelope attachment find endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const res = await request.get(
|
||||
`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment?envelopeId=${doc.id}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${tokenB}` },
|
||||
},
|
||||
);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should allow authorized access to envelope attachment find endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const res = await request.get(
|
||||
`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment?envelopeId=${doc.id}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
},
|
||||
);
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Envelope attachment create endpoint', () => {
|
||||
test('should block unauthorized access to envelope attachment create endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const res = await request.post(
|
||||
`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/create`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${tokenB}` },
|
||||
data: {
|
||||
envelopeId: doc.id,
|
||||
data: {
|
||||
label: 'Test Attachment',
|
||||
data: 'https://example.com/file.pdf',
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should allow authorized access to envelope attachment create endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const res = await request.post(
|
||||
`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/create`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
envelopeId: doc.id,
|
||||
data: {
|
||||
label: 'Test Attachment',
|
||||
data: 'https://example.com/file.pdf',
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Envelope attachment update endpoint', () => {
|
||||
test('should block unauthorized access to envelope attachment update endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const attachment = await prisma.envelopeAttachment.create({
|
||||
data: {
|
||||
envelopeId: doc.id,
|
||||
type: 'link',
|
||||
label: 'Original Label',
|
||||
data: 'https://example.com/original.pdf',
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request.post(
|
||||
`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/update`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${tokenB}` },
|
||||
data: {
|
||||
id: attachment.id,
|
||||
data: {
|
||||
label: 'Updated Label',
|
||||
data: 'https://example.com/updated.pdf',
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should allow authorized access to envelope attachment update endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const attachment = await prisma.envelopeAttachment.create({
|
||||
data: {
|
||||
envelopeId: doc.id,
|
||||
type: 'link',
|
||||
label: 'Original Label',
|
||||
data: 'https://example.com/original.pdf',
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request.post(
|
||||
`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/update`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
id: attachment.id,
|
||||
data: {
|
||||
label: 'Updated Label',
|
||||
data: 'https://example.com/updated.pdf',
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Envelope attachment delete endpoint', () => {
|
||||
test('should block unauthorized access to envelope attachment delete endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const attachment = await prisma.envelopeAttachment.create({
|
||||
data: {
|
||||
envelopeId: doc.id,
|
||||
type: 'link',
|
||||
label: 'Test Attachment',
|
||||
data: 'https://example.com/file.pdf',
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request.post(
|
||||
`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/delete`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${tokenB}` },
|
||||
data: { id: attachment.id },
|
||||
},
|
||||
);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should allow authorized access to envelope attachment delete endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const attachment = await prisma.envelopeAttachment.create({
|
||||
data: {
|
||||
envelopeId: doc.id,
|
||||
type: 'link',
|
||||
label: 'Test Attachment',
|
||||
data: 'https://example.com/file.pdf',
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request.post(
|
||||
`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/delete`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: { id: attachment.id },
|
||||
},
|
||||
);
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Envelope audit logs endpoint', () => {
|
||||
test('should block unauthorized access to envelope audit logs endpoint', async ({
|
||||
request,
|
||||
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import type { Team, User } from '@prisma/client';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
});
|
||||
|
||||
test.describe('Envelope Attachments API V2', () => {
|
||||
let userA: User, teamA: Team, userB: User, teamB: Team, tokenA: string, tokenB: string;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
({ user: userA, team: teamA } = await seedUser());
|
||||
({ token: tokenA } = await createApiToken({
|
||||
userId: userA.id,
|
||||
teamId: teamA.id,
|
||||
tokenName: 'userA',
|
||||
expiresIn: null,
|
||||
}));
|
||||
|
||||
({ user: userB, team: teamB } = await seedUser());
|
||||
({ token: tokenB } = await createApiToken({
|
||||
userId: userB.id,
|
||||
teamId: teamB.id,
|
||||
tokenName: 'userB',
|
||||
expiresIn: null,
|
||||
}));
|
||||
});
|
||||
|
||||
test.describe('Envelope attachment find endpoint', () => {
|
||||
test('should block unauthorized access to envelope attachment find endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const res = await request.get(
|
||||
`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment?envelopeId=${doc.id}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${tokenB}` },
|
||||
},
|
||||
);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should allow authorized access to envelope attachment find endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const res = await request.get(
|
||||
`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment?envelopeId=${doc.id}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
},
|
||||
);
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Envelope attachment create endpoint', () => {
|
||||
test('should block unauthorized access to envelope attachment create endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/create`, {
|
||||
headers: { Authorization: `Bearer ${tokenB}` },
|
||||
data: {
|
||||
envelopeId: doc.id,
|
||||
data: {
|
||||
label: 'Test Attachment',
|
||||
data: 'https://example.com/file.pdf',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should allow authorized access to envelope attachment create endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/create`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
envelopeId: doc.id,
|
||||
data: {
|
||||
label: 'Test Attachment',
|
||||
data: 'https://example.com/file.pdf',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Envelope attachment update endpoint', () => {
|
||||
test('should block unauthorized access to envelope attachment update endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const attachment = await prisma.envelopeAttachment.create({
|
||||
data: {
|
||||
envelopeId: doc.id,
|
||||
type: 'link',
|
||||
label: 'Original Label',
|
||||
data: 'https://example.com/original.pdf',
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/update`, {
|
||||
headers: { Authorization: `Bearer ${tokenB}` },
|
||||
data: {
|
||||
id: attachment.id,
|
||||
data: {
|
||||
label: 'Updated Label',
|
||||
data: 'https://example.com/updated.pdf',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should allow authorized access to envelope attachment update endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const attachment = await prisma.envelopeAttachment.create({
|
||||
data: {
|
||||
envelopeId: doc.id,
|
||||
type: 'link',
|
||||
label: 'Original Label',
|
||||
data: 'https://example.com/original.pdf',
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/update`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
id: attachment.id,
|
||||
data: {
|
||||
label: 'Updated Label',
|
||||
data: 'https://example.com/updated.pdf',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Envelope attachment delete endpoint', () => {
|
||||
test('should block unauthorized access to envelope attachment delete endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const attachment = await prisma.envelopeAttachment.create({
|
||||
data: {
|
||||
envelopeId: doc.id,
|
||||
type: 'link',
|
||||
label: 'Test Attachment',
|
||||
data: 'https://example.com/file.pdf',
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/delete`, {
|
||||
headers: { Authorization: `Bearer ${tokenB}` },
|
||||
data: { id: attachment.id },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should allow authorized access to envelope attachment delete endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const attachment = await prisma.envelopeAttachment.create({
|
||||
data: {
|
||||
envelopeId: doc.id,
|
||||
type: 'link',
|
||||
label: 'Test Attachment',
|
||||
data: 'https://example.com/file.pdf',
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/delete`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: { id: attachment.id },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
+390
@@ -0,0 +1,390 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import type { Team, User } from '@prisma/client';
|
||||
import { EnvelopeType, FolderType } from '@prisma/client';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
});
|
||||
|
||||
// Todo: Remove skip once the API endpoints are released.
|
||||
test.describe.skip('Envelope Bulk API V2', () => {
|
||||
let userA: User, teamA: Team, userB: User, teamB: Team, tokenA: string, tokenB: string;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
({ user: userA, team: teamA } = await seedUser());
|
||||
({ token: tokenA } = await createApiToken({
|
||||
userId: userA.id,
|
||||
teamId: teamA.id,
|
||||
tokenName: 'userA',
|
||||
expiresIn: null,
|
||||
}));
|
||||
|
||||
({ user: userB, team: teamB } = await seedUser());
|
||||
({ token: tokenB } = await createApiToken({
|
||||
userId: userB.id,
|
||||
teamId: teamB.id,
|
||||
tokenName: 'userB',
|
||||
expiresIn: null,
|
||||
}));
|
||||
});
|
||||
|
||||
test.describe('Envelope bulk move endpoint', () => {
|
||||
test('should block unauthorized access to envelope bulk move endpoint', async ({ request }) => {
|
||||
// Create a document owned by userA
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
// UserB tries to move userA's document
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, {
|
||||
headers: { Authorization: `Bearer ${tokenB}` },
|
||||
data: {
|
||||
envelopeIds: [doc.id],
|
||||
envelopeType: EnvelopeType.DOCUMENT,
|
||||
folderId: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
expect(body.movedCount).toBe(0);
|
||||
|
||||
// Verify in database that the document was not modified
|
||||
const docInDb = await prisma.envelope.findFirst({
|
||||
where: { id: doc.id },
|
||||
});
|
||||
|
||||
expect(docInDb).not.toBeNull();
|
||||
expect(docInDb?.folderId).toBeNull();
|
||||
});
|
||||
|
||||
test('should block moving envelopes to unauthorized folder', async ({ request }) => {
|
||||
// Create a document owned by userB
|
||||
const doc = await seedBlankDocument(userB, teamB.id);
|
||||
|
||||
// Create a folder owned by userA
|
||||
const folderA = await seedBlankFolder(userA, teamA.id, {
|
||||
createFolderOptions: {
|
||||
name: 'UserA Folder',
|
||||
type: FolderType.DOCUMENT,
|
||||
},
|
||||
});
|
||||
|
||||
// UserB tries to move their document to userA's folder
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, {
|
||||
headers: { Authorization: `Bearer ${tokenB}` },
|
||||
data: {
|
||||
envelopeIds: [doc.id],
|
||||
envelopeType: EnvelopeType.DOCUMENT,
|
||||
folderId: folderA.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
|
||||
// Verify in database that the document was not modified
|
||||
const docInDb = await prisma.envelope.findFirst({
|
||||
where: { id: doc.id },
|
||||
});
|
||||
|
||||
expect(docInDb).not.toBeNull();
|
||||
expect(docInDb?.folderId).toBeNull();
|
||||
});
|
||||
|
||||
test('should allow authorized access to envelope bulk move endpoint', async ({ request }) => {
|
||||
// Create a document owned by userA
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
// Create a folder owned by userA
|
||||
const folderA = await seedBlankFolder(userA, teamA.id, {
|
||||
createFolderOptions: {
|
||||
name: 'UserA Folder',
|
||||
type: FolderType.DOCUMENT,
|
||||
},
|
||||
});
|
||||
|
||||
// UserA moves their own document to their own folder
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
envelopeIds: [doc.id],
|
||||
envelopeType: EnvelopeType.DOCUMENT,
|
||||
folderId: folderA.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
expect(body.movedCount).toBe(1);
|
||||
|
||||
// Verify in database that the document was moved to the folder
|
||||
const docInDb = await prisma.envelope.findFirst({
|
||||
where: { id: doc.id },
|
||||
});
|
||||
|
||||
expect(docInDb).not.toBeNull();
|
||||
expect(docInDb?.folderId).toBe(folderA.id);
|
||||
});
|
||||
|
||||
test('should only move authorized envelopes when given mixed array of envelope IDs', async ({
|
||||
request,
|
||||
}) => {
|
||||
// Create documents owned by userA
|
||||
const docA1 = await seedBlankDocument(userA, teamA.id);
|
||||
const docA2 = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
// Create a document owned by userB
|
||||
const docB = await seedBlankDocument(userB, teamB.id);
|
||||
|
||||
// Create a folder owned by userA
|
||||
const folderA = await seedBlankFolder(userA, teamA.id, {
|
||||
createFolderOptions: {
|
||||
name: 'UserA Folder',
|
||||
type: FolderType.DOCUMENT,
|
||||
},
|
||||
});
|
||||
|
||||
// UserA tries to move a mix of their own documents and userB's document
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
envelopeIds: [docA1.id, docB.id, docA2.id],
|
||||
envelopeType: EnvelopeType.DOCUMENT,
|
||||
folderId: folderA.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
// Only userA's documents should be moved
|
||||
expect(body.movedCount).toBe(2);
|
||||
|
||||
// Verify userA's documents were moved
|
||||
const docA1InDb = await prisma.envelope.findFirst({
|
||||
where: { id: docA1.id },
|
||||
});
|
||||
expect(docA1InDb).not.toBeNull();
|
||||
expect(docA1InDb?.folderId).toBe(folderA.id);
|
||||
|
||||
const docA2InDb = await prisma.envelope.findFirst({
|
||||
where: { id: docA2.id },
|
||||
});
|
||||
expect(docA2InDb).not.toBeNull();
|
||||
expect(docA2InDb?.folderId).toBe(folderA.id);
|
||||
|
||||
// Verify userB's document was NOT moved
|
||||
const docBInDb = await prisma.envelope.findFirst({
|
||||
where: { id: docB.id },
|
||||
});
|
||||
expect(docBInDb).not.toBeNull();
|
||||
expect(docBInDb?.folderId).toBeNull();
|
||||
});
|
||||
|
||||
test('should move zero envelopes when all envelope IDs in array are unauthorized', async ({
|
||||
request,
|
||||
}) => {
|
||||
// Create documents owned by userB
|
||||
const docB1 = await seedBlankDocument(userB, teamB.id);
|
||||
const docB2 = await seedBlankDocument(userB, teamB.id);
|
||||
|
||||
// Create a folder owned by userA
|
||||
const folderA = await seedBlankFolder(userA, teamA.id, {
|
||||
createFolderOptions: {
|
||||
name: 'UserA Folder',
|
||||
type: FolderType.DOCUMENT,
|
||||
},
|
||||
});
|
||||
|
||||
// UserA tries to move userB's documents
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
envelopeIds: [docB1.id, docB2.id],
|
||||
envelopeType: EnvelopeType.DOCUMENT,
|
||||
folderId: folderA.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
expect(body.movedCount).toBe(0);
|
||||
|
||||
// Verify userB's documents were NOT moved
|
||||
const docB1InDb = await prisma.envelope.findFirst({
|
||||
where: { id: docB1.id },
|
||||
});
|
||||
expect(docB1InDb).not.toBeNull();
|
||||
expect(docB1InDb?.folderId).toBeNull();
|
||||
|
||||
const docB2InDb = await prisma.envelope.findFirst({
|
||||
where: { id: docB2.id },
|
||||
});
|
||||
expect(docB2InDb).not.toBeNull();
|
||||
expect(docB2InDb?.folderId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Envelope bulk delete endpoint', () => {
|
||||
test('should block unauthorized access to envelope bulk delete endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
// Create a document owned by userA
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
// UserB tries to delete userA's document
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/delete`, {
|
||||
headers: { Authorization: `Bearer ${tokenB}` },
|
||||
data: {
|
||||
envelopeIds: [doc.id],
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
expect(body.deletedCount).toBe(0);
|
||||
// Unauthorized envelope ID should be in failedIds
|
||||
expect(body.failedIds).toEqual([doc.id]);
|
||||
|
||||
// Verify in database that the document still exists
|
||||
const docInDb = await prisma.envelope.findFirst({
|
||||
where: { id: doc.id },
|
||||
});
|
||||
|
||||
expect(docInDb).not.toBeNull();
|
||||
expect(docInDb?.id).toBe(doc.id);
|
||||
expect(docInDb?.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
test('should allow authorized access to envelope bulk delete endpoint', async ({ request }) => {
|
||||
// Create a document owned by userA
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
// UserA deletes their own document
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/delete`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
envelopeIds: [doc.id],
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
expect(body.deletedCount).toBe(1);
|
||||
expect(body.failedIds).toEqual([]);
|
||||
|
||||
// Verify in database that the document no longer exists
|
||||
const docInDb = await prisma.envelope.findFirst({
|
||||
where: { id: doc.id },
|
||||
});
|
||||
|
||||
expect(docInDb).toBeNull();
|
||||
});
|
||||
|
||||
test('should only delete authorized envelopes when given mixed array of envelope IDs', async ({
|
||||
request,
|
||||
}) => {
|
||||
// Create documents owned by userA
|
||||
const docA1 = await seedBlankDocument(userA, teamA.id);
|
||||
const docA2 = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
// Create a document owned by userB
|
||||
const docB = await seedBlankDocument(userB, teamB.id);
|
||||
|
||||
// UserA tries to delete a mix of their own documents and userB's document
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/delete`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
envelopeIds: [docA1.id, docB.id, docA2.id],
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
// Only userA's documents should be deleted
|
||||
expect(body.deletedCount).toBe(2);
|
||||
// Unauthorized envelope ID (docB) should be in failedIds
|
||||
expect(body.failedIds).toEqual([docB.id]);
|
||||
|
||||
// Verify userA's documents were deleted
|
||||
const docA1InDb = await prisma.envelope.findFirst({
|
||||
where: { id: docA1.id },
|
||||
});
|
||||
expect(docA1InDb).toBeNull();
|
||||
|
||||
const docA2InDb = await prisma.envelope.findFirst({
|
||||
where: { id: docA2.id },
|
||||
});
|
||||
expect(docA2InDb).toBeNull();
|
||||
|
||||
// Verify userB's document was NOT deleted
|
||||
const docBInDb = await prisma.envelope.findFirst({
|
||||
where: { id: docB.id },
|
||||
});
|
||||
expect(docBInDb).not.toBeNull();
|
||||
expect(docBInDb?.id).toBe(docB.id);
|
||||
expect(docBInDb?.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
test('should delete zero envelopes when all envelope IDs in array are unauthorized', async ({
|
||||
request,
|
||||
}) => {
|
||||
// Create documents owned by userB
|
||||
const docB1 = await seedBlankDocument(userB, teamB.id);
|
||||
const docB2 = await seedBlankDocument(userB, teamB.id);
|
||||
|
||||
// UserA tries to delete userB's documents
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/delete`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
envelopeIds: [docB1.id, docB2.id],
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
expect(body.deletedCount).toBe(0);
|
||||
// All unauthorized envelope IDs should be in failedIds
|
||||
expect(body.failedIds).toEqual(expect.arrayContaining([docB1.id, docB2.id]));
|
||||
expect(body.failedIds).toHaveLength(2);
|
||||
|
||||
// Verify userB's documents were NOT deleted
|
||||
const docB1InDb = await prisma.envelope.findFirst({
|
||||
where: { id: docB1.id },
|
||||
});
|
||||
expect(docB1InDb).not.toBeNull();
|
||||
expect(docB1InDb?.id).toBe(docB1.id);
|
||||
expect(docB1InDb?.deletedAt).toBeNull();
|
||||
|
||||
const docB2InDb = await prisma.envelope.findFirst({
|
||||
where: { id: docB2.id },
|
||||
});
|
||||
expect(docB2InDb).not.toBeNull();
|
||||
expect(docB2InDb?.id).toBe(docB2.id);
|
||||
expect(docB2InDb?.deletedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { seedDraftDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
const seedBulkActionsTestRequirements = async () => {
|
||||
const sender = await seedUser({ setTeamEmailAsOwner: true });
|
||||
|
||||
const [doc1, doc2, doc3] = await Promise.all([
|
||||
seedDraftDocument(sender.user, sender.team.id, [], {
|
||||
createDocumentOptions: { title: 'Bulk Test Doc 1' },
|
||||
}),
|
||||
seedDraftDocument(sender.user, sender.team.id, [], {
|
||||
createDocumentOptions: { title: 'Bulk Test Doc 2' },
|
||||
}),
|
||||
seedDraftDocument(sender.user, sender.team.id, [], {
|
||||
createDocumentOptions: { title: 'Bulk Test Doc 3' },
|
||||
}),
|
||||
]);
|
||||
|
||||
const folder = await seedBlankFolder(sender.user, sender.team.id, {
|
||||
createFolderOptions: {
|
||||
name: 'Target Folder',
|
||||
teamId: sender.team.id,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
sender,
|
||||
documents: [doc1, doc2, doc3],
|
||||
folder,
|
||||
};
|
||||
};
|
||||
|
||||
test('[BULK_ACTIONS]: can select multiple documents with checkboxes', async ({ page }) => {
|
||||
const { sender } = await seedBulkActionsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/documents`,
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('2 selected')).toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: header checkbox selects all documents on page', async ({ page }) => {
|
||||
const { sender, documents } = await seedBulkActionsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/documents`,
|
||||
});
|
||||
|
||||
await page.locator('thead').getByRole('checkbox').click();
|
||||
|
||||
await expect(page.getByText(`${documents.length} selected`)).toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
|
||||
const { sender } = await seedBulkActionsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/documents`,
|
||||
});
|
||||
|
||||
await page.locator('thead').getByRole('checkbox').click();
|
||||
await expect(page.getByText(/\d+ selected/)).toBeVisible();
|
||||
|
||||
await page.getByLabel('Clear selection').click();
|
||||
|
||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: can move multiple documents to a folder', async ({ page }) => {
|
||||
const { sender, folder } = await seedBulkActionsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/documents`,
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click();
|
||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await expect(page.getByText('Move Documents to Folder')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: folder.name }).click();
|
||||
await page.getByRole('button', { name: 'Move' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
||||
|
||||
await page.goto(`/t/${sender.team.url}/documents/f/${folder.id}`);
|
||||
await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Bulk Test Doc 2' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: can delete multiple draft documents', async ({ page }) => {
|
||||
const { sender } = await seedBulkActionsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/documents`,
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await expect(page.getByText('Delete Documents')).toBeVisible();
|
||||
await expect(page.getByText('You are about to delete 2 documents')).toBeVisible();
|
||||
await expect(page.getByText('irreversible')).toBeVisible();
|
||||
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Documents deleted');
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).not.toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Bulk Test Doc 2' })).not.toBeVisible();
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Bulk Test Doc 3' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: selection clears after successful move', async ({ page }) => {
|
||||
const { sender, folder } = await seedBulkActionsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/documents`,
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
||||
await page.getByRole('button', { name: folder.name }).click();
|
||||
await page.getByRole('button', { name: 'Move' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }) => {
|
||||
const { sender } = await seedBulkActionsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/documents`,
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Documents deleted');
|
||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) => {
|
||||
const { sender, folder } = await seedBulkActionsTestRequirements();
|
||||
|
||||
const otherFolder = await seedBlankFolder(sender.user, sender.team.id, {
|
||||
createFolderOptions: {
|
||||
name: 'Other Folder',
|
||||
teamId: sender.team.id,
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/documents`,
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
await expect(page.getByRole('button', { name: folder.name })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: otherFolder.name })).toBeVisible();
|
||||
|
||||
await page.getByPlaceholder('Search folders...').fill('Target');
|
||||
await expect(page.getByRole('button', { name: folder.name })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: otherFolder.name })).not.toBeVisible();
|
||||
|
||||
await page.getByPlaceholder('Search folders...').fill('Other');
|
||||
await expect(page.getByRole('button', { name: folder.name })).not.toBeVisible();
|
||||
await expect(page.getByRole('button', { name: otherFolder.name })).toBeVisible();
|
||||
|
||||
await page.getByPlaceholder('Search folders...').fill('NonExistent');
|
||||
await expect(page.getByText('No folders found')).toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: can move documents from folder to home (root)', async ({ page }) => {
|
||||
const { sender, documents, folder } = await seedBulkActionsTestRequirements();
|
||||
|
||||
const { prisma } = await import('@documenso/prisma');
|
||||
|
||||
await prisma.envelope.updateMany({
|
||||
where: { id: documents[0].id },
|
||||
data: { folderId: folder.id },
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/documents/f/${folder.id}`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).toBeVisible();
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Home (No Folder)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Move' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
||||
|
||||
await page.goto(`/t/${sender.team.url}/documents`);
|
||||
await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).toBeVisible();
|
||||
|
||||
await page.goto(`/t/${sender.team.url}/documents/f/${folder.id}`);
|
||||
await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).not.toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { FolderType } from '@documenso/prisma/client';
|
||||
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
const seedBulkActionsTestRequirements = async () => {
|
||||
const sender = await seedUser({ setTeamEmailAsOwner: true });
|
||||
|
||||
const [template1, template2, template3] = await Promise.all([
|
||||
seedBlankTemplate(sender.user, sender.team.id, {
|
||||
createTemplateOptions: { title: 'Bulk Test Template 1' },
|
||||
}),
|
||||
seedBlankTemplate(sender.user, sender.team.id, {
|
||||
createTemplateOptions: { title: 'Bulk Test Template 2' },
|
||||
}),
|
||||
seedBlankTemplate(sender.user, sender.team.id, {
|
||||
createTemplateOptions: { title: 'Bulk Test Template 3' },
|
||||
}),
|
||||
]);
|
||||
|
||||
const folder = await seedBlankFolder(sender.user, sender.team.id, {
|
||||
createFolderOptions: {
|
||||
name: 'Target Template Folder',
|
||||
teamId: sender.team.id,
|
||||
type: FolderType.TEMPLATE,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
sender,
|
||||
templates: [template1, template2, template3],
|
||||
folder,
|
||||
};
|
||||
};
|
||||
|
||||
test('[BULK_ACTIONS]: can select multiple templates with checkboxes', async ({ page }) => {
|
||||
const { sender } = await seedBulkActionsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/templates`,
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Template 2' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('2 selected')).toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: header checkbox selects all templates on page', async ({ page }) => {
|
||||
const { sender, templates } = await seedBulkActionsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/templates`,
|
||||
});
|
||||
|
||||
await page.locator('thead').getByRole('checkbox').click();
|
||||
|
||||
await expect(page.getByText(`${templates.length} selected`)).toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
|
||||
const { sender } = await seedBulkActionsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/templates`,
|
||||
});
|
||||
|
||||
await page.locator('thead').getByRole('checkbox').click();
|
||||
await expect(page.getByText(/\d+ selected/)).toBeVisible();
|
||||
|
||||
await page.getByLabel('Clear selection').click();
|
||||
|
||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: can move multiple templates to a folder', async ({ page }) => {
|
||||
const { sender, folder } = await seedBulkActionsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/templates`,
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
||||
await page.locator('tr', { hasText: 'Bulk Test Template 2' }).getByRole('checkbox').click();
|
||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await expect(page.getByText('Move Templates to Folder')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: folder.name }).click();
|
||||
await page.getByRole('button', { name: 'Move' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
||||
|
||||
await page.goto(`/t/${sender.team.url}/templates/f/${folder.id}`);
|
||||
await expect(page.getByRole('link', { name: 'Bulk Test Template 1' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Bulk Test Template 2' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: can delete multiple templates', async ({ page }) => {
|
||||
const { sender } = await seedBulkActionsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/templates`,
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
||||
await page.locator('tr', { hasText: 'Bulk Test Template 2' }).getByRole('checkbox').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await expect(page.getByText('Delete Templates')).toBeVisible();
|
||||
await expect(page.getByText('You are about to delete 2 templates')).toBeVisible();
|
||||
await expect(page.getByText('irreversible')).toBeVisible();
|
||||
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Templates deleted');
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Bulk Test Template 1' })).not.toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Bulk Test Template 2' })).not.toBeVisible();
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Bulk Test Template 3' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: selection clears after successful move', async ({ page }) => {
|
||||
const { sender, folder } = await seedBulkActionsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/templates`,
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
||||
await page.getByRole('button', { name: folder.name }).click();
|
||||
await page.getByRole('button', { name: 'Move' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }) => {
|
||||
const { sender } = await seedBulkActionsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/templates`,
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Templates deleted');
|
||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) => {
|
||||
const { sender, folder } = await seedBulkActionsTestRequirements();
|
||||
|
||||
const otherFolder = await seedBlankFolder(sender.user, sender.team.id, {
|
||||
createFolderOptions: {
|
||||
name: 'Other Template Folder',
|
||||
teamId: sender.team.id,
|
||||
type: FolderType.TEMPLATE,
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/templates`,
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
await expect(page.getByRole('button', { name: folder.name })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: otherFolder.name })).toBeVisible();
|
||||
|
||||
await page.getByPlaceholder('Search folders...').fill('Target');
|
||||
await expect(page.getByRole('button', { name: folder.name })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: otherFolder.name })).not.toBeVisible();
|
||||
|
||||
await page.getByPlaceholder('Search folders...').fill('Other');
|
||||
await expect(page.getByRole('button', { name: folder.name })).not.toBeVisible();
|
||||
await expect(page.getByRole('button', { name: otherFolder.name })).toBeVisible();
|
||||
|
||||
await page.getByPlaceholder('Search folders...').fill('NonExistent');
|
||||
await expect(page.getByText('No folders found')).toBeVisible();
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: can move templates from folder to home (root)', async ({ page }) => {
|
||||
const { sender, templates, folder } = await seedBulkActionsTestRequirements();
|
||||
|
||||
const { prisma } = await import('@documenso/prisma');
|
||||
|
||||
await prisma.envelope.updateMany({
|
||||
where: { id: templates[0].id },
|
||||
data: { folderId: folder.id },
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/templates/f/${folder.id}`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Bulk Test Template 1' })).toBeVisible();
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Move to Folder' }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Home (No Folder)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Move' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
|
||||
|
||||
await page.goto(`/t/${sender.team.url}/templates`);
|
||||
await expect(page.getByRole('link', { name: 'Bulk Test Template 1' })).toBeVisible();
|
||||
|
||||
await page.goto(`/t/${sender.team.url}/templates/f/${folder.id}`);
|
||||
await expect(page.getByRole('link', { name: 'Bulk Test Template 1' })).not.toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import pMap from 'p-map';
|
||||
|
||||
import { deleteDocument } from '@documenso/lib/server-only/document/delete-document';
|
||||
import { getMultipleEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelopes-by-ids';
|
||||
import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
ZBulkDeleteEnvelopesRequestSchema,
|
||||
ZBulkDeleteEnvelopesResponseSchema,
|
||||
} from './bulk-delete-envelopes.types';
|
||||
|
||||
export const bulkDeleteEnvelopesRoute = authenticatedProcedure
|
||||
// .meta(bulkDeleteEnvelopesMeta) // Keeping this as a private API for a little while until we're sure it's stable and the request/response schemas are finalized.
|
||||
.input(ZBulkDeleteEnvelopesRequestSchema)
|
||||
.output(ZBulkDeleteEnvelopesResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { teamId, user } = ctx;
|
||||
const { envelopeIds } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
envelopeIds,
|
||||
},
|
||||
});
|
||||
|
||||
const { envelopeWhereInput } = await getMultipleEnvelopeWhereInput({
|
||||
ids: {
|
||||
type: 'envelopeId',
|
||||
ids: envelopeIds,
|
||||
},
|
||||
userId: user.id,
|
||||
teamId,
|
||||
type: null,
|
||||
});
|
||||
|
||||
const envelopes = await prisma.envelope.findMany({
|
||||
where: envelopeWhereInput,
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
},
|
||||
});
|
||||
|
||||
const results = await pMap(
|
||||
envelopes,
|
||||
async (envelope) => {
|
||||
const { id: envelopeId, type: envelopeType } = envelope;
|
||||
|
||||
try {
|
||||
if (envelopeType === EnvelopeType.DOCUMENT) {
|
||||
await deleteDocument({
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: envelopeId,
|
||||
},
|
||||
userId: user.id,
|
||||
teamId,
|
||||
requestMetadata: ctx.metadata,
|
||||
});
|
||||
} else if (envelopeType === EnvelopeType.TEMPLATE) {
|
||||
await deleteTemplate({
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: envelopeId,
|
||||
},
|
||||
userId: user.id,
|
||||
teamId,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
envelopeId,
|
||||
};
|
||||
} catch (err) {
|
||||
ctx.logger.warn(
|
||||
{
|
||||
envelopeId,
|
||||
error: err,
|
||||
},
|
||||
'Failed to delete envelope during bulk delete',
|
||||
);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
envelopeId,
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
concurrency: 10,
|
||||
stopOnError: false,
|
||||
},
|
||||
);
|
||||
|
||||
const deletedCount = results.filter((r) => r.success).length;
|
||||
const failedIds = results.filter((r) => !r.success).map((r) => r.envelopeId);
|
||||
|
||||
// Include envelope IDs that were not attempted (unauthorized/not found)
|
||||
const attemptedIds = new Set(envelopes.map((e) => e.id));
|
||||
const unattemptedIds = envelopeIds.filter((id) => !attemptedIds.has(id));
|
||||
|
||||
return {
|
||||
deletedCount,
|
||||
failedIds: [...failedIds, ...unattemptedIds],
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// READ ME: IF YOU UNCOMMENT THIS THEN UNSKIP THE TEST IN api-access-envelope-bulk.spec.ts
|
||||
// Keeping this as a private API for a little while until we're sure it's stable and the request/response schemas are finalized.
|
||||
// export const bulkDeleteEnvelopesMeta: TrpcRouteMeta = {
|
||||
// openapi: {
|
||||
// method: 'POST',
|
||||
// path: '/envelope/bulk/delete',
|
||||
// summary: 'Bulk delete envelopes',
|
||||
// description: 'Delete multiple envelopes.',
|
||||
// tags: ['Envelopes'],
|
||||
// },
|
||||
// };
|
||||
|
||||
export const ZBulkDeleteEnvelopesRequestSchema = z.object({
|
||||
envelopeIds: z
|
||||
.array(z.string())
|
||||
.min(1)
|
||||
.max(100)
|
||||
.describe(
|
||||
'The IDs of the envelopes to delete. The maximum number of envelopes you can delete at once is 100.',
|
||||
),
|
||||
});
|
||||
|
||||
export const ZBulkDeleteEnvelopesResponseSchema = z.object({
|
||||
deletedCount: z.number(),
|
||||
failedIds: z.array(z.string()),
|
||||
});
|
||||
|
||||
export type TBulkDeleteEnvelopesRequest = z.infer<typeof ZBulkDeleteEnvelopesRequestSchema>;
|
||||
export type TBulkDeleteEnvelopesResponse = z.infer<typeof ZBulkDeleteEnvelopesResponseSchema>;
|
||||
@@ -0,0 +1,73 @@
|
||||
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '@documenso/lib/constants/teams';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getMultipleEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelopes-by-ids';
|
||||
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
ZBulkMoveEnvelopesRequestSchema,
|
||||
ZBulkMoveEnvelopesResponseSchema,
|
||||
} from './bulk-move-envelopes.types';
|
||||
|
||||
export const bulkMoveEnvelopesRoute = authenticatedProcedure
|
||||
// .meta(bulkMoveEnvelopesMeta)
|
||||
.input(ZBulkMoveEnvelopesRequestSchema)
|
||||
.output(ZBulkMoveEnvelopesResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { teamId, user } = ctx;
|
||||
const { envelopeIds, envelopeType, folderId } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
envelopeIds,
|
||||
envelopeType,
|
||||
folderId,
|
||||
},
|
||||
});
|
||||
|
||||
// Build the where input for the update query.
|
||||
const { envelopeWhereInput, team } = await getMultipleEnvelopeWhereInput({
|
||||
ids: {
|
||||
type: 'envelopeId',
|
||||
ids: envelopeIds,
|
||||
},
|
||||
userId: user.id,
|
||||
teamId,
|
||||
type: envelopeType,
|
||||
});
|
||||
|
||||
// Validate folder access if moving to a folder (not root).
|
||||
if (folderId) {
|
||||
const folder = await prisma.folder.findFirst({
|
||||
where: {
|
||||
id: folderId,
|
||||
team: buildTeamWhereQuery({
|
||||
teamId,
|
||||
userId: user.id,
|
||||
}),
|
||||
type: envelopeType,
|
||||
visibility: {
|
||||
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!folder) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Folder not found or access denied',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result = await prisma.envelope.updateMany({
|
||||
where: envelopeWhereInput,
|
||||
data: {
|
||||
folderId: folderId,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
movedCount: result.count,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
// READ ME: IF YOU UNCOMMENT THIS THEN UNSKIP THE TEST IN api-access-envelope-bulk.spec.ts
|
||||
// Keeping this as a private API for a little while until we're sure it's stable and the request/response schemas are finalized.
|
||||
// export const bulkMoveEnvelopesMeta: TrpcRouteMeta = {
|
||||
// openapi: {
|
||||
// method: 'POST',
|
||||
// path: '/envelope/bulk/move',
|
||||
// summary: 'Bulk move envelopes to folder',
|
||||
// description: 'Move multiple envelopes to a specified folder.',
|
||||
// tags: ['Envelopes'],
|
||||
// },
|
||||
// };
|
||||
|
||||
export const ZBulkMoveEnvelopesRequestSchema = z.object({
|
||||
envelopeIds: z
|
||||
.array(z.string())
|
||||
.min(1)
|
||||
.max(100)
|
||||
.describe(
|
||||
'The IDs of the envelopes to move. The maximum number of envelopes you can move at once is 100.',
|
||||
),
|
||||
envelopeType: z.nativeEnum(EnvelopeType).describe('The type of the envelopes being moved.'),
|
||||
folderId: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
'The ID of the folder to move the envelopes to. If null envelopes will be moved to the root folder.',
|
||||
),
|
||||
});
|
||||
|
||||
export const ZBulkMoveEnvelopesResponseSchema = z.object({
|
||||
movedCount: z.number().describe('The number of envelopes that were moved.'),
|
||||
});
|
||||
|
||||
export type TBulkMoveEnvelopesRequest = z.infer<typeof ZBulkMoveEnvelopesRequestSchema>;
|
||||
export type TBulkMoveEnvelopesResponse = z.infer<typeof ZBulkMoveEnvelopesResponseSchema>;
|
||||
@@ -3,6 +3,8 @@ import { createAttachmentRoute } from './attachment/create-attachment';
|
||||
import { deleteAttachmentRoute } from './attachment/delete-attachment';
|
||||
import { findAttachmentsRoute } from './attachment/find-attachments';
|
||||
import { updateAttachmentRoute } from './attachment/update-attachment';
|
||||
import { bulkDeleteEnvelopesRoute } from './bulk-delete-envelopes';
|
||||
import { bulkMoveEnvelopesRoute } from './bulk-move-envelopes';
|
||||
import { createEnvelopeRoute } from './create-envelope';
|
||||
import { createEnvelopeItemsRoute } from './create-envelope-items';
|
||||
import { deleteEnvelopeRoute } from './delete-envelope';
|
||||
@@ -72,6 +74,10 @@ export const envelopeRouter = router({
|
||||
auditLog: {
|
||||
find: findEnvelopeAuditLogsRoute,
|
||||
},
|
||||
bulk: {
|
||||
move: bulkMoveEnvelopesRoute,
|
||||
delete: bulkDeleteEnvelopesRoute,
|
||||
},
|
||||
get: getEnvelopeRoute,
|
||||
getMany: getEnvelopesByIdsRoute,
|
||||
create: createEnvelopeRoute,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Trans } from '@lingui/react/macro';
|
||||
import type {
|
||||
ColumnDef,
|
||||
PaginationState,
|
||||
RowSelectionState,
|
||||
Table as TTable,
|
||||
Updater,
|
||||
VisibilityState,
|
||||
@@ -15,7 +16,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '.
|
||||
|
||||
export type DataTableChildren<TData> = (_table: TTable<TData>) => React.ReactNode;
|
||||
|
||||
export type { ColumnDef as DataTableColumnDef } from '@tanstack/react-table';
|
||||
export type { ColumnDef as DataTableColumnDef, RowSelectionState } from '@tanstack/react-table';
|
||||
|
||||
export interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
@@ -40,6 +41,10 @@ export interface DataTableProps<TData, TValue> {
|
||||
enable: boolean;
|
||||
component?: React.ReactNode;
|
||||
};
|
||||
enableRowSelection?: boolean;
|
||||
rowSelection?: RowSelectionState;
|
||||
onRowSelectionChange?: (selection: RowSelectionState) => void;
|
||||
getRowId?: (row: TData) => string;
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
@@ -58,6 +63,10 @@ export function DataTable<TData, TValue>({
|
||||
rowClassName,
|
||||
children,
|
||||
emptyState,
|
||||
enableRowSelection,
|
||||
rowSelection,
|
||||
onRowSelectionChange,
|
||||
getRowId,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const pagination = useMemo<PaginationState>(() => {
|
||||
if (currentPage !== undefined && perPage !== undefined) {
|
||||
@@ -85,6 +94,13 @@ export function DataTable<TData, TValue>({
|
||||
}
|
||||
};
|
||||
|
||||
const onTableRowSelectionChange = (updater: Updater<RowSelectionState>) => {
|
||||
if (onRowSelectionChange) {
|
||||
const newSelection = typeof updater === 'function' ? updater(rowSelection ?? {}) : updater;
|
||||
onRowSelectionChange(newSelection);
|
||||
}
|
||||
};
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
@@ -92,10 +108,14 @@ export function DataTable<TData, TValue>({
|
||||
state: {
|
||||
pagination: manualPagination ? pagination : undefined,
|
||||
columnVisibility,
|
||||
rowSelection: rowSelection ?? {},
|
||||
},
|
||||
manualPagination,
|
||||
pageCount: totalPages,
|
||||
onPaginationChange: onTablePaginationChange,
|
||||
enableRowSelection,
|
||||
onRowSelectionChange: onTableRowSelectionChange,
|
||||
getRowId,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -162,7 +182,7 @@ export function DataTable<TData, TValue>({
|
||||
{hasFilters && onClearFilters !== undefined && (
|
||||
<button
|
||||
onClick={() => onClearFilters()}
|
||||
className="text-foreground mt-1 text-sm"
|
||||
className="mt-1 text-sm text-foreground"
|
||||
>
|
||||
<Trans>Clear filters</Trans>
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user