feat: bulk-operations (#2346)
Co-authored-by: PatrikKozak <patrik@trbl.design>
This commit is contained in:
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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
116
test/versions/e2e.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
*/
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
6
test/versions/shared.ts
Normal 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';
|
||||
Reference in New Issue
Block a user