feat: bulk-operations (#2346)

Co-authored-by: PatrikKozak <patrik@trbl.design>
This commit is contained in:
Dan Ribbens
2023-03-23 12:33:13 -04:00
committed by GitHub
parent c5cb08c5b8
commit 0fedbabe9e
112 changed files with 4833 additions and 1385 deletions

View File

@@ -66,6 +66,41 @@ describe('Access Control', () => {
expect(updatedDoc.partiallyHiddenArray[0].value).toEqual('private_value');
});
it('should not affect hidden fields when patching data - update many', async () => {
const docsMany = await payload.create({
collection: hiddenFieldsSlug,
data: {
partiallyHiddenArray: [{
name: 'public_name',
value: 'private_value',
}],
partiallyHiddenGroup: {
name: 'public_name',
value: 'private_value',
},
},
});
await payload.update({
collection: hiddenFieldsSlug,
where: {
id: { equals: docsMany.id },
},
data: {
title: 'Doc Title',
},
});
const updatedMany = await payload.findByID({
collection: hiddenFieldsSlug,
id: docsMany.id,
showHiddenFields: true,
});
expect(updatedMany.partiallyHiddenGroup.value).toEqual('private_value');
expect(updatedMany.partiallyHiddenArray[0].value).toEqual('private_value');
});
it('should be able to restrict access based upon siblingData', async () => {
const { id } = await payload.create({
collection: siblingDataSlug,
@@ -210,6 +245,44 @@ describe('Access Control', () => {
expect(doc).toMatchObject({ id: post1.id });
});
it('should allow overrideAccess: false - update many', async () => {
const req = async () => payload.update({
collection: slug,
where: {
id: { equals: post1.id },
},
data: { restrictedField: restricted.id },
overrideAccess: false, // this should respect access control
});
await expect(req).rejects.toThrow(Forbidden);
});
it('should allow overrideAccess: true - update many', async () => {
const doc = await payload.update({
collection: slug,
where: {
id: { equals: post1.id },
},
data: { restrictedField: restricted.id },
overrideAccess: true, // this should override access control
});
expect(doc.docs[0]).toMatchObject({ id: post1.id });
});
it('should allow overrideAccess by default - update many', async () => {
const doc = await payload.update({
collection: slug,
where: {
id: { equals: post1.id },
},
data: { restrictedField: restricted.id },
});
expect(doc.docs[0]).toMatchObject({ id: post1.id });
});
});
describe('Collections', () => {
@@ -246,6 +319,44 @@ describe('Access Control', () => {
expect(doc).toMatchObject({ id: restricted.id, name: updatedName });
});
it('should allow overrideAccess: false - update many', async () => {
const req = async () => payload.update({
collection: restrictedSlug,
where: {
id: { equals: restricted.id },
},
data: { name: updatedName },
overrideAccess: false, // this should respect access control
});
await expect(req).rejects.toThrow(Forbidden);
});
it('should allow overrideAccess: true - update many', async () => {
const doc = await payload.update({
collection: restrictedSlug,
where: {
id: { equals: restricted.id },
},
data: { name: updatedName },
overrideAccess: true, // this should override access control
});
expect(doc.docs[0]).toMatchObject({ id: restricted.id, name: updatedName });
});
it('should allow overrideAccess by default - update many', async () => {
const doc = await payload.update({
collection: restrictedSlug,
where: {
id: { equals: restricted.id },
},
data: { name: updatedName },
});
expect(doc.docs[0]).toMatchObject({ id: restricted.id, name: updatedName });
});
});
});
});

View File

@@ -162,6 +162,53 @@ describe('admin', () => {
expect(page.url()).toContain(url.list);
});
test('should bulk delete', async () => {
createPost();
createPost();
createPost();
await page.goto(url.list);
await page.locator('.select-all__input').click();
await page.locator('.delete-documents__toggle').click();
await page.locator('#confirm-delete').click();
await expect(page.locator('.Toastify__toast--success')).toHaveText('Deleted 3 Posts en successfully.');
await expect(page.locator('.collection-list__no-results')).toBeVisible();
});
test('should bulk update', async () => {
createPost();
createPost();
createPost();
const bulkTitle = 'Bulk update title';
await page.goto(url.list);
await page.locator('.select-all__input').click();
await page.locator('.edit-many__toggle').click();
await page.locator('.field-select .rs__control').click();
const options = page.locator('.rs__option');
const titleOption = await options.locator('text=Title en');
await expect(titleOption).toHaveText('Title en');
await titleOption.click();
const titleInput = await page.locator('#field-title');
await expect(titleInput).toBeVisible();
await titleInput.fill(bulkTitle);
await page.locator('.form-submit button[type="submit"]').click();
await expect(page.locator('.Toastify__toast--success')).toContainText('Updated 3 Posts en successfully.');
await expect(page.locator('.row-1 .cell-title')).toContainText(bulkTitle);
await expect(page.locator('.row-2 .cell-title')).toContainText(bulkTitle);
await expect(page.locator('.row-3 .cell-title')).toContainText(bulkTitle);
});
test('should save globals', async () => {
await page.goto(url.global(globalSlug));
@@ -230,11 +277,12 @@ describe('admin', () => {
test('toggle columns', async () => {
const columnCountLocator = 'table >> thead >> tr >> th';
await createPost();
await page.locator('.list-controls__toggle-columns').click();
await wait(500); // Wait for column toggle UI, should probably use waitForSelector
const numberOfColumns = await page.locator(columnCountLocator).count();
await expect(await page.locator('table >> thead >> tr >> th:first-child')).toHaveText('ID');
await expect(await page.locator('table >> thead >> tr >> th:nth-child(2)')).toHaveText('ID');
const idButton = await page.locator('.column-selector >> text=ID');
@@ -242,19 +290,19 @@ describe('admin', () => {
await idButton.click();
await wait(100);
await expect(await page.locator(columnCountLocator)).toHaveCount(numberOfColumns - 1);
await expect(await page.locator('table >> thead >> tr >> th:first-child')).toHaveText('Number');
await expect(await page.locator('table >> thead >> tr >> th:nth-child(2)')).toHaveText('Number');
// Add back ID column
await idButton.click();
await wait(100);
await expect(await page.locator(columnCountLocator)).toHaveCount(numberOfColumns);
await expect(await page.locator('table >> thead >> tr >> th:first-child')).toHaveText('ID');
await expect(await page.locator('table >> thead >> tr >> th:nth-child(2)')).toHaveText('ID');
});
test('first cell is a link', async () => {
test('2nd cell is a link', async () => {
const { id } = await createPost();
const firstCell = await page.locator(`${tableRowLocator} td`).first().locator('a');
await expect(firstCell).toHaveAttribute('href', `/admin/collections/posts/${id}`);
const linkCell = await page.locator(`${tableRowLocator} td`).nth(1).locator('a');
await expect(linkCell).toHaveAttribute('href', `/admin/collections/posts/${id}`);
// open the column controls
await page.locator('.list-controls__toggle-columns').click();
@@ -264,8 +312,8 @@ describe('admin', () => {
page.locator('.column-selector >> text=ID').click();
await wait(200);
// recheck that the first cell is still a link
await expect(firstCell).toHaveAttribute('href', `/admin/collections/posts/${id}`);
// recheck that the 2nd cell is still a link
await expect(linkCell).toHaveAttribute('href', `/admin/collections/posts/${id}`);
});
test('filter rows', async () => {
@@ -303,8 +351,7 @@ describe('admin', () => {
await wait(1000);
await expect(page.locator(tableRowLocator)).toHaveCount(1);
const firstId = await page.locator(tableRowLocator).first().locator('td').first()
.innerText();
const firstId = await page.locator(tableRowLocator).first().locator('.cell-id').innerText();
expect(firstId).toEqual(id);
// Remove filter
@@ -339,15 +386,17 @@ describe('admin', () => {
// ensure the "number" column is now first
await expect(await page.locator('.list-controls .column-selector .column-selector__column').first()).toHaveText('Number');
await expect(await page.locator('table >> thead >> tr >> th').first()).toHaveText('Number');
await expect(await page.locator('table thead tr th').nth(1)).toHaveText('Number');
// await expect(await page.locator('table >> thead >> tr >> th').first()).toHaveText('Number');
// reload to ensure the preferred order was stored in the database
await page.reload();
await expect(await page.locator('.list-controls .column-selector .column-selector__column').first()).toHaveText('Number');
await expect(await page.locator('table >> thead >> tr >> th').first()).toHaveText('Number');
await expect(await page.locator('table thead tr th').nth(1)).toHaveText('Number');
});
test('should render drawer columns in order', async () => {
await createPost();
await page.goto(url.create);
// Open the drawer
@@ -356,13 +405,13 @@ describe('admin', () => {
await expect(listDrawer).toBeVisible();
const collectionSelector = await page.locator('[id^=list-drawer_1_] .list-drawer__select-collection.react-select');
const columnSelector = await page.locator('[id^=list-drawer_1_] .list-controls__toggle-columns');
// select the "Post en" collection
await collectionSelector.click();
await page.locator('[id^=list-drawer_1_] .list-drawer__select-collection.react-select .rs__option >> text="Post en"').click();
// open the column controls
const columnSelector = await page.locator('[id^=list-drawer_1_] .list-controls__toggle-columns');
await columnSelector.click();
await wait(500); // Wait for column toggle UI, should probably use waitForSelector (same as above)
@@ -417,6 +466,46 @@ describe('admin', () => {
});
});
describe('multi-select', () => {
beforeEach(async () => {
await mapAsync([...Array(3)], async () => {
await createPost();
});
});
test('should select multiple rows', async () => {
const selectAll = page.locator('.select-all');
await page.locator('.row-1 .select-row button').click();
const indeterminateSelectAll = selectAll.locator('.icon--line');
expect(indeterminateSelectAll).toBeDefined();
await selectAll.locator('button').click();
const emptySelectAll = selectAll.locator('.icon');
await expect(emptySelectAll).toHaveCount(0);
await selectAll.locator('button').click();
const checkSelectAll = selectAll.locator('.icon .icon--check');
expect(checkSelectAll).toBeDefined();
});
test('should delete many', async () => {
// delete should not appear without selection
await expect(page.locator('#confirm-delete')).toHaveCount(0);
// select one row
await page.locator('.row-1 .select-row button').click();
// delete button should be present
await expect(page.locator('#confirm-delete')).toHaveCount(1);
await page.locator('.row-2 .select-row button').click();
await page.locator('.delete-documents__toggle').click();
await page.locator('#confirm-delete').click();
await expect(await page.locator('.select-row')).toHaveCount(1);
});
});
describe('pagination', () => {
beforeAll(async () => {
await mapAsync([...Array(11)], async () => {

View File

@@ -32,6 +32,7 @@ export const relationSlug = 'relation';
export const pointSlug = 'point';
export const customIdSlug = 'custom-id';
export const customIdNumberSlug = 'custom-id-number';
export const errorOnHookSlug = 'error-on-hooks';
export default buildConfig({
collections: [
@@ -124,6 +125,36 @@ export default buildConfig({
},
],
},
{
slug: errorOnHookSlug,
access: openAccess,
hooks: {
beforeChange: [({ originalDoc }) => {
if (originalDoc?.errorBeforeChange) {
throw new Error('Error Before Change Thrown');
}
}],
afterDelete: [({ doc }) => {
if (doc?.errorAfterDelete) {
throw new Error('Error After Delete Thrown');
}
}],
},
fields: [
{
name: 'text',
type: 'text',
},
{
name: 'errorBeforeChange',
type: 'checkbox',
},
{
name: 'errorAfterDelete',
type: 'checkbox',
},
],
},
],
onInit: async (payload) => {
await payload.create({

View File

@@ -2,10 +2,10 @@ import mongoose from 'mongoose';
import { randomBytes } from 'crypto';
import { initPayloadTest } from '../helpers/configHelpers';
import type { Relation } from './config';
import config, { customIdNumberSlug, customIdSlug, slug, relationSlug, pointSlug } from './config';
import config, { customIdNumberSlug, customIdSlug, slug, relationSlug, pointSlug, errorOnHookSlug } from './config';
import payload from '../../src';
import { RESTClient } from '../helpers/rest';
import type { Post } from './payload-types';
import type { ErrorOnHook, Post } from './payload-types';
import { mapAsync } from '../../src/utilities/mapAsync';
let client: RESTClient;
@@ -13,7 +13,7 @@ let client: RESTClient;
describe('collections-rest', () => {
beforeAll(async () => {
const { serverURL } = await initPayloadTest({ __dirname, init: { local: false } });
client = new RESTClient(config, { serverURL, defaultSlug: slug });
client = new RESTClient(await config, { serverURL, defaultSlug: slug });
});
afterAll(async () => {
@@ -62,6 +62,100 @@ describe('collections-rest', () => {
expect(updated.description).toEqual(description); // Check was not modified
});
it('should bulk update', async () => {
await mapAsync([...Array(11)], async (_, i) => {
await createPost({ description: `desc ${i}` });
});
const description = 'updated';
const { status, docs } = await client.updateMany<Post>({
query: { title: { equals: 'title' } },
data: { description },
});
expect(status).toEqual(200);
expect(docs[0].title).toEqual('title'); // Check was not modified
expect(docs[0].description).toEqual(description);
expect(docs.pop().description).toEqual(description);
});
it('should return formatted errors for bulk updates', async () => {
const text = 'bulk-update-test-errors';
const errorDoc = await payload.create({
collection: errorOnHookSlug,
data: {
text,
errorBeforeChange: true,
},
});
const successDoc = await payload.create({
collection: errorOnHookSlug,
data: {
text,
errorBeforeChange: false,
},
});
const update = 'update';
const result = await client.updateMany<ErrorOnHook>({
slug: errorOnHookSlug,
query: { text: { equals: text } },
data: { text: update },
});
expect(result.status).toEqual(400);
expect(result.docs).toHaveLength(1);
expect(result.docs[0].id).toEqual(successDoc.id);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].message).toBeDefined();
expect(result.errors[0].id).toEqual(errorDoc.id);
expect(result.docs[0].text).toEqual(update);
});
it('should bulk delete', async () => {
const count = 11;
await mapAsync([...Array(count)], async (_, i) => {
await createPost({ description: `desc ${i}` });
});
const { status, docs } = await client.deleteMany<Post>({
query: { title: { eq: 'title' } },
});
expect(status).toEqual(200);
expect(docs[0].title).toEqual('title'); // Check was not modified
expect(docs).toHaveLength(count);
});
it('should return formatted errors for bulk deletes', async () => {
await payload.create({
collection: errorOnHookSlug,
data: {
text: 'test',
errorAfterDelete: true,
},
});
await payload.create({
collection: errorOnHookSlug,
data: {
text: 'test',
errorAfterDelete: false,
},
});
const result = await client.deleteMany({
slug: errorOnHookSlug,
query: { text: { equals: 'test' } },
});
expect(result.status).toEqual(400);
expect(result.docs).toHaveLength(1);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].message).toBeDefined();
expect(result.errors[0].id).toBeDefined();
});
describe('Custom ID', () => {
describe('string', () => {
it('should create', async () => {
@@ -697,7 +791,7 @@ async function createPosts(count: number) {
}
async function clearDocs(): Promise<void> {
const allDocs = await payload.find<Post>({ collection: slug, limit: 100 });
const allDocs = await payload.find({ collection: slug, limit: 100 });
const ids = allDocs.docs.map((doc) => doc.id);
await mapAsync(ids, async (id) => {
await payload.delete({ collection: slug, id });

View File

@@ -5,11 +5,19 @@
* and re-run `payload generate:types` to regenerate this file.
*/
export interface Config {}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts".
*/
export interface Config {
collections: {
posts: Post;
point: Point;
relation: Relation;
dummy: Dummy;
'custom-id': CustomId;
'custom-id-number': CustomIdNumber;
'error-on-hooks': ErrorOnHook;
users: User;
};
globals: {};
}
export interface Post {
id: string;
title?: string;
@@ -50,30 +58,18 @@ export interface Post {
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "relation".
*/
export interface Relation {
id: string;
name?: string;
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "dummy".
*/
export interface Dummy {
id: string;
name?: string;
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "point".
*/
export interface Point {
id: string;
/**
@@ -84,30 +80,26 @@ export interface Point {
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "custom-id".
*/
export interface CustomId {
id: string;
name?: string;
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "custom-id-number".
*/
export interface CustomIdNumber {
id: number;
name?: string;
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface ErrorOnHook {
id: string;
text?: string;
errorBeforeChange?: boolean;
errorAfterDelete?: boolean;
createdAt: string;
updatedAt: string;
}
export interface User {
id: string;
email?: string;
@@ -117,4 +109,5 @@ export interface User {
lockUntil?: string;
createdAt: string;
updatedAt: string;
password?: string;
}

View File

@@ -52,12 +52,26 @@ type UpdateArgs<T = any> = {
auth?: boolean;
query?: any;
};
type UpdateManyArgs<T = any> = {
slug?: string;
data: Partial<T>;
auth?: boolean;
query: any;
};
type DeleteArgs = {
slug?: string;
id: string;
auth?: boolean;
};
type DeleteManyArgs = {
slug?: string;
auth?: boolean;
query: any;
};
type FindGlobalArgs<T = any> = {
slug?: string;
auth?: boolean;
@@ -75,6 +89,12 @@ type DocResponse<T> = {
errors?: { name: string, message: string, data: any }[]
};
type DocsResponse<T> = {
status: number;
docs: T[];
errors?: { name: string, message: string, data: any, id: string | number}[]
};
const headers = {
'Content-Type': 'application/json',
Authorization: '',
@@ -184,6 +204,45 @@ export class RESTClient {
return { status, doc: json.doc, errors: json.errors };
}
async updateMany<T = any>(args: UpdateManyArgs<T>): Promise<DocsResponse<T>> {
const { slug, data, query } = args;
const formattedQs = qs.stringify({
...(query ? { where: query } : {}),
}, {
addQueryPrefix: true,
});
if (args?.auth) {
headers.Authorization = `JWT ${this.token}`;
}
const response = await fetch(`${this.serverURL}/api/${slug || this.defaultSlug}${formattedQs}`, {
body: JSON.stringify(data),
headers,
method: 'PATCH',
});
const { status } = response;
const json = await response.json();
return { status, docs: json.docs, errors: json.errors };
}
async deleteMany<T = any>(args: DeleteManyArgs): Promise<DocsResponse<T>> {
const { slug, query } = args;
const formattedQs = qs.stringify({
...(query ? { where: query } : {}),
}, {
addQueryPrefix: true,
});
if (args?.auth) {
headers.Authorization = `JWT ${this.token}`;
}
const response = await fetch(`${this.serverURL}/api/${slug || this.defaultSlug}${formattedQs}`, {
headers,
method: 'DELETE',
});
const { status } = response;
const json = await response.json();
return { status, docs: json.docs, errors: json.errors };
}
async findByID<T = any>(args: FindByIDArgs): Promise<DocResponse<T>> {
const options = {
headers: {

View File

@@ -65,10 +65,10 @@ describe('uploads', () => {
test('should show upload filename in upload collection list', async () => {
await page.goto(mediaURL.list);
const audioUpload = page.locator('.thumbnail-card__label').nth(0);
const audioUpload = page.locator('tr.row-1 .cell-filename');
await expect(audioUpload).toHaveText('audio.mp3');
const imageUpload = page.locator('.thumbnail-card__label').nth(1);
const imageUpload = page.locator('tr.row-2 .cell-filename');
await expect(imageUpload).toHaveText('image.png');
});
@@ -118,13 +118,13 @@ describe('uploads', () => {
await page.locator('.upload__toggler.list-drawer__toggler').click();
const listDrawer = await page.locator('[id^=list-drawer_1_]');
await expect(listDrawer).toBeVisible();
await wait(200); // cards are loading
await wait(200); // list is loading
// ensure the only card is the audio file
const cards = await listDrawer.locator('.upload-gallery .thumbnail-card');
expect(await cards.count()).toEqual(1);
const card = cards.nth(0);
await expect(card).toHaveText('audio.mp3');
const rows = await listDrawer.locator('table tbody tr');
expect(await rows.count()).toEqual(1);
const filename = rows.locator('.cell-filename');
await expect(filename).toHaveText('audio.mp3');
// upload an image and try to select it
await listDrawer.locator('button.list-drawer__create-new-button.doc-drawer__toggler').click();

View File

@@ -181,6 +181,39 @@ describe('Collections - Uploads', () => {
expect(await fileExists(path.join(__dirname, './media', mediaDoc.sizes.icon.filename))).toBe(true);
});
it('update - update many', async () => {
// Create image
const filePath = path.resolve(__dirname, './image.png');
const file = await getFileByPath(filePath);
file.name = 'renamed.png';
const mediaDoc = await payload.create({
collection: mediaSlug,
data: {},
file,
});
const formData = new FormData();
formData.append('file', fs.createReadStream(path.join(__dirname, './small.png')));
const { status } = await client.updateMany({
// id: mediaDoc.id,
query: {
id: { equals: mediaDoc.id },
},
file: true,
data: formData,
auth: true,
headers: {},
});
expect(status).toBe(200);
// Check that previously existing files were removed
expect(await fileExists(path.join(__dirname, './media', mediaDoc.filename))).toBe(true);
expect(await fileExists(path.join(__dirname, './media', mediaDoc.sizes.icon.filename))).toBe(true);
});
it('should remove existing media on re-upload', async () => {
// Create temp file
const filePath = path.resolve(__dirname, './temp.png');
@@ -213,6 +246,40 @@ describe('Collections - Uploads', () => {
expect(await fileExists(path.join(__dirname, './media', mediaDoc.filename))).toBe(false);
});
it('should remove existing media on re-upload - update many', async () => {
// Create temp file
const filePath = path.resolve(__dirname, './temp.png');
const file = await getFileByPath(filePath);
file.name = 'temp.png';
const mediaDoc = await payload.create({
collection: mediaSlug,
data: {},
file,
});
// Check that the temp file was created
expect(await fileExists(path.join(__dirname, './media', mediaDoc.filename))).toBe(true);
// Replace the temp file with a new one
const newFilePath = path.resolve(__dirname, './temp-renamed.png');
const newFile = await getFileByPath(newFilePath);
newFile.name = 'temp-renamed.png';
const updatedMediaDoc = await payload.update({
collection: mediaSlug,
where: {
id: { equals: mediaDoc.id },
},
file: newFile,
data: {},
});
// Check that the replacement file was created and the old one was removed
expect(await fileExists(path.join(__dirname, './media', updatedMediaDoc.docs[0].filename))).toBe(true);
expect(await fileExists(path.join(__dirname, './media', mediaDoc.filename))).toBe(false);
});
it('should remove extra sizes on update', async () => {
const filePath = path.resolve(__dirname, './image.png');
const file = await getFileByPath(filePath);
@@ -235,6 +302,30 @@ describe('Collections - Uploads', () => {
expect(doc.sizes.tablet.width).toBeNull();
});
it('should remove extra sizes on update - update many', async () => {
const filePath = path.resolve(__dirname, './image.png');
const file = await getFileByPath(filePath);
const small = await getFileByPath(path.resolve(__dirname, './small.png'));
const { id } = await payload.create({
collection: mediaSlug,
data: {},
file,
});
const doc = await payload.update({
collection: mediaSlug,
where: {
id: { equals: id },
},
data: {},
file: small,
});
expect(doc.docs[0].sizes.icon).toBeDefined();
expect(doc.docs[0].sizes.tablet.width).toBeNull();
});
it('should allow update removing a relationship', async () => {
const filePath = path.resolve(__dirname, './image.png');
const file = await getFileByPath(filePath);
@@ -264,6 +355,37 @@ describe('Collections - Uploads', () => {
expect(doc.image).toBeNull();
});
it('should allow update removing a relationship - update many', async () => {
const filePath = path.resolve(__dirname, './image.png');
const file = await getFileByPath(filePath);
file.name = 'renamed.png';
const { id } = await payload.create({
collection: mediaSlug,
data: {},
file,
});
const related = await payload.create({
collection: relationSlug,
data: {
image: id,
},
});
const doc = await payload.update({
collection: relationSlug,
where: {
id: { equals: related.id },
},
data: {
image: null,
},
});
expect(doc.docs[0].image).toBeNull();
});
it('delete', async () => {
const formData = new FormData();
formData.append('file', fs.createReadStream(path.join(__dirname, './image.png')));
@@ -283,6 +405,30 @@ describe('Collections - Uploads', () => {
expect(await fileExists(path.join(__dirname, doc.filename))).toBe(false);
});
it('delete - update many', async () => {
const formData = new FormData();
formData.append('file', fs.createReadStream(path.join(__dirname, './image.png')));
const { doc } = await client.create({
file: true,
data: formData,
auth: true,
headers: {},
});
const { errors } = await client.deleteMany({
slug: mediaSlug,
query: {
id: { equals: doc.id },
},
auth: true,
});
expect(errors).toHaveLength(0);
expect(await fileExists(path.join(__dirname, doc.filename))).toBe(false);
});
});
async function fileExists(fileName: string): Promise<boolean> {

View File

@@ -1,7 +1,8 @@
import type { CollectionConfig } from '../../../src/collections/config/types';
import { autosaveSlug } from '../shared';
const AutosavePosts: CollectionConfig = {
slug: 'autosave-posts',
slug: autosaveSlug,
labels: {
singular: 'Autosave Post',
plural: 'Autosave Posts',

View File

@@ -1,10 +1,11 @@
import type { CollectionConfig } from '../../../src/collections/config/types';
import { draftSlug } from '../shared';
const DraftPosts: CollectionConfig = {
slug: 'draft-posts',
slug: draftSlug,
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'description', 'createdAt'],
defaultColumns: ['title', 'description', 'createdAt', '_status'],
preview: () => 'https://payloadcms.com',
},
versions: {

View File

@@ -1,7 +1,8 @@
import type { CollectionConfig } from '../../../src/collections/config/types';
import { versionSlug } from '../shared';
const VersionPosts: CollectionConfig = {
slug: 'version-posts',
slug: versionSlug,
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'description', 'createdAt'],

View File

@@ -5,6 +5,7 @@ import AutosaveGlobal from './globals/Autosave';
import { devUser } from '../credentials';
import DraftGlobal from './globals/Draft';
import VersionPosts from './collections/Versions';
import { draftSlug } from './shared';
export default buildConfig({
collections: [
@@ -28,5 +29,46 @@ export default buildConfig({
password: devUser.password,
},
});
const { id: draftID } = await payload.create({
collection: draftSlug,
draft: true,
data: {
id: 1,
title: 'draft title',
description: 'draft description',
radio: 'test',
},
});
await payload.create({
collection: draftSlug,
draft: false,
data: {
id: 2,
title: 'published title',
description: 'published description',
radio: 'test',
_status: 'published',
},
});
await payload.update({
collection: draftSlug,
id: draftID,
draft: true,
data: {
title: 'draft title 2',
},
});
await payload.update({
collection: draftSlug,
id: draftID,
draft: true,
data: {
title: 'draft title 3',
},
});
},
});

116
test/versions/e2e.spec.ts Normal file
View File

@@ -0,0 +1,116 @@
/**
* TODO: Versions, 3 separate collections
* - drafts
* - save draft before publishing
* - publish immediately
* - validation should be skipped when creating a draft
*
* - autosave
* - versions (no drafts)
* - version control shown
* - assert version counts increment
* - navigate to versions
* - versions view accurately shows number of versions
* - compare
* - iterable
* - nested
* - relationship
* - select w/ i18n options (label: { en: 'example', ... })
* - tabs
* - text
* - richtext
* - restore version
* - specify locales to show
*/
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { initPayloadE2E } from '../helpers/configHelpers';
import { AdminUrlUtil } from '../helpers/adminUrlUtil';
import { login } from '../helpers';
import { draftSlug } from './shared';
const { beforeAll, describe } = test;
describe('versions', () => {
let page: Page;
let serverURL: string;
beforeAll(async ({ browser }) => {
const config = await initPayloadE2E(__dirname);
serverURL = config.serverURL;
const context = await browser.newContext();
page = await context.newPage();
await login({ page, serverURL });
});
describe('draft collections', () => {
let url: AdminUrlUtil;
beforeAll(() => {
url = new AdminUrlUtil(serverURL, draftSlug);
});
test('should bulk publish', async () => {
await page.goto(url.list);
await page.locator('.select-all__input').click();
await page.locator('.publish-many__toggle').click();
await page.locator('#confirm-publish').click();
await expect(page.locator('.row-1 .cell-_status')).toContainText('Published');
await expect(page.locator('.row-2 .cell-_status')).toContainText('Published');
});
test('should bulk unpublish', async () => {
await page.goto(url.list);
await page.locator('.select-all__input').click();
await page.locator('.unpublish-many__toggle').click();
await page.locator('#confirm-unpublish').click();
await expect(page.locator('.row-1 .cell-_status')).toContainText('Draft');
await expect(page.locator('.row-2 .cell-_status')).toContainText('Draft');
});
test('should publish while editing many', async () => {
const description = 'published document';
await page.goto(url.list);
await page.locator('.select-all__input').click();
await page.locator('.edit-many__toggle').click();
await page.locator('.field-select .rs__control').click();
const options = page.locator('.rs__option');
const field = await options.locator('text=description');
await field.click();
await page.locator('#field-description').fill(description);
await page.locator('.form-submit .edit-many__publish').click();
await expect(page.locator('.Toastify__toast--success')).toContainText('Updated 2 Draft Posts successfully.');
await expect(page.locator('.row-1 .cell-_status')).toContainText('Published');
await expect(page.locator('.row-2 .cell-_status')).toContainText('Published');
});
test('should save as draft while editing many', async () => {
const description = 'draft document';
await page.goto(url.list);
await page.locator('.select-all__input').click();
await page.locator('.edit-many__toggle').click();
await page.locator('.field-select .rs__control').click();
const options = page.locator('.rs__option');
const field = await options.locator('text=description');
await field.click();
await page.locator('#field-description').fill(description);
await page.locator('.form-submit .edit-many__draft').click();
await expect(page.locator('.Toastify__toast--success')).toContainText('Updated 2 Draft Posts successfully.');
await expect(page.locator('.row-1 .cell-_status')).toContainText('Draft');
await expect(page.locator('.row-2 .cell-_status')).toContainText('Draft');
});
});
});

View File

@@ -1,24 +0,0 @@
/**
* TODO: Versions, 3 separate collections
* - drafts
* - save draft before publishing
* - publish immediately
* - validation should be skipped when creating a draft
*
* - autosave
* - versions (no drafts)
* - version control shown
* - assert version counts increment
* - navigate to versions
* - versions view accurately shows number of versions
* - compare
* - iterable
* - nested
* - relationship
* - select w/ i18n options (label: { en: 'example', ... })
* - tabs
* - text
* - richtext
* - restore version
* - specify locales to show
*/

View File

@@ -1,7 +1,8 @@
import { GlobalConfig } from '../../../src/globals/config/types';
import { autoSaveGlobalSlug } from '../shared';
const AutosaveGlobal: GlobalConfig = {
slug: 'autosave-global',
slug: autoSaveGlobalSlug,
label: 'Autosave Global',
preview: () => 'https://payloadcms.com',
versions: {

View File

@@ -1,7 +1,8 @@
import { GlobalConfig } from '../../../src/globals/config/types';
import { draftGlobalSlug } from '../shared';
const DraftGlobal: GlobalConfig = {
slug: 'draft-global',
slug: draftGlobalSlug,
label: 'Draft Global',
preview: () => 'https://payloadcms.com',
versions: {

6
test/versions/shared.ts Normal file
View File

@@ -0,0 +1,6 @@
export const draftSlug = 'draft-posts';
export const autosaveSlug = 'autosave-posts';
export const versionSlug = 'version-posts';
export const autoSaveGlobalSlug = 'autosave-global';
export const draftGlobalSlug = 'draft-global';