chore: move to monorepo structure

This commit is contained in:
Alessio Gravili
2023-08-23 12:20:30 +02:00
parent e24ad67168
commit a67278b29f
1398 changed files with 2392 additions and 16005 deletions

View File

@@ -1,13 +0,0 @@
import type { CollectionConfig } from '../../../../src/collections/config/types';
export const mediaSlug = 'media';
export const MediaCollection: CollectionConfig = {
slug: mediaSlug,
upload: true,
access: {
read: () => true,
create: () => true,
},
fields: [],
};

View File

@@ -1,23 +0,0 @@
import type { CollectionConfig } from '../../../../src/collections/config/types';
import { mediaSlug } from '../Media';
export const postsSlug = 'posts';
export const PostsCollection: CollectionConfig = {
slug: postsSlug,
fields: [
{
name: 'text',
type: 'text',
},
{
name: 'associatedMedia',
type: 'upload',
relationTo: mediaSlug,
access: {
create: () => true,
update: () => false,
},
},
],
};

View File

@@ -1,38 +0,0 @@
import { buildConfigWithDefaults } from '../buildConfigWithDefaults';
import { PostsCollection, postsSlug } from './collections/Posts';
import { MenuGlobal } from './globals/Menu';
import { devUser } from '../credentials';
import { MediaCollection } from './collections/Media';
export default buildConfigWithDefaults({
// ...extend config here
collections: [
PostsCollection,
MediaCollection,
// ...add more collections here
],
globals: [
MenuGlobal,
// ...add more globals here
],
graphQL: {
schemaOutputFile: './test/_community/schema.graphql',
},
onInit: async (payload) => {
await payload.create({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
});
await payload.create({
collection: postsSlug,
data: {
text: 'example post',
},
});
},
});

View File

@@ -1,26 +0,0 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { AdminUrlUtil } from '../helpers/adminUrlUtil';
import { initPayloadE2E } from '../helpers/configHelpers';
const { beforeAll, describe } = test;
let url: AdminUrlUtil;
describe('Admin Panel', () => {
let page: Page;
beforeAll(async ({ browser }) => {
const { serverURL } = await initPayloadE2E(__dirname);
url = new AdminUrlUtil(serverURL, 'posts');
const context = await browser.newContext();
page = await context.newPage();
});
test('example test', async () => {
await page.goto(url.list);
const textCell = page.locator('.row-1 .cell-text');
await expect(textCell).toHaveText('example post');
});
});

View File

@@ -1,13 +0,0 @@
import { GlobalConfig } from '../../../../src/globals/config/types';
export const menuSlug = 'menu';
export const MenuGlobal: GlobalConfig = {
slug: menuSlug,
fields: [
{
name: 'globalText',
type: 'text',
},
],
};

View File

@@ -1,72 +0,0 @@
import payload from '../../src';
import { initPayloadTest } from '../helpers/configHelpers';
import { devUser } from '../credentials';
import { postsSlug } from './collections/Posts';
require('isomorphic-fetch');
let apiUrl;
let jwt;
const headers = {
'Content-Type': 'application/json',
};
const { email, password } = devUser;
describe('_Community Tests', () => {
// --__--__--__--__--__--__--__--__--__
// Boilerplate test setup/teardown
// --__--__--__--__--__--__--__--__--__
beforeAll(async () => {
const { serverURL } = await initPayloadTest({ __dirname, init: { local: false } });
apiUrl = `${serverURL}/api`;
const response = await fetch(`${apiUrl}/users/login`, {
body: JSON.stringify({
email,
password,
}),
headers,
method: 'post',
});
const data = await response.json();
jwt = data.token;
});
afterAll(async () => {
if (typeof payload.db.destroy === 'function') {
await payload.db.destroy(payload);
}
});
// --__--__--__--__--__--__--__--__--__
// You can run tests against the local API or the REST API
// use the tests below as a guide
// --__--__--__--__--__--__--__--__--__
it('local API example', async () => {
const newPost = await payload.create({
collection: postsSlug,
data: {
text: 'LOCAL API EXAMPLE',
},
});
expect(newPost.text).toEqual('LOCAL API EXAMPLE');
});
it('rest API example', async () => {
const newPost = await fetch(`${apiUrl}/${postsSlug}`, {
method: 'POST',
headers: {
...headers,
Authorization: `JWT ${jwt}`,
},
body: JSON.stringify({
text: 'REST API EXAMPLE',
}),
}).then((res) => res.json());
expect(newPost.doc.text).toEqual('REST API EXAMPLE');
});
});

View File

@@ -1,50 +0,0 @@
/* tslint:disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
export interface Config {
collections: {
posts: Post;
media: Media;
users: User;
};
globals: {
menu: Menu;
};
}
export interface Post {
id: string;
text?: string;
associatedMedia?: string | Media;
updatedAt: string;
createdAt: string;
}
export interface Media {
id: string;
updatedAt: string;
createdAt: string;
url?: string;
filename?: string;
mimeType?: string;
filesize?: number;
width?: number;
height?: number;
}
export interface User {
id: string;
updatedAt: string;
createdAt: string;
email?: string;
resetPasswordToken?: string;
resetPasswordExpiration?: string;
loginAttempts?: number;
lockUntil?: string;
password?: string;
}
export interface Menu {
id: string;
globalText?: string;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +0,0 @@
{
"compilerOptions": {
"paths": {
"payload/generated-types": [
"./payload-types.ts"
]
}
}
}

View File

@@ -1,427 +0,0 @@
import { devUser } from '../credentials';
import { buildConfigWithDefaults } from '../buildConfigWithDefaults';
import { FieldAccess } from '../../src/fields/config/types';
import { firstArrayText, secondArrayText } from './shared';
export const slug = 'posts';
export const unrestrictedSlug = 'unrestricted';
export const readOnlySlug = 'read-only-collection';
export const userRestrictedSlug = 'user-restricted';
export const restrictedSlug = 'restricted';
export const restrictedVersionsSlug = 'restricted-versions';
export const siblingDataSlug = 'sibling-data';
export const relyOnRequestHeadersSlug = 'rely-on-request-headers';
export const docLevelAccessSlug = 'doc-level-access';
export const hiddenFieldsSlug = 'hidden-fields';
export const hiddenAccessSlug = 'hidden-access';
const openAccess = {
create: () => true,
read: () => true,
update: () => true,
delete: () => true,
};
const PublicReadabilityAccess: FieldAccess = ({ req: { user }, siblingData }) => {
if (user) return true;
if (siblingData?.allowPublicReadability) return true;
return false;
};
export const requestHeaders = { authorization: 'Bearer testBearerToken' };
const UseRequestHeadersAccess: FieldAccess = ({ req: { headers } }) => {
return !!headers && headers.authorization === requestHeaders.authorization;
};
export default buildConfigWithDefaults({
admin: {
user: 'users',
},
collections: [
{
slug: 'users',
auth: true,
access: {
// admin: () => true,
admin: async () => new Promise((resolve) => {
// Simulate a request to an external service to determine access, i.e. another instance of Payload
setTimeout(resolve, 50, true); // set to 'true' or 'false' here to simulate the response
}),
},
fields: [],
},
{
slug,
access: {
...openAccess,
update: () => false,
},
fields: [
{
name: 'restrictedField',
type: 'text',
access: {
read: () => false,
update: () => false,
},
},
{
type: 'group',
name: 'group',
fields: [
{
name: 'restrictedGroupText',
type: 'text',
access: {
read: () => false,
update: () => false,
create: () => false,
},
},
],
},
{
type: 'row',
fields: [
{
name: 'restrictedRowText',
type: 'text',
access: {
read: () => false,
update: () => false,
create: () => false,
},
},
],
},
{
type: 'collapsible',
label: 'Access',
fields: [
{
name: 'restrictedCollapsibleText',
type: 'text',
access: {
read: () => false,
update: () => false,
create: () => false,
},
},
],
},
],
},
{
slug: unrestrictedSlug,
fields: [
{
name: 'name',
type: 'text',
},
{
name: 'userRestrictedDocs',
type: 'relationship',
relationTo: userRestrictedSlug,
hasMany: true,
},
],
},
{
slug: restrictedSlug,
fields: [
{
name: 'name',
type: 'text',
},
],
access: {
create: () => false,
read: () => false,
update: () => false,
delete: () => false,
},
},
{
slug: readOnlySlug,
fields: [
{
name: 'name',
type: 'text',
},
],
access: {
create: () => false,
read: () => true,
update: () => false,
delete: () => false,
},
},
{
slug: userRestrictedSlug,
admin: {
useAsTitle: 'name',
},
fields: [
{
name: 'name',
type: 'text',
},
],
access: {
create: () => true,
read: () => true,
update: ({ req }) => ({
name: {
equals: req.user?.email,
},
}),
delete: () => false,
},
},
{
slug: restrictedVersionsSlug,
versions: true,
fields: [
{
name: 'name',
type: 'text',
},
{
name: 'hidden',
type: 'checkbox',
hidden: true,
},
],
access: {
read: ({ req: { user } }) => {
if (user) return true;
return {
hidden: {
not_equals: true,
},
};
},
readVersions: ({ req: { user } }) => {
if (user) return true;
return {
'version.hidden': {
not_equals: true,
},
};
},
},
},
{
slug: siblingDataSlug,
access: openAccess,
fields: [
{
name: 'array',
type: 'array',
fields: [
{
type: 'row',
fields: [
{
name: 'allowPublicReadability',
label: 'Allow Public Readability',
type: 'checkbox',
},
{
name: 'text',
type: 'text',
access: {
read: PublicReadabilityAccess,
},
},
],
},
],
},
],
},
{
slug: relyOnRequestHeadersSlug,
access: {
create: UseRequestHeadersAccess,
read: UseRequestHeadersAccess,
update: UseRequestHeadersAccess,
delete: UseRequestHeadersAccess,
},
fields: [
{
name: 'name',
type: 'text',
},
],
},
{
slug: docLevelAccessSlug,
labels: {
singular: 'Doc Level Access',
plural: 'Doc Level Access',
},
access: {
delete: () => ({
and: [
{
approvedForRemoval: {
equals: true,
},
},
],
}),
},
fields: [
{
name: 'approvedForRemoval',
type: 'checkbox',
defaultValue: false,
admin: {
position: 'sidebar',
},
},
{
name: 'approvedTitle',
type: 'text',
localized: true,
access: {
update: (args) => {
if (args?.doc?.lockTitle) {
return false;
}
return true;
},
},
},
{
name: 'lockTitle',
type: 'checkbox',
defaultValue: false,
},
],
},
{
slug: hiddenFieldsSlug,
access: openAccess,
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'partiallyHiddenGroup',
type: 'group',
fields: [
{
name: 'name',
type: 'text',
},
{
name: 'value',
type: 'text',
hidden: true,
},
],
},
{
name: 'partiallyHiddenArray',
type: 'array',
fields: [
{
name: 'name',
type: 'text',
},
{
name: 'value',
type: 'text',
hidden: true,
},
],
},
{
name: 'hidden',
type: 'checkbox',
hidden: true,
},
],
},
{
slug: hiddenAccessSlug,
access: {
read: ({ req: { user } }) => {
if (user) return true;
return {
hidden: {
not_equals: true,
},
};
},
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'hidden',
type: 'checkbox',
hidden: true,
},
],
},
],
onInit: async (payload) => {
await payload.create({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
});
await payload.create({
collection: slug,
data: {
restrictedField: 'restricted',
},
});
await payload.create({
collection: readOnlySlug,
data: {
name: 'read-only',
},
});
await payload.create({
collection: restrictedVersionsSlug,
data: {
name: 'versioned',
},
});
await payload.create({
collection: siblingDataSlug,
data: {
array: [
{
text: firstArrayText,
allowPublicReadability: true,
},
{
text: secondArrayText,
allowPublicReadability: false,
},
],
},
});
},
});

View File

@@ -1,261 +0,0 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import payload from '../../src';
import { AdminUrlUtil } from '../helpers/adminUrlUtil';
import { initPayloadE2E } from '../helpers/configHelpers';
import { restrictedVersionsSlug, readOnlySlug, restrictedSlug, slug, docLevelAccessSlug, unrestrictedSlug } from './config';
import type { ReadOnlyCollection, RestrictedVersion } from './payload-types';
import wait from '../../src/utilities/wait';
/**
* TODO: Access Control
* prevent user from logging in (canAccessAdmin)
*
* FSK: 'should properly prevent / allow public users from reading a restricted field'
*
* Repeat all above for globals
*/
const { beforeAll, describe } = test;
describe('access control', () => {
let page: Page;
let url: AdminUrlUtil;
let restrictedUrl: AdminUrlUtil;
let readOnlyUrl: AdminUrlUtil;
let restrictedVersionsUrl: AdminUrlUtil;
let serverURL: string;
beforeAll(async ({ browser }) => {
const config = await initPayloadE2E(__dirname);
serverURL = config.serverURL;
url = new AdminUrlUtil(serverURL, slug);
restrictedUrl = new AdminUrlUtil(serverURL, restrictedSlug);
readOnlyUrl = new AdminUrlUtil(serverURL, readOnlySlug);
restrictedVersionsUrl = new AdminUrlUtil(serverURL, restrictedVersionsSlug);
const context = await browser.newContext();
page = await context.newPage();
});
test('field without read access should not show', async () => {
const { id } = await createDoc({ restrictedField: 'restricted' });
await page.goto(url.edit(id));
await expect(page.locator('#field-restrictedField')).toHaveCount(0);
});
test('field without read access inside a group should not show', async () => {
const { id } = await createDoc({ restrictedField: 'restricted' });
await page.goto(url.edit(id));
await expect(page.locator('#field-group__restrictedGroupText')).toHaveCount(0);
});
test('field without read access inside a collapsible should not show', async () => {
const { id } = await createDoc({ restrictedField: 'restricted' });
await page.goto(url.edit(id));
await expect(page.locator('#field-restrictedRowText')).toHaveCount(0);
});
test('field without read access inside a row should not show', async () => {
const { id } = await createDoc({ restrictedField: 'restricted' });
await page.goto(url.edit(id));
await expect(page.locator('#field-restrictedCollapsibleText')).toHaveCount(0);
});
describe('restricted collection', () => {
let existingDoc: ReadOnlyCollection;
beforeAll(async () => {
existingDoc = await payload.create({
collection: readOnlySlug,
data: {
name: 'name',
},
});
});
test('should not show in card list', async () => {
await page.goto(url.admin);
await expect(page.locator(`#card-${restrictedSlug}`)).toHaveCount(0);
});
test('should not show in nav', async () => {
await page.goto(url.admin);
await expect(page.locator('.nav >> a:has-text("Restricteds")')).toHaveCount(0);
});
test('should not have list url', async () => {
await page.goto(restrictedUrl.list);
await expect(page.locator('.unauthorized')).toBeVisible();
});
test('should not have create url', async () => {
await page.goto(restrictedUrl.create);
await expect(page.locator('.unauthorized')).toBeVisible();
});
test('should not have access to existing doc', async () => {
await page.goto(restrictedUrl.edit(existingDoc.id));
await expect(page.locator('.unauthorized')).toBeVisible();
});
});
describe('read-only collection', () => {
let existingDoc: ReadOnlyCollection;
beforeAll(async () => {
existingDoc = await payload.create({
collection: readOnlySlug,
data: {
name: 'name',
},
});
});
test('should show in card list', async () => {
await page.goto(url.admin);
await expect(page.locator(`#card-${readOnlySlug}`)).toHaveCount(1);
});
test('should show in nav', async () => {
await page.goto(url.admin);
await expect(page.locator(`.nav a[href="/admin/collections/${readOnlySlug}"]`)).toHaveCount(1);
});
test('should have collection url', async () => {
await page.goto(readOnlyUrl.list);
await expect(page).toHaveURL(readOnlyUrl.list); // no redirect
});
test('should not have "Create New" button', async () => {
await page.goto(readOnlyUrl.create);
await expect(page.locator('.collection-list__header a')).toHaveCount(0);
});
test('should not have quick create button', async () => {
await page.goto(url.admin);
await expect(page.locator(`#card-${readOnlySlug}`)).not.toHaveClass('card__actions');
});
test('edit view should not have actions buttons', async () => {
await page.goto(readOnlyUrl.edit(existingDoc.id));
await expect(page.locator('.collection-edit__collection-actions li')).toHaveCount(0);
});
test('fields should be read-only', async () => {
await page.goto(readOnlyUrl.edit(existingDoc.id));
await expect(page.locator('#field-name')).toBeDisabled();
});
});
describe('readVersions', () => {
let existingDoc: RestrictedVersion;
beforeAll(async () => {
existingDoc = await payload.create({
collection: restrictedVersionsSlug,
data: {
name: 'name',
},
});
});
test('versions sidebar should not show', async () => {
await page.goto(restrictedVersionsUrl.edit(existingDoc.id));
await expect(page.locator('.versions-count')).toBeHidden();
});
});
describe('doc level access', () => {
let existingDoc: ReadOnlyCollection;
let docLevelAccessURL;
beforeAll(async () => {
docLevelAccessURL = new AdminUrlUtil(serverURL, docLevelAccessSlug);
existingDoc = await payload.create({
collection: docLevelAccessSlug,
data: {
approvedTitle: 'Title',
lockTitle: true,
approvedForRemoval: false,
},
});
});
test('disable field based on document data', async () => {
await page.goto(docLevelAccessURL.edit(existingDoc.id));
// validate that the text input is disabled because the field is "locked"
const isDisabled = await page.locator('#field-approvedTitle').isDisabled();
expect(isDisabled).toBe(true);
});
test('disable operation based on document data', async () => {
await page.goto(docLevelAccessURL.edit(existingDoc.id));
// validate that the delete action is not displayed
const duplicateAction = page.locator('.collection-edit__collection-actions >> li').last();
await expect(duplicateAction).toContainText('Duplicate');
await page.locator('#field-approvedForRemoval').check();
await page.locator('#action-save').click();
const deleteAction = page.locator('.collection-edit__collection-actions >> li').last();
await expect(deleteAction).toContainText('Delete');
});
});
test('maintain access control in document drawer', async () => {
const unrestrictedDoc = await payload.create({
collection: unrestrictedSlug,
data: {
name: 'unrestricted-123',
},
});
// navigate to the `unrestricted` document and open the drawers to test access
const unrestrictedURL = new AdminUrlUtil(serverURL, unrestrictedSlug);
await page.goto(unrestrictedURL.edit(unrestrictedDoc.id));
const button = await page.locator('#userRestrictedDocs-add-new button.relationship-add-new__add-button.doc-drawer__toggler');
await button.click();
const documentDrawer = await page.locator('[id^=doc-drawer_user-restricted_1_]');
await expect(documentDrawer).toBeVisible();
await documentDrawer.locator('#field-name').fill('anonymous@email.com');
await documentDrawer.locator('#action-save').click();
await wait(200);
await expect(page.locator('.Toastify')).toContainText('successfully');
// ensure user is not allowed to edit this document
await expect(await documentDrawer.locator('#field-name')).toBeDisabled();
await documentDrawer.locator('button.doc-drawer__header-close').click();
await wait(200);
await button.click();
const documentDrawer2 = await page.locator('[id^=doc-drawer_user-restricted_1_]');
await expect(documentDrawer2).toBeVisible();
await documentDrawer2.locator('#field-name').fill('dev@payloadcms.com');
await documentDrawer2.locator('#action-save').click();
await wait(200);
await expect(page.locator('.Toastify')).toContainText('successfully');
// ensure user is allowed to edit this document
await expect(await documentDrawer2.locator('#field-name')).toBeEnabled();
});
});
async function createDoc(data: any): Promise<{ id: string }> {
return payload.create({
collection: slug,
data,
});
}

View File

@@ -1,431 +0,0 @@
import payload from '../../src';
import { Forbidden } from '../../src/errors';
import type { PayloadRequest } from '../../src/types';
import { initPayloadTest } from '../helpers/configHelpers';
import {
hiddenAccessSlug,
hiddenFieldsSlug,
relyOnRequestHeadersSlug,
requestHeaders,
restrictedSlug,
restrictedVersionsSlug,
siblingDataSlug,
slug,
} from './config';
import type { Post, RelyOnRequestHeader, Restricted } from './payload-types';
import { firstArrayText, secondArrayText } from './shared';
describe('Access Control', () => {
let post1: Post;
let restricted: Restricted;
beforeAll(async () => {
await initPayloadTest({ __dirname });
});
beforeEach(async () => {
post1 = await payload.create({
collection: slug,
data: { name: 'name' },
});
restricted = await payload.create({
collection: restrictedSlug,
data: { name: 'restricted' },
});
});
afterAll(async () => {
if (typeof payload.db.destroy === 'function') {
await payload.db.destroy(payload);
}
});
it('should not affect hidden fields when patching data', async () => {
const doc = 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,
id: doc.id,
data: {
title: 'Doc Title',
},
});
const updatedDoc = await payload.findByID({
collection: hiddenFieldsSlug,
id: doc.id,
showHiddenFields: true,
});
expect(updatedDoc.partiallyHiddenGroup.value).toEqual('private_value');
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,
data: {
array: [
{
text: firstArrayText,
allowPublicReadability: true,
},
{
text: secondArrayText,
allowPublicReadability: false,
},
],
},
});
const doc = await payload.findByID({
id,
collection: siblingDataSlug,
overrideAccess: false,
});
expect(doc.array?.[0].text).toBe(firstArrayText);
// Should respect PublicReadabilityAccess function and not be sent
expect(doc.array?.[1].text).toBeUndefined();
// Retrieve with default of overriding access
const docOverride = await payload.findByID({
id,
collection: siblingDataSlug,
});
expect(docOverride.array?.[0].text).toBe(firstArrayText);
expect(docOverride.array?.[1].text).toBe(secondArrayText);
});
describe('Collections', () => {
describe('restricted collection', () => {
it('field without read access should not show', async () => {
const { id } = await createDoc<Post>({ restrictedField: 'restricted' });
const retrievedDoc = await payload.findByID({ collection: slug, id, overrideAccess: false });
expect(retrievedDoc.restrictedField).toBeUndefined();
});
it('field without read access should not show when overrideAccess: true', async () => {
const { id, restrictedField } = await createDoc<Post>({ restrictedField: 'restricted' });
const retrievedDoc = await payload.findByID({ collection: slug, id, overrideAccess: true });
expect(retrievedDoc.restrictedField).toEqual(restrictedField);
});
it('field without read access should not show when overrideAccess default', async () => {
const { id, restrictedField } = await createDoc<Post>({ restrictedField: 'restricted' });
const retrievedDoc = await payload.findByID({ collection: slug, id });
expect(retrievedDoc.restrictedField).toEqual(restrictedField);
});
});
describe('non-enumerated request properties passed to access control', () => {
it('access control ok when passing request headers', async () => {
const req = Object.defineProperty({}, 'headers', {
value: requestHeaders,
enumerable: false,
}) as PayloadRequest;
const name = 'name';
const overrideAccess = false;
const { id } = await createDoc<RelyOnRequestHeader>({ name }, relyOnRequestHeadersSlug, { req, overrideAccess });
const docById = await payload.findByID({ collection: relyOnRequestHeadersSlug, id, req, overrideAccess });
const { docs: docsByName } = await payload.find({
collection: relyOnRequestHeadersSlug,
where: {
name: {
equals: name,
},
},
req,
overrideAccess,
});
expect(docById).not.toBeUndefined();
expect(docsByName.length).toBeGreaterThan(0);
});
it('access control fails when omitting request headers', async () => {
const name = 'name';
const overrideAccess = false;
await expect(() => createDoc<RelyOnRequestHeader>({ name }, relyOnRequestHeadersSlug, { overrideAccess })).rejects.toThrow(Forbidden);
const { id } = await createDoc<RelyOnRequestHeader>({ name }, relyOnRequestHeadersSlug);
await expect(() => payload.findByID({ collection: relyOnRequestHeadersSlug, id, overrideAccess })).rejects.toThrow(Forbidden);
await expect(() => payload.find({
collection: relyOnRequestHeadersSlug,
where: {
name: {
equals: name,
},
},
overrideAccess,
})).rejects.toThrow(Forbidden);
});
});
});
describe('Override Access', () => {
describe('Fields', () => {
it('should allow overrideAccess: false', async () => {
const req = async () => payload.update({
collection: slug,
id: post1.id,
data: { restrictedField: restricted.id },
overrideAccess: false, // this should respect access control
});
await expect(req).rejects.toThrow(Forbidden);
});
it('should allow overrideAccess: true', async () => {
const doc = await payload.update({
collection: slug,
id: post1.id,
data: { restrictedField: restricted.id },
overrideAccess: true, // this should override access control
});
expect(doc).toMatchObject({ id: post1.id });
});
it('should allow overrideAccess by default', async () => {
const doc = await payload.update({
collection: slug,
id: post1.id,
data: { restrictedField: restricted.id },
});
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', () => {
const updatedName = 'updated';
it('should allow overrideAccess: false', async () => {
const req = async () => payload.update({
collection: restrictedSlug,
id: restricted.id,
data: { name: updatedName },
overrideAccess: false, // this should respect access control
});
await expect(req).rejects.toThrow(Forbidden);
});
it('should allow overrideAccess: true', async () => {
const doc = await payload.update({
collection: restrictedSlug,
id: restricted.id,
data: { name: updatedName },
overrideAccess: true, // this should override access control
});
expect(doc).toMatchObject({ id: restricted.id, name: updatedName });
});
it('should allow overrideAccess by default', async () => {
const doc = await payload.update({
collection: restrictedSlug,
id: restricted.id,
data: { name: updatedName },
});
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 });
});
});
});
describe('Querying', () => {
it('should respect query constraint using hidden field', async () => {
await payload.create({
collection: hiddenAccessSlug,
data: {
title: 'hello',
},
});
await payload.create({
collection: hiddenAccessSlug,
data: {
title: 'hello',
hidden: true,
},
});
const { docs } = await payload.find({
collection: hiddenAccessSlug,
overrideAccess: false,
});
expect(docs).toHaveLength(1);
});
it('should respect query constraint using hidden field on versions', async () => {
await payload.create({
collection: restrictedVersionsSlug,
data: {
name: 'match',
hidden: true,
},
});
await payload.create({
collection: restrictedVersionsSlug,
data: {
name: 'match',
hidden: false,
},
});
const { docs } = await payload.findVersions({
where: {
'version.name': { equals: 'match' },
},
collection: restrictedVersionsSlug,
overrideAccess: false,
});
expect(docs).toHaveLength(1);
});
});
});
async function createDoc<Collection>(data: Partial<Collection>, overrideSlug = slug, options?: Partial<Collection>): Promise<Collection> {
return payload.create({
...options,
collection: overrideSlug,
data: data ?? {},
});
}

View File

@@ -1,100 +0,0 @@
/* tslint:disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
export interface Config {
collections: {
users: User;
posts: Post;
restricted: Restricted;
'read-only-collection': ReadOnlyCollection;
'restricted-versions': RestrictedVersion;
'sibling-data': SiblingDatum;
'rely-on-request-headers': RelyOnRequestHeader;
'doc-level-access': DocLevelAccess;
'hidden-fields': HiddenField;
};
globals: {};
}
export interface User {
id: string;
email?: string;
resetPasswordToken?: string;
resetPasswordExpiration?: string;
loginAttempts?: number;
lockUntil?: string;
createdAt: string;
updatedAt: string;
password?: string;
}
export interface Post {
id: string;
restrictedField?: string;
group?: {
restrictedGroupText?: string;
};
restrictedRowText?: string;
restrictedCollapsibleText?: string;
createdAt: string;
updatedAt: string;
}
export interface Restricted {
id: string;
name?: string;
createdAt: string;
updatedAt: string;
}
export interface ReadOnlyCollection {
id: string;
name?: string;
createdAt: string;
updatedAt: string;
}
export interface RestrictedVersion {
id: string;
name?: string;
createdAt: string;
updatedAt: string;
}
export interface SiblingDatum {
id: string;
array?: {
allowPublicReadability?: boolean;
text?: string;
id?: string;
}[];
createdAt: string;
updatedAt: string;
}
export interface RelyOnRequestHeader {
id: string;
name?: string;
createdAt: string;
updatedAt: string;
}
export interface DocLevelAccess {
id: string;
approvedForRemoval?: boolean;
approvedTitle?: string;
lockTitle?: boolean;
createdAt: string;
updatedAt: string;
}
export interface HiddenField {
id: string;
title?: string;
partiallyHiddenGroup?: {
name?: string;
value?: string;
};
partiallyHiddenArray?: {
name?: string;
value?: string;
id?: string;
}[];
createdAt: string;
updatedAt: string;
}

View File

@@ -1,2 +0,0 @@
export const firstArrayText = 'first-array-text';
export const secondArrayText = 'second-array-text';

View File

@@ -1,4 +0,0 @@
.after-dashboard {
border-top: 1px solid var(--theme-elevation-100);
}

View File

@@ -1,18 +0,0 @@
import React from 'react';
import './index.scss';
const baseClass = 'after-dashboard';
const AfterDashboard: React.FC = () => {
return (
<div className={baseClass}>
<h4>Test Config</h4>
<p>
The /test directory is used for create custom configurations and data seeding for developing features, writing e2e and integration testing.
</p>
</div>
);
};
export default AfterDashboard;

View File

@@ -1,43 +0,0 @@
import React from 'react';
import { NavLink } from 'react-router-dom';
// As this is the demo project, we import our dependencies from the `src` directory.
import Chevron from '../../../../src/admin/components/icons/Chevron';
import { useConfig } from '../../../../src/admin/components/utilities/Config';
// In your projects, you can import as follows:
// import { Chevron } from 'payload/components';
// import { useConfig } from 'payload/components/utilities';
const baseClass = 'after-nav-links';
const AfterNavLinks: React.FC = () => {
const { routes: { admin: adminRoute } } = useConfig();
return (
<div className={baseClass}>
<span className="nav__label">Custom Routes</span>
<nav>
<NavLink
className="nav__link"
activeClassName="active"
to={`${adminRoute}/custom-default-route`}
>
<Chevron />
Default Template
</NavLink>
<NavLink
className="nav__link"
activeClassName="active"
to={`${adminRoute}/custom-minimal-route`}
>
<Chevron />
Minimal Template
</NavLink>
</nav>
</div>
);
};
export default AfterNavLinks;

View File

@@ -1,26 +0,0 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
const BeforeLogin: React.FC<{i18n}> = () => {
const { t } = useTranslation();
return (
<div>
<h3>{t('general:welcome')}</h3>
<p>
This demo is a set up to configure Payload for the develop and testing of features. To see a product demo of a Payload project
please visit:
{' '}
<a
href="https://demo.payloadcms.com"
target="_blank"
rel="noreferrer"
>
demo.payloadcms.com
</a>
.
</p>
</div>
);
};
export default BeforeLogin;

View File

@@ -1,29 +0,0 @@
import React, { createContext, useState, useContext } from 'react';
type CustomContext = {
getCustom
setCustom
}
const Context = createContext({} as CustomContext);
const CustomProvider: React.FC = ({ children }) => {
const [getCustom, setCustom] = useState({});
const value = {
getCustom,
setCustom,
};
console.log('custom provider called');
return (
<Context.Provider value={value}>
{children}
</Context.Provider>
);
};
export default CustomProvider;
export const useCustom = () => useContext(Context);

View File

@@ -1,7 +0,0 @@
import React from 'react';
const DemoUIFieldCell: React.FC = () => (
<p>Demo UI Field Cell</p>
);
export default DemoUIFieldCell;

View File

@@ -1,7 +0,0 @@
import React from 'react';
const DemoUIField: React.FC = () => (
<p>Demo UI Field</p>
);
export default DemoUIField;

View File

@@ -1,23 +0,0 @@
import React from 'react';
import { useConfig } from '../../../../src/admin/components/utilities/Config';
import LogOut from '../../../../src/admin/components/icons/LogOut';
const Logout: React.FC = () => {
const config = useConfig();
const {
routes: {
admin,
},
admin: {
logoutRoute
},
} = config;
return (
<a href={`${admin}${logoutRoute}#custom`}>
<LogOut />
</a>
);
};
export default Logout;

View File

@@ -1,33 +0,0 @@
@import '../../../../../../../src/admin/scss/styles.scss';
.button-rich-text-button {
.btn {
margin-right: base(1);
}
&__modal {
display: flex;
align-items: center;
height: 100%;
&.payload__modal-item--enterDone {
@include blur-bg;
}
}
&__header {
width: 100%;
margin-bottom: $baseline;
display: flex;
justify-content: space-between;
h3 {
margin: 0;
}
svg {
width: base(1.5);
height: base(1.5);
}
}
}

View File

@@ -1,129 +0,0 @@
import React, { Fragment, useCallback } from 'react';
import { Modal, useModal } from '@faceless-ui/modal';
import { Transforms } from 'slate';
import { useSlate, ReactEditor } from 'slate-react';
import MinimalTemplate from '../../../../../../../src/admin/components/templates/Minimal';
import ElementButton from '../../../../../../../src/admin/components/forms/field-types/RichText/elements/Button';
import X from '../../../../../../../src/admin/components/icons/X';
import Button from '../../../../../../../src/admin/components/elements/Button';
import Form from '../../../../../../../src/admin/components/forms/Form';
import Submit from '../../../../../../../src/admin/components/forms/Submit';
import reduceFieldsToValues from '../../../../../../../src/admin/components/forms/Form/reduceFieldsToValues';
import Text from '../../../../../../../src/admin/components/forms/field-types/Text';
import Checkbox from '../../../../../../../src/admin/components/forms/field-types/Checkbox';
import Select from '../../../../../../../src/admin/components/forms/field-types/Select';
import './index.scss';
const baseClass = 'button-rich-text-button';
const initialFormData = {
style: 'primary',
};
const insertButton = (editor, { href, label, style, newTab = false }: any) => {
const text = { text: ' ' };
const button = {
type: 'button',
href,
style,
newTab,
label,
children: [
text,
],
};
const nodes = [button, { children: [{ text: '' }] }];
if (editor.blurSelection) {
Transforms.select(editor, editor.blurSelection);
}
Transforms.insertNodes(editor, nodes);
const currentPath = editor.selection.anchor.path[0];
const newSelection = { anchor: { path: [currentPath + 1, 0], offset: 0 }, focus: { path: [currentPath + 1, 0], offset: 0 } };
Transforms.select(editor, newSelection);
ReactEditor.focus(editor);
};
const ToolbarButton: React.FC<{path: string}> = ({ path }) => {
const { open, closeAll } = useModal();
const editor = useSlate();
const handleAddButton = useCallback((fields) => {
const data = reduceFieldsToValues(fields);
insertButton(editor, data);
closeAll();
}, [editor, closeAll]);
const modalSlug = `${path}-add-button`;
return (
<Fragment>
<ElementButton
className={baseClass}
format="button"
onClick={() => open(modalSlug)}
>
Button
</ElementButton>
<Modal
slug={modalSlug}
className={`${baseClass}__modal`}
>
<MinimalTemplate>
<header className={`${baseClass}__header`}>
<h3>Add button</h3>
<Button
buttonStyle="none"
onClick={closeAll}
>
<X />
</Button>
</header>
<Form
onSubmit={handleAddButton}
initialData={initialFormData}
>
<Text
label="Label"
name="label"
required
/>
<Text
label="URL"
name="href"
required
/>
<Select
label="Style"
name="style"
options={[
{
label: 'Primary',
value: 'primary',
},
{
label: 'Secondary',
value: 'secondary',
},
]}
/>
<Checkbox
label="Open in new tab"
name="newTab"
/>
<Submit>
Add button
</Submit>
</Form>
</MinimalTemplate>
</Modal>
</Fragment>
);
};
export default ToolbarButton;

View File

@@ -1,19 +0,0 @@
@import '../../../../../../../src/admin/scss/styles.scss';
.rich-text-button {
margin: $baseline 0;
}
.rich-text-button__button {
padding: base(.5) base(1.5);
border-radius: $style-radius-s;
&--primary {
background-color: $color-dark-gray;
color: white;
}
&--secondary {
background-color: $color-light-gray;
}
}

View File

@@ -1,29 +0,0 @@
import React from 'react';
import './index.scss';
const baseClass = 'rich-text-button';
const ButtonElement: React.FC = ({ attributes, children, element }) => {
const { style = 'primary', label } = element;
return (
<div
className={baseClass}
contentEditable={false}
>
<span
{...attributes}
className={[
`${baseClass}__button`,
`${baseClass}__button--${style}`,
].join(' ')}
>
{label}
{children}
</span>
</div>
);
};
export default ButtonElement;

View File

@@ -1,15 +0,0 @@
import { RichTextCustomElement } from '../../../../../../src/fields/config/types';
import Button from './Button';
import Element from './Element';
import plugin from './plugin';
const button: RichTextCustomElement = {
name: 'button',
Button,
Element,
plugins: [
plugin,
],
};
export default button;

View File

@@ -1,12 +0,0 @@
import { Editor } from 'slate';
const withButton = (incomingEditor: Editor): Editor => {
const editor = incomingEditor;
const { isVoid } = editor;
editor.isVoid = (element) => (element.type === 'button' ? true : isVoid(element));
return editor;
};
export default withButton;

View File

@@ -1,10 +0,0 @@
import React from 'react';
import LeafButton from '../../../../../../../src/admin/components/forms/field-types/RichText/leaves/Button';
const Button = () => (
<LeafButton format="purple-background">
Purple Background
</LeafButton>
);
export default Button;

View File

@@ -1,12 +0,0 @@
import React from 'react';
const PurpleBackground: React.FC<any> = ({ attributes, children }) => (
<span
{...attributes}
style={{ backgroundColor: 'purple' }}
>
{children}
</span>
);
export default PurpleBackground;

View File

@@ -1,8 +0,0 @@
import Button from './Button';
import Leaf from './Leaf';
export default {
name: 'purple-background',
Button,
Leaf,
};

View File

@@ -1,64 +0,0 @@
import React, { useEffect } from 'react';
import { Redirect } from 'react-router-dom';
// As this is the demo project, we import our dependencies from the `src` directory.
import DefaultTemplate from '../../../../../src/admin/components/templates/Default';
import Button from '../../../../../src/admin/components/elements/Button';
import Eyebrow from '../../../../../src/admin/components/elements/Eyebrow';
import { AdminView } from '../../../../../src/config/types';
import { useStepNav } from '../../../../../src/admin/components/elements/StepNav';
import { useConfig } from '../../../../../src/admin/components/utilities/Config';
import Meta from '../../../../../src/admin/components/utilities/Meta';
// In your projects, you can import as follows:
// import { DefaultTemplate } from 'payload/components/templates';
// import { Button, Eyebrow } from 'payload/components/elements';
// import { AdminView } from 'payload/config';
// import { useStepNav } from 'payload/components/hooks';
// import { useConfig, Meta } from 'payload/components/utilities';
const CustomDefaultRoute: AdminView = ({ user, canAccessAdmin }) => {
const { routes: { admin: adminRoute } } = useConfig();
const { setStepNav } = useStepNav();
// This effect will only run one time and will allow us
// to set the step nav to display our custom route name
useEffect(() => {
setStepNav([
{
label: 'Custom Route with Default Template',
},
]);
}, [setStepNav]);
// If an unauthorized user tries to navigate straight to this page,
// Boot 'em out
if (!user || (user && !canAccessAdmin)) {
return (
<Redirect to={`${adminRoute}/unauthorized`} />
);
}
return (
<DefaultTemplate>
<Meta
title="Custom Route with Default Template"
description="Building custom routes into Payload is easy."
keywords="Custom React Components, Payload, CMS"
/>
<Eyebrow />
<h1>Custom Route</h1>
<p>Here is a custom route that was added in the Payload config. It uses the Default Template, so the sidebar is rendered.</p>
<Button
el="link"
to={`${adminRoute}`}
buttonStyle="secondary"
>
Go to Dashboard
</Button>
</DefaultTemplate>
);
};
export default CustomDefaultRoute;

View File

@@ -1,12 +0,0 @@
// As this is the demo folder, we import Payload SCSS functions relatively.
@import '../../../../../scss';
// In your own projects, you'd import as follows:
// @import '~payload/scss';
.custom-minimal-route {
&__login-btn {
margin-right: base(.5);
}
}

View File

@@ -1,42 +0,0 @@
import React from 'react';
// As this is the demo project, we import our dependencies from the `src` directory.
import MinimalTemplate from '../../../../../src/admin/components/templates/Minimal';
import Button from '../../../../../src/admin/components/elements/Button';
import { useConfig } from '../../../../../src/admin/components/utilities/Config';
// In your projects, you can import as follows:
// import { MinimalTemplate } from 'payload/components/templates';
// import { Button } from 'payload/components/elements';
// import { useConfig } from 'payload/components/utilities';
import './index.scss';
const baseClass = 'custom-minimal-route';
const CustomMinimalRoute: React.FC = () => {
const { routes: { admin: adminRoute } } = useConfig();
return (
<MinimalTemplate className={baseClass}>
<h1>Custom Route</h1>
<p>Here is a custom route that was added in the Payload config.</p>
<Button
className={`${baseClass}__login-btn`}
el="link"
to={`${adminRoute}/login`}
>
Go to Login
</Button>
<Button
el="link"
to={`${adminRoute}`}
buttonStyle="secondary"
>
Go to Dashboard
</Button>
</MinimalTemplate>
);
};
export default CustomMinimalRoute;

View File

@@ -1,286 +0,0 @@
import path from 'path';
import { mapAsync } from '../../src/utilities/mapAsync';
import { devUser } from '../credentials';
import { buildConfigWithDefaults } from '../buildConfigWithDefaults';
import AfterDashboard from './components/AfterDashboard';
import CustomMinimalRoute from './components/views/CustomMinimal';
import CustomDefaultRoute from './components/views/CustomDefault';
import BeforeLogin from './components/BeforeLogin';
import AfterNavLinks from './components/AfterNavLinks';
import { globalSlug, slug } from './shared';
import Logout from './components/Logout';
import DemoUIFieldField from './components/DemoUIField/Field';
import DemoUIFieldCell from './components/DemoUIField/Cell';
export interface Post {
id: string;
title: string;
description: string;
createdAt: Date;
updatedAt: Date;
}
export default buildConfigWithDefaults({
admin: {
css: path.resolve(__dirname, 'styles.scss'),
components: {
// providers: [CustomProvider, CustomProvider],
routes: [
{
path: '/custom-minimal-route',
Component: CustomMinimalRoute,
},
{
path: '/custom-default-route',
Component: CustomDefaultRoute,
},
],
afterDashboard: [
AfterDashboard,
],
beforeLogin: [
BeforeLogin,
],
logout: {
Button: Logout,
},
afterNavLinks: [
AfterNavLinks,
],
views: {
// Dashboard: CustomDashboardView,
// Account: CustomAccountView,
},
},
},
i18n: {
resources: {
en: {
general: {
dashboard: 'Home',
},
},
},
},
collections: [
{
slug: 'users',
auth: true,
fields: [],
},
{
slug: 'hidden-collection',
admin: {
hidden: () => true,
},
fields: [
{
name: 'title',
type: 'text',
},
],
},
{
slug,
labels: {
singular: {
en: 'Post en',
es: 'Post es',
},
plural: {
en: 'Posts en',
es: 'Posts es',
},
},
admin: {
description: { en: 'Description en', es: 'Description es' },
listSearchableFields: ['title', 'description', 'number'],
group: { en: 'One', es: 'Una' },
useAsTitle: 'title',
defaultColumns: ['id', 'number', 'title', 'description', 'demoUIField'],
},
fields: [
{
name: 'title',
label: {
en: 'Title en',
es: 'Title es',
},
type: 'text',
},
{
name: 'description',
type: 'text',
},
{
name: 'number',
type: 'number',
},
{
name: 'richText',
type: 'richText',
admin: {
elements: [
'relationship',
],
},
},
{
type: 'ui',
name: 'demoUIField',
label: { en: 'Demo UI Field', de: 'Demo UI Field de' },
admin: {
components: {
Field: DemoUIFieldField,
Cell: DemoUIFieldCell,
},
},
},
],
},
{
slug: 'group-one-collection-ones',
admin: {
group: { en: 'One', es: 'Una' },
},
fields: [
{
name: 'title',
type: 'text',
},
],
},
{
slug: 'group-one-collection-twos',
admin: {
group: { en: 'One', es: 'Una' },
},
fields: [
{
name: 'title',
type: 'text',
},
],
},
{
slug: 'group-two-collection-ones',
admin: {
group: 'Two',
},
fields: [
{
name: 'title',
type: 'text',
},
],
},
{
slug: 'group-two-collection-twos',
admin: {
group: 'Two',
},
fields: [
{
name: 'title',
type: 'text',
},
],
},
{
slug: 'geo',
fields: [
{
name: 'point',
type: 'point',
},
],
},
],
globals: [
{
slug: 'hidden-global',
admin: {
hidden: () => true,
},
fields: [
{
name: 'title',
type: 'text',
},
],
},
{
slug: globalSlug,
label: {
en: 'Global en',
es: 'Global es',
},
admin: {
group: 'Group',
},
fields: [
{
name: 'title',
type: 'text',
},
],
},
{
slug: 'group-globals-one',
admin: {
group: 'Group',
},
fields: [
{
name: 'title',
type: 'text',
},
],
},
{
slug: 'group-globals-two',
admin: {
group: 'Group',
},
fields: [
{
name: 'title',
type: 'text',
},
],
},
],
onInit: async (payload) => {
await payload.create({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
});
await mapAsync([...Array(11)], async () => {
await payload.create({
collection: slug,
data: {
title: 'title',
description: 'description',
},
});
});
await payload.create({
collection: 'geo',
data: {
point: [7, -7],
},
});
await payload.create({
collection: 'geo',
data: {
point: [5, -5],
},
});
},
});

View File

@@ -1,757 +0,0 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import qs from 'qs';
import payload from '../../src';
import { AdminUrlUtil } from '../helpers/adminUrlUtil';
import { initPayloadE2E } from '../helpers/configHelpers';
import { saveDocAndAssert, saveDocHotkeyAndAssert } from '../helpers';
import type { Post } from './config';
import { globalSlug, slug } from './shared';
import { mapAsync } from '../../src/utilities/mapAsync';
import wait from '../../src/utilities/wait';
const { afterEach, beforeAll, beforeEach, describe } = test;
const title = 'title';
const description = 'description';
let url: AdminUrlUtil;
let serverURL: string;
describe('admin', () => {
let page: Page;
beforeAll(async ({ browser }) => {
serverURL = (await initPayloadE2E(__dirname)).serverURL;
await clearDocs(); // Clear any seeded data from onInit
url = new AdminUrlUtil(serverURL, slug);
const context = await browser.newContext();
page = await context.newPage();
});
afterEach(async () => {
await clearDocs();
// clear preferences
await payload.preferences.Model.deleteMany();
});
describe('Nav', () => {
test('should nav to collection - sidebar', async () => {
await page.goto(url.admin);
const collectionLink = page.locator(`#nav-${slug}`);
await collectionLink.click();
expect(page.url()).toContain(url.list);
});
test('should nav to a global - sidebar', async () => {
await page.goto(url.admin);
await page.locator(`#nav-global-${globalSlug}`).click();
expect(page.url()).toContain(url.global(globalSlug));
});
test('should navigate to collection - card', async () => {
await page.goto(url.admin);
await page.locator(`#card-${slug}`).click();
expect(page.url()).toContain(url.list);
});
test('should collapse and expand collection groups', async () => {
await page.goto(url.admin);
const navGroup = page.locator('#nav-group-One .nav-group__toggle');
const link = page.locator('#nav-group-one-collection-ones');
await expect(navGroup).toContainText('One');
await expect(link).toBeVisible();
await navGroup.click();
await expect(link).toBeHidden();
await navGroup.click();
await expect(link).toBeVisible();
});
test('should collapse and expand globals groups', async () => {
await page.goto(url.admin);
const navGroup = page.locator('#nav-group-Group .nav-group__toggle');
const link = page.locator('#nav-global-group-globals-one');
await expect(navGroup).toContainText('Group');
await expect(link).toBeVisible();
await navGroup.click();
await expect(link).toBeHidden();
await navGroup.click();
await expect(link).toBeVisible();
});
test('should save nav group collapse preferences', async () => {
await page.goto(url.admin);
const navGroup = page.locator('#nav-group-One .nav-group__toggle');
await navGroup.click();
await page.goto(url.admin);
const link = page.locator('#nav-group-one-collection-ones');
await expect(link).toBeHidden();
});
test('breadcrumbs - from list to dashboard', async () => {
await page.goto(url.list);
await page.locator('.step-nav a[href="/admin"]').click();
expect(page.url()).toContain(url.admin);
});
test('breadcrumbs - from document to collection', async () => {
const { id } = await createPost();
await page.goto(url.edit(id));
await page.locator(`.step-nav >> text=${slug}`).click();
expect(page.url()).toContain(url.list);
});
test('should not show hidden collections and globals', async () => {
await page.goto(url.admin);
// nav menu
await expect(page.locator('#nav-hidden-collection')).toBeHidden();
await expect(page.locator('#nav-hidden-global')).toBeHidden();
// dashboard
await expect(page.locator('#card-hidden-collection')).toBeHidden();
await expect(page.locator('#card-hidden-global')).toBeHidden();
// routing
await page.goto(url.collection('hidden-collection'));
await expect(page.locator('.not-found')).toContainText('Nothing found');
await page.goto(url.global('hidden-global'));
await expect(page.locator('.not-found')).toContainText('Nothing found');
});
});
describe('CRUD', () => {
test('should create', async () => {
await page.goto(url.create);
await page.locator('#field-title').fill(title);
await page.locator('#field-description').fill(description);
await saveDocAndAssert(page);
await expect(page.locator('#field-title')).toHaveValue(title);
await expect(page.locator('#field-description')).toHaveValue(description);
});
test('should read existing', async () => {
const { id } = await createPost();
await page.goto(url.edit(id));
await expect(page.locator('#field-title')).toHaveValue(title);
await expect(page.locator('#field-description')).toHaveValue(description);
});
test('should update existing', async () => {
const { id } = await createPost();
await page.goto(url.edit(id));
const newTitle = 'new title';
const newDesc = 'new description';
await page.locator('#field-title').fill(newTitle);
await page.locator('#field-description').fill(newDesc);
await saveDocAndAssert(page);
await expect(page.locator('#field-title')).toHaveValue(newTitle);
await expect(page.locator('#field-description')).toHaveValue(newDesc);
});
test('should save using hotkey', async () => {
const { id } = await createPost();
await page.goto(url.edit(id));
const newTitle = 'new title';
await page.locator('#field-title').fill(newTitle);
await saveDocHotkeyAndAssert(page);
await expect(page.locator('#field-title')).toHaveValue(newTitle);
});
test('should delete existing', async () => {
const { id, ...post } = await createPost();
await page.goto(url.edit(id));
await page.locator('#action-delete').click();
await page.locator('#confirm-delete').click();
await expect(page.locator(`text=Post en "${post.title}" successfully deleted.`)).toBeVisible();
expect(page.url()).toContain(url.list);
});
test('should bulk delete', async () => {
createPost();
createPost();
createPost();
await page.goto(url.list);
await page.locator('input#select-all').check();
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('input#select-all').check();
await page.locator('.edit-many__toggle').click();
await page.locator('.field-select .rs__control').click();
const options = page.locator('.rs__option');
const titleOption = options.locator('text=Title en');
await expect(titleOption).toHaveText('Title en');
await titleOption.click();
const titleInput = 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));
await page.locator('#field-title').fill(title);
await saveDocAndAssert(page);
await expect(page.locator('#field-title')).toHaveValue(title);
});
});
describe('i18n', () => {
test('should allow changing language', async () => {
await page.goto(url.account);
const field = page.locator('.account__language .react-select');
await field.click();
const options = page.locator('.rs__option');
await options.locator('text=Español').click();
await expect(page.locator('.step-nav')).toContainText('Tablero');
await field.click();
await options.locator('text=English').click();
await field.click();
await expect(page.locator('.form-submit .btn')).toContainText('Save');
});
test('should allow custom translation', async () => {
await page.goto(url.account);
await expect(page.locator('.step-nav')).toContainText('Home');
});
});
describe('list view', () => {
const tableRowLocator = 'table >> tbody >> tr';
beforeEach(async () => {
await page.goto(url.list);
});
describe('filtering', () => {
test('search by id', async () => {
const { id } = await createPost();
await page.locator('.search-filter__input').fill(id);
const tableItems = page.locator(tableRowLocator);
await expect(tableItems).toHaveCount(1);
});
test('search by title or description', async () => {
await createPost({
title: 'find me',
description: 'this is fun',
});
await page.locator('.search-filter__input').fill('find me');
await expect(page.locator(tableRowLocator)).toHaveCount(1);
await page.locator('.search-filter__input').fill('this is fun');
await expect(page.locator(tableRowLocator)).toHaveCount(1);
});
test('toggle columns', async () => {
const columnCountLocator = 'table >> thead >> tr >> th';
await createPost();
await page.locator('.list-controls__toggle-columns').click();
// wait until the column toggle UI is visible and fully expanded
await expect(page.locator('.list-controls__columns.rah-static--height-auto')).toBeVisible();
const numberOfColumns = await page.locator(columnCountLocator).count();
await expect(page.locator('table >> thead >> tr >> th:nth-child(2)')).toHaveText('ID');
const idButton = page.locator('.column-selector >> text=ID');
// Remove ID column
await idButton.click();
// wait until .cell-id is not present on the page:
await page.locator('.cell-id').waitFor({ state: 'detached' });
await expect(page.locator(columnCountLocator)).toHaveCount(numberOfColumns - 1);
await expect(page.locator('table >> thead >> tr >> th:nth-child(2)')).toHaveText('Number');
// Add back ID column
await idButton.click();
await expect(page.locator('.cell-id')).toBeVisible();
await expect(page.locator(columnCountLocator)).toHaveCount(numberOfColumns);
await expect(page.locator('table >> thead >> tr >> th:nth-child(2)')).toHaveText('ID');
});
test('2nd cell is a link', async () => {
const { id } = await createPost();
const linkCell = 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();
// wait until the column toggle UI is visible and fully expanded
await expect(page.locator('.list-controls__columns.rah-static--height-auto')).toBeVisible();
// toggle off the ID column
page.locator('.column-selector >> text=ID').click();
// wait until .cell-id is not present on the page:
await page.locator('.cell-id').waitFor({ state: 'detached' });
// recheck that the 2nd cell is still a link
await expect(linkCell).toHaveAttribute('href', `/admin/collections/posts/${id}`);
});
test('filter rows', async () => {
const { id } = await createPost({ title: 'post1' });
await createPost({ title: 'post2' });
// open the column controls
await page.locator('.list-controls__toggle-columns').click();
// wait until the column toggle UI is visible and fully expanded
await expect(page.locator('.list-controls__columns.rah-static--height-auto')).toBeVisible();
// ensure the ID column is active
const idButton = page.locator('.column-selector >> text=ID');
const buttonClasses = await idButton.getAttribute('class');
if (buttonClasses && !buttonClasses.includes('column-selector__column--active')) {
await idButton.click();
await expect(page.locator(tableRowLocator).first().locator('.cell-id')).toBeVisible();
}
await expect(page.locator(tableRowLocator)).toHaveCount(2);
await page.locator('.list-controls__toggle-where').click();
// wait until the filter UI is visible and fully expanded
await expect(page.locator('.list-controls__where.rah-static--height-auto')).toBeVisible();
await page.locator('.where-builder__add-first-filter').click();
const operatorField = page.locator('.condition__operator');
const valueField = page.locator('.condition__value >> input');
await operatorField.click();
const dropdownOptions = operatorField.locator('.rs__option');
await dropdownOptions.locator('text=equals').click();
await valueField.fill(id);
await expect(page.locator(tableRowLocator)).toHaveCount(1);
const firstId = await page.locator(tableRowLocator).first().locator('.cell-id').innerText();
expect(firstId).toEqual(id);
// Remove filter
await page.locator('.condition__actions-remove').click();
await expect(page.locator(tableRowLocator)).toHaveCount(2);
});
test('should accept where query from valid URL where parameter', async () => {
await createPost({ title: 'post1' });
await createPost({ title: 'post2' });
await page.goto(`${url.list}?limit=10&page=1&where[or][0][and][0][title][equals]=post1`);
await expect(page.locator('.react-select--single-value').first()).toContainText('Title en');
await expect(page.locator(tableRowLocator)).toHaveCount(1);
});
test('should accept transformed where query from invalid URL where parameter', async () => {
await createPost({ title: 'post1' });
await createPost({ title: 'post2' });
// [title][equals]=post1 should be getting transformed into a valid where[or][0][and][0][title][equals]=post1
await page.goto(`${url.list}?limit=10&page=1&where[title][equals]=post1`);
await expect(page.locator('.react-select--single-value').first()).toContainText('Title en');
await expect(page.locator(tableRowLocator)).toHaveCount(1);
});
test('should accept where query from complex, valid URL where parameter using the near operator', async () => {
// We have one point collection with the point [5,-5] and one with [7,-7]. This where query should kick out the [5,-5] point
await page.goto(`${new AdminUrlUtil(serverURL, 'geo').list}?limit=10&page=1&where[or][0][and][0][point][near]=6,-7,200000`);
await expect(page.getByPlaceholder('Enter a value')).toHaveValue('6,-7,200000');
await expect(page.locator(tableRowLocator)).toHaveCount(1);
});
test('should accept transformed where query from complex, invalid URL where parameter using the near operator', async () => {
// We have one point collection with the point [5,-5] and one with [7,-7]. This where query should kick out the [5,-5] point
await page.goto(`${new AdminUrlUtil(serverURL, 'geo').list}?limit=10&page=1&where[point][near]=6,-7,200000`);
await expect(page.getByPlaceholder('Enter a value')).toHaveValue('6,-7,200000');
await expect(page.locator(tableRowLocator)).toHaveCount(1);
});
test('should accept where query from complex, valid URL where parameter using the within operator', async () => {
type Point = [number, number];
const polygon: Point[] = [
[3.5, -3.5], // bottom-left
[3.5, -6.5], // top-left
[6.5, -6.5], // top-right
[6.5, -3.5], // bottom-right
[3.5, -3.5], // back to starting point to close the polygon
];
const whereQueryJSON = {
point: {
within: {
type: 'Polygon',
coordinates: [polygon],
},
},
};
const whereQuery = qs.stringify({
...({ where: whereQueryJSON }),
}, {
addQueryPrefix: false,
});
// We have one point collection with the point [5,-5] and one with [7,-7]. This where query should kick out the [7,-7] point, as it's not within the polygon
await page.goto(`${new AdminUrlUtil(serverURL, 'geo').list}?limit=10&page=1&${whereQuery}`);
await expect(page.getByPlaceholder('Enter a value')).toHaveValue('[object Object]');
await expect(page.locator(tableRowLocator)).toHaveCount(1);
});
});
describe('table columns', () => {
const reorderColumns = async () => {
// open the column controls
await page.locator('.list-controls__toggle-columns').click();
// wait until the column toggle UI is visible and fully expanded
await expect(page.locator('.list-controls__columns.rah-static--height-auto')).toBeVisible();
const numberBoundingBox = await page.locator('.column-selector >> text=Number').boundingBox();
const idBoundingBox = await page.locator('.column-selector >> text=ID').boundingBox();
if (!numberBoundingBox || !idBoundingBox) return;
// drag the "number" column to the left of the "ID" column
await page.mouse.move(numberBoundingBox.x + 2, numberBoundingBox.y + 2, { steps: 10 });
await page.mouse.down();
await wait(300);
await page.mouse.move(idBoundingBox.x - 2, idBoundingBox.y - 2, { steps: 10 });
await page.mouse.up();
// ensure the "number" column is now first
await expect(page.locator('.list-controls .column-selector .column-selector__column').first()).toHaveText('Number');
await expect(page.locator('table thead tr th').nth(1)).toHaveText('Number');
// TODO: This wait makes sure the preferences are actually saved. Just waiting for the UI to update is not enough. We should replace this wait
await wait(1000);
};
test('should drag to reorder columns and save to preferences', async () => {
await createPost();
await reorderColumns();
// reload to ensure the preferred order was stored in the database
await page.reload();
await expect(page.locator('.list-controls .column-selector .column-selector__column').first()).toHaveText('Number');
await expect(page.locator('table thead tr th').nth(1)).toHaveText('Number');
});
test('should render drawer columns in order', async () => {
// Re-order columns like done in the previous test
await createPost();
await reorderColumns();
await page.reload();
await createPost();
await page.goto(url.create);
// Open the drawer
await page.locator('.rich-text .list-drawer__toggler').click();
const listDrawer = page.locator('[id^=list-drawer_1_]');
await expect(listDrawer).toBeVisible();
const collectionSelector = page.locator('[id^=list-drawer_1_] .list-drawer__select-collection.react-select');
// 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 = page.locator('[id^=list-drawer_1_] .list-controls__toggle-columns');
await columnSelector.click();
// wait until the column toggle UI is visible and fully expanded
await expect(page.locator('.list-controls__columns.rah-static--height-auto')).toBeVisible();
// ensure that the columns are in the correct order
await expect(page.locator('[id^=list-drawer_1_] .list-controls .column-selector .column-selector__column').first()).toHaveText('Number');
});
test('should retain preferences when changing drawer collections', async () => {
await page.goto(url.create);
// Open the drawer
await page.locator('.rich-text .list-drawer__toggler').click();
const listDrawer = page.locator('[id^=list-drawer_1_]');
await expect(listDrawer).toBeVisible();
const collectionSelector = page.locator('[id^=list-drawer_1_] .list-drawer__select-collection.react-select');
const columnSelector = page.locator('[id^=list-drawer_1_] .list-controls__toggle-columns');
// open the column controls
await columnSelector.click();
// wait until the column toggle UI is visible and fully expanded
await expect(page.locator('.list-controls__columns.rah-static--height-auto')).toBeVisible();
// deselect the "id" column
await page.locator('[id^=list-drawer_1_] .list-controls .column-selector .column-selector__column >> text=ID').click();
// 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();
// deselect the "number" column
await page.locator('[id^=list-drawer_1_] .list-controls .column-selector .column-selector__column >> text=Number').click();
// select the "User" collection again
await collectionSelector.click();
await page.locator('[id^=list-drawer_1_] .list-drawer__select-collection.react-select .rs__option >> text="User"').click();
// ensure that the "id" column is still deselected
await expect(page.locator('[id^=list-drawer_1_] .list-controls .column-selector .column-selector__column').first()).not.toHaveClass('column-selector__column--active');
// select the "Post en" collection again
await collectionSelector.click();
await page.locator('[id^=list-drawer_1_] .list-drawer__select-collection.react-select .rs__option >> text="Post en"').click();
// ensure that the "number" column is still deselected
await expect(page.locator('[id^=list-drawer_1_] .list-controls .column-selector .column-selector__column').first()).not.toHaveClass('column-selector__column--active');
});
test('should render custom table cell component', async () => {
await createPost();
await page.goto(url.list);
await expect(page.locator('table >> thead >> tr >> th >> text=Demo UI Field')).toBeVisible();
});
});
describe('multi-select', () => {
beforeEach(async () => {
await mapAsync([...Array(3)], async () => {
await createPost();
});
});
test('should select multiple rows', async () => {
const selectAll = page.locator('.custom-checkbox:has(#select-all)');
await page.locator('.row-1 .cell-_select input').check();
const indeterminateSelectAll = selectAll.locator('.custom-checkbox__icon.partial');
expect(indeterminateSelectAll).toBeDefined();
await selectAll.locator('input').click();
const emptySelectAll = selectAll.locator('.custom-checkbox__icon:not(.check):not(.partial)');
await expect(emptySelectAll).toHaveCount(0);
await selectAll.locator('input').click();
const checkSelectAll = selectAll.locator('.custom-checkbox__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 .cell-_select input').check();
// delete button should be present
await expect(page.locator('#confirm-delete')).toHaveCount(1);
await page.locator('.row-2 .cell-_select input').check();
await page.locator('.delete-documents__toggle').click();
await page.locator('#confirm-delete').click();
await expect(await page.locator('.cell-_select')).toHaveCount(1);
});
});
describe('pagination', () => {
beforeAll(async () => {
await mapAsync([...Array(11)], async () => {
await createPost();
});
});
test('should paginate', async () => {
const pageInfo = page.locator('.collection-list__page-info');
const perPage = page.locator('.per-page');
const paginator = page.locator('.paginator');
const tableItems = page.locator(tableRowLocator);
await expect(tableItems).toHaveCount(10);
await expect(pageInfo).toHaveText('1-10 of 11');
await expect(perPage).toContainText('Per Page: 10');
// Forward one page and back using numbers
await paginator.locator('button').nth(1).click();
expect(page.url()).toContain('page=2');
await expect(tableItems).toHaveCount(1);
await paginator.locator('button').nth(0).click();
expect(page.url()).toContain('page=1');
await expect(tableItems).toHaveCount(10);
});
});
describe('custom css', () => {
test('should see custom css in admin UI', async () => {
await page.goto(url.admin);
const navControls = page.locator('.nav__controls');
await expect(navControls).toHaveCSS('font-family', 'monospace');
});
});
// TODO: Troubleshoot flaky suite
describe.skip('sorting', () => {
beforeAll(async () => {
await createPost();
await createPost();
});
test('should sort', async () => {
const upChevron = page.locator('#heading-id .sort-column__asc');
const downChevron = page.locator('#heading-id .sort-column__desc');
const firstId = await page.locator('.row-1 .cell-id').innerText();
const secondId = await page.locator('.row-2 .cell-id').innerText();
await upChevron.click({ delay: 200 });
// Order should have swapped
expect(await page.locator('.row-1 .cell-id').innerText()).toEqual(secondId);
expect(await page.locator('.row-2 .cell-id').innerText()).toEqual(firstId);
await downChevron.click({ delay: 200 });
// Swap back
expect(await page.locator('.row-1 .cell-id').innerText()).toEqual(firstId);
expect(await page.locator('.row-2 .cell-id').innerText()).toEqual(secondId);
});
});
describe('i18n', () => {
test('should display translated collections and globals config options', async () => {
await page.goto(url.list);
// collection label
await expect(page.locator('#nav-posts')).toContainText('Posts en');
// global label
await expect(page.locator('#nav-global-global')).toContainText('Global en');
// view description
await expect(page.locator('.view-description')).toContainText('Description en');
});
test('should display translated field titles', async () => {
await createPost();
// column controls
await page.locator('.list-controls__toggle-columns').click();
await expect(page.locator('.column-selector__column >> text=Title en')).toHaveText('Title en');
// filters
await page.locator('.list-controls__toggle-where').click();
await page.locator('.where-builder__add-first-filter').click();
await page.locator('.condition__field .rs__control').click();
const options = page.locator('.rs__option');
await expect(options.locator('text=Title en')).toHaveText('Title en');
// list columns
await expect(page.locator('#heading-title .sort-column__label')).toHaveText('Title en');
await expect(page.locator('.search-filter input')).toHaveAttribute('placeholder', /(Title en)/);
});
test('should use fallback language on field titles', async () => {
// change language German
await page.goto(url.account);
await page.locator('.account__language .react-select').click();
const languageSelect = page.locator('.rs__option');
// text field does not have a 'de' label
await languageSelect.locator('text=Deutsch').click();
await page.goto(url.list);
await page.locator('.list-controls__toggle-columns').click();
// expecting the label to fall back to english as default fallbackLng
await expect(page.locator('.column-selector__column >> text=Title en')).toHaveText('Title en');
});
});
});
});
async function createPost(overrides?: Partial<Post>): Promise<Post> {
return payload.create({
collection: slug,
data: {
title,
description,
...overrides,
},
});
}
async function clearDocs(): Promise<void> {
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

@@ -1,140 +0,0 @@
/* tslint:disable */
/* eslint-disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
export interface Config {
collections: {
users: User;
'hidden-collection': HiddenCollection;
posts: Post;
'group-one-collection-ones': GroupOneCollectionOne;
'group-one-collection-twos': GroupOneCollectionTwo;
'group-two-collection-ones': GroupTwoCollectionOne;
'group-two-collection-twos': GroupTwoCollectionTwo;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
globals: {
'hidden-global': HiddenGlobal;
global: Global;
'group-globals-one': GroupGlobalsOne;
'group-globals-two': GroupGlobalsTwo;
};
}
export interface User {
id: string;
updatedAt: string;
createdAt: string;
email: string;
resetPasswordToken?: string;
resetPasswordExpiration?: string;
salt?: string;
hash?: string;
loginAttempts?: number;
lockUntil?: string;
password?: string;
}
export interface HiddenCollection {
id: string;
title?: string;
updatedAt: string;
createdAt: string;
}
export interface Post {
id: string;
title?: string;
description?: string;
number?: number;
richText?: {
[k: string]: unknown;
}[];
updatedAt: string;
createdAt: string;
}
export interface GroupOneCollectionOne {
id: string;
title?: string;
updatedAt: string;
createdAt: string;
}
export interface GroupOneCollectionTwo {
id: string;
title?: string;
updatedAt: string;
createdAt: string;
}
export interface GroupTwoCollectionOne {
id: string;
title?: string;
updatedAt: string;
createdAt: string;
}
export interface GroupTwoCollectionTwo {
id: string;
title?: string;
updatedAt: string;
createdAt: string;
}
export interface PayloadPreference {
id: string;
user: {
value: string | User;
relationTo: 'users';
};
key?: string;
value?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
export interface PayloadMigration {
id: string;
name?: string;
batch?: number;
schema?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
export interface HiddenGlobal {
id: string;
title?: string;
updatedAt?: string;
createdAt?: string;
}
export interface Global {
id: string;
title?: string;
updatedAt?: string;
createdAt?: string;
}
export interface GroupGlobalsOne {
id: string;
title?: string;
updatedAt?: string;
createdAt?: string;
}
export interface GroupGlobalsTwo {
id: string;
title?: string;
updatedAt?: string;
createdAt?: string;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 B

View File

@@ -1,2 +0,0 @@
export const slug = 'posts';
export const globalSlug = 'global';

View File

@@ -1,7 +0,0 @@
.nav__controls {
font-family: monospace;
background-image: url('/placeholder.png');
}
.nav__controls:before {
content: 'custom-css';
}

View File

@@ -1,54 +0,0 @@
import { buildConfigWithDefaults } from '../buildConfigWithDefaults';
import { devUser } from '../credentials';
export default buildConfigWithDefaults({
collections: [
{
slug: 'arrays',
fields: [
{
name: 'arrayOfFields',
type: 'array',
admin: {
initCollapsed: true,
},
fields: [
{
type: 'text',
name: 'required',
required: true,
},
{
type: 'text',
name: 'optional',
},
{
name: 'innerArrayOfFields',
type: 'array',
fields: [
{
type: 'text',
name: 'required',
required: true,
},
{
type: 'text',
name: 'optional',
},
],
},
],
},
],
},
],
onInit: async (payload) => {
await payload.create({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
});
},
});

View File

@@ -1,106 +0,0 @@
import { initPayloadTest } from '../helpers/configHelpers';
import payload from '../../src';
import configPromise from './config';
let collection: string;
describe('array-update', () => {
beforeAll(async () => {
const config = await configPromise;
collection = config.collections[0]?.slug;
await initPayloadTest({ __dirname });
});
afterAll(async () => {
if (typeof payload.db.destroy === 'function') {
await payload.db.destroy(payload);
}
});
it('should persist existing array-based data while updating and passing row ID', async () => {
const originalText = 'some optional text';
const doc = await payload.create({
collection,
data: {
arrayOfFields: [
{
required: 'a required field here',
optional: originalText,
},
{
required: 'another required field here',
optional: 'this is cool',
},
],
},
});
const arrayWithExistingValues = [...doc.arrayOfFields];
const updatedText = 'this is some new text for the first item in array';
arrayWithExistingValues[0] = {
id: arrayWithExistingValues[0].id,
required: updatedText,
};
const updatedDoc = await payload.update({
id: doc.id,
collection,
data: {
arrayOfFields: arrayWithExistingValues,
},
});
expect(updatedDoc.arrayOfFields?.[0]).toMatchObject({
required: updatedText,
optional: originalText,
});
});
it('should disregard existing array-based data while updating and NOT passing row ID', async () => {
const updatedText = 'here is some new text';
const secondArrayItem = {
required: 'test',
optional: 'optional test',
};
const doc = await payload.create({
collection,
data: {
arrayOfFields: [
{
required: 'a required field here',
optional: 'some optional text',
},
secondArrayItem,
],
},
});
const updatedDoc = await payload.update({
id: doc.id,
collection,
data: {
arrayOfFields: [
{
required: updatedText,
},
{
id: doc.arrayOfFields?.[1].id,
required: doc.arrayOfFields?.[1].required as string,
// NOTE - not passing optional field. It should persist
// because we're passing ID
},
],
},
});
expect(updatedDoc.arrayOfFields?.[0].required).toStrictEqual(updatedText);
expect(updatedDoc.arrayOfFields?.[0].optional).toBeUndefined();
expect(updatedDoc.arrayOfFields?.[1]).toMatchObject(secondArrayItem);
});
});

View File

@@ -1,36 +0,0 @@
/* tslint:disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* 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` "arrays".
*/
export interface Array {
id: string;
array: {
required: string;
optional?: string;
id?: string;
}[];
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User {
id: string;
email?: string;
resetPasswordToken?: string;
resetPasswordExpiration?: string;
loginAttempts?: number;
lockUntil?: string;
createdAt: string;
updatedAt: string;
}

View File

@@ -1,29 +0,0 @@
import React, { useEffect, useState } from 'react';
import { useAuth } from '../../src/admin/components/utilities/Auth';
import { User } from '../../src/auth';
import { UIField } from '../../src/fields/config/types';
export const AuthDebug: React.FC<UIField> = () => {
const [state, setState] = useState<User | null | undefined>();
const { user } = useAuth();
useEffect(() => {
const fetchUser = async () => {
const userRes = await fetch(`/api/users/${user?.id}`)?.then((res) => res.json());
setState(userRes);
};
fetchUser();
}, [user]);
return (
<div id="auth-debug">
<div id="use-auth-result">
{user?.custom as string}
</div>
<div id="users-api-result">
{state?.custom as string}
</div>
</div>
);
};

View File

@@ -1,202 +0,0 @@
import { v4 as uuid } from 'uuid';
import { mapAsync } from '../../src/utilities/mapAsync';
import { buildConfigWithDefaults } from '../buildConfigWithDefaults';
import { devUser } from '../credentials';
import { AuthDebug } from './AuthDebug';
export const slug = 'users';
export const namedSaveToJWTValue = 'namedSaveToJWT value';
export const saveToJWTKey = 'x-custom-jwt-property-name';
export default buildConfigWithDefaults({
admin: {
user: 'users',
autoLogin: {
email: 'test@example.com',
password: 'test',
prefillOnly: true,
},
},
collections: [
{
slug,
auth: {
tokenExpiration: 7200, // 2 hours
verify: false,
maxLoginAttempts: 2,
lockTime: 600 * 1000, // lock time in ms
useAPIKey: true,
depth: 0,
cookies: {
secure: false,
sameSite: 'lax',
domain: undefined,
},
},
fields: [
{
name: 'roles',
label: 'Role',
type: 'select',
options: ['admin', 'editor', 'moderator', 'user', 'viewer'],
defaultValue: 'user',
required: true,
saveToJWT: true,
hasMany: true,
},
{
name: 'namedSaveToJWT',
type: 'text',
defaultValue: namedSaveToJWTValue,
saveToJWT: saveToJWTKey,
},
{
name: 'group',
type: 'group',
fields: [
{
name: 'liftedSaveToJWT',
type: 'text',
saveToJWT: 'x-lifted-from-group',
defaultValue: 'lifted from group',
},
],
},
{
name: 'groupSaveToJWT',
type: 'group',
saveToJWT: 'x-group',
fields: [
{
name: 'saveToJWTString',
type: 'text',
saveToJWT: 'x-test',
defaultValue: 'nested property',
},
{
name: 'saveToJWTFalse',
type: 'text',
saveToJWT: false,
defaultValue: 'nested property',
},
],
},
{
type: 'tabs',
tabs: [
{
name: 'saveToJWTTab',
saveToJWT: true,
fields: [
{
name: 'test',
type: 'text',
saveToJWT: 'x-field',
defaultValue: 'yes',
},
],
},
{
name: 'tabSaveToJWTString',
saveToJWT: 'tab-test',
fields: [
{
name: 'includedByDefault',
type: 'text',
defaultValue: 'yes',
},
],
},
{
label: 'No Name',
fields: [
{
name: 'tabLiftedSaveToJWT',
type: 'text',
saveToJWT: true,
defaultValue: 'lifted from unnamed tab',
},
{
name: 'unnamedTabSaveToJWTString',
type: 'text',
saveToJWT: 'x-tab-field',
defaultValue: 'text',
},
{
name: 'unnamedTabSaveToJWTFalse',
type: 'text',
saveToJWT: false,
defaultValue: 'false',
},
],
},
],
},
{
name: 'custom',
label: 'Custom',
type: 'text',
},
{
name: 'authDebug',
label: 'Auth Debug',
type: 'ui',
admin: {
components: {
Field: AuthDebug,
},
},
},
],
},
{
slug: 'api-keys',
access: {
read: ({ req: { user } }) => {
if (user.collection === 'api-keys') {
return {
id: {
equals: user.id,
},
};
}
return true;
},
},
auth: {
disableLocalStrategy: true,
useAPIKey: true,
},
fields: [],
},
{
slug: 'public-users',
auth: {
verify: true,
},
fields: [],
},
],
onInit: async (payload) => {
await payload.create({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
custom: 'Hello, world!',
},
});
await mapAsync([...Array(2)], async () => {
await payload.create({
collection: 'api-keys',
data: {
apiKey: uuid(),
enableAPIKey: true,
},
});
});
},
});

View File

@@ -1,95 +0,0 @@
import { Request } from 'express';
import { Strategy } from 'passport-strategy';
import { Payload } from '../../../src/payload';
import { buildConfigWithDefaults } from '../../buildConfigWithDefaults';
export const slug = 'users';
export const strategyName = 'test-local';
export class CustomStrategy extends Strategy {
ctx: Payload;
constructor(ctx: Payload) {
super();
this.ctx = ctx;
}
authenticate(req: Request, options?: any): void {
if (!req.headers.code && !req.headers.secret) {
return this.success(null);
}
this.ctx.find({
collection: slug,
where: {
code: {
equals: req.headers.code,
},
secret: {
equals: req.headers.secret,
},
},
}).then((users) => {
if (users.docs && users.docs.length) {
const user = users.docs[0];
user.collection = slug;
user._strategy = `${slug}-${strategyName}`;
this.success(user);
} else {
this.error(null);
}
});
}
}
export default buildConfigWithDefaults({
admin: {
user: 'users',
},
collections: [
{
slug,
auth: {
disableLocalStrategy: true,
strategies: [
{
name: strategyName,
strategy: (ctx) => new CustomStrategy(ctx),
},
],
},
access: {
create: () => true,
},
fields: [
{
name: 'code',
label: 'Code',
type: 'text',
unique: true,
index: true,
},
{
name: 'secret',
label: 'Secret',
type: 'text',
},
{
name: 'name',
label: 'Name',
type: 'text',
},
{
name: 'roles',
label: 'Role',
type: 'select',
options: ['admin', 'editor', 'moderator', 'user', 'viewer'],
defaultValue: 'user',
required: true,
saveToJWT: true,
hasMany: true,
},
],
},
],
});

View File

@@ -1,55 +0,0 @@
import payload from '../../../src';
import { initPayloadTest } from '../../helpers/configHelpers';
import { slug } from './config';
require('isomorphic-fetch');
let apiUrl;
const [code, secret, name] = ['test', 'strategy', 'Tester'];
const headers = {
'Content-Type': 'application/json',
};
describe('AuthStrategies', () => {
beforeAll(async () => {
const { serverURL } = await initPayloadTest({ __dirname, init: { local: false } });
apiUrl = `${serverURL}/api`;
});
afterAll(async () => {
if (typeof payload.db.destroy === 'function') {
await payload.db.destroy(payload);
}
});
describe('create user', () => {
beforeAll(async () => {
await fetch(`${apiUrl}/${slug}`, {
body: JSON.stringify({
code,
secret,
name,
}),
headers,
method: 'post',
});
});
it('should return a logged in user from /me', async () => {
const response = await fetch(`${apiUrl}/${slug}/me`, {
headers: {
...headers,
code,
secret,
},
});
const data = await response.json();
expect(response.status).toBe(200);
expect(data.user.name).toBe(name);
});
});
});

View File

@@ -1,60 +0,0 @@
import type { Page } from '@playwright/test';
import { test, expect } from '@playwright/test';
import { AdminUrlUtil } from '../helpers/adminUrlUtil';
import { initPayloadE2E } from '../helpers/configHelpers';
import { login, saveDocAndAssert } from '../helpers';
import { slug } from './config';
/**
* TODO: Auth
* create first user
* unlock
* generate api key
* log out
*/
const { beforeAll, describe } = test;
let url: AdminUrlUtil;
describe('auth', () => {
let page: Page;
beforeAll(async ({ browser }) => {
const { serverURL } = await initPayloadE2E(__dirname);
url = new AdminUrlUtil(serverURL, slug);
const context = await browser.newContext();
page = await context.newPage();
await login({
page,
serverURL,
});
});
describe('authenticated users', () => {
test('should allow change password', async () => {
await page.goto(url.account);
await page.locator('#change-password').click();
await page.locator('#field-password').fill('password');
await page.locator('#field-confirm-password').fill('password');
await saveDocAndAssert(page);
});
test('should have up-to-date user in `useAuth` hook', async () => {
await page.goto(url.account);
await expect(await page.locator('#users-api-result')).toHaveText('Hello, world!');
await expect(await page.locator('#use-auth-result')).toHaveText('Hello, world!');
const field = await page.locator('#field-custom');
await field.fill('Goodbye, world!');
await saveDocAndAssert(page);
await expect(await page.locator('#users-api-result')).toHaveText('Goodbye, world!');
await expect(await page.locator('#use-auth-result')).toHaveText('Goodbye, world!');
});
});
});

View File

@@ -1,521 +0,0 @@
import mongoose from 'mongoose';
import jwtDecode from 'jwt-decode';
import { GraphQLClient } from 'graphql-request';
import payload from '../../src';
import { initPayloadTest } from '../helpers/configHelpers';
import { namedSaveToJWTValue, saveToJWTKey, slug } from './config';
import { devUser } from '../credentials';
import type { User } from '../../src/auth';
import configPromise from '../collections-graphql/config';
require('isomorphic-fetch');
let apiUrl;
let client: GraphQLClient;
const headers = {
'Content-Type': 'application/json',
};
const { email, password } = devUser;
describe('Auth', () => {
beforeAll(async () => {
const { serverURL } = await initPayloadTest({ __dirname, init: { local: false } });
apiUrl = `${serverURL}/api`;
const config = await configPromise;
const url = `${serverURL}${config.routes.api}${config.routes.graphQL}`;
client = new GraphQLClient(url);
});
afterAll(async () => {
if (typeof payload.db.destroy === 'function') {
await payload.db.destroy(payload);
}
});
describe('GraphQL - admin user', () => {
let token;
let user;
beforeAll(async () => {
// language=graphQL
const query = `mutation {
loginUser(email: "${devUser.email}", password: "${devUser.password}") {
token
user {
id
email
}
}
}`;
const response = await client.request(query);
user = response.loginUser.user;
token = response.loginUser.token;
});
it('should login', async () => {
expect(user.id).toBeDefined();
expect(user.email).toEqual(devUser.email);
expect(token).toBeDefined();
});
it('should have fields saved to JWT', async () => {
const decoded = jwtDecode<User>(token);
const {
email: jwtEmail,
collection,
roles,
iat,
exp,
} = decoded;
expect(jwtEmail).toBeDefined();
expect(collection).toEqual('users');
expect(Array.isArray(roles)).toBeTruthy();
expect(iat).toBeDefined();
expect(exp).toBeDefined();
});
});
describe('REST - admin user', () => {
beforeAll(async () => {
await fetch(`${apiUrl}/${slug}/first-register`, {
body: JSON.stringify({
email,
password,
}),
headers,
method: 'post',
});
});
it('should prevent registering a new first user', async () => {
const response = await fetch(`${apiUrl}/${slug}/first-register`, {
body: JSON.stringify({
email: 'thisuser@shouldbeprevented.com',
password: 'get-out',
}),
headers,
method: 'post',
});
expect(response.status).toBe(403);
});
it('should login a user successfully', async () => {
const response = await fetch(`${apiUrl}/${slug}/login`, {
body: JSON.stringify({
email,
password,
}),
headers,
method: 'post',
});
const data = await response.json();
expect(response.status).toBe(200);
expect(data.token).toBeDefined();
});
describe('logged in', () => {
let token: string | undefined;
let loggedInUser: User | undefined;
beforeAll(async () => {
const response = await fetch(`${apiUrl}/${slug}/login`, {
body: JSON.stringify({
email,
password,
}),
headers,
method: 'post',
});
const data = await response.json();
token = data.token;
loggedInUser = data.user;
});
it('should return a logged in user from /me', async () => {
const response = await fetch(`${apiUrl}/${slug}/me`, {
headers: {
...headers,
Authorization: `JWT ${token}`,
},
});
const data = await response.json();
expect(response.status).toBe(200);
expect(data.user.email).toBeDefined();
});
it('should have fields saved to JWT', async () => {
const decoded = jwtDecode<User>(token);
const {
email: jwtEmail,
collection,
roles,
[saveToJWTKey]: customJWTPropertyKey,
'x-lifted-from-group': liftedFromGroup,
'x-tab-field': unnamedTabSaveToJWTString,
tabLiftedSaveToJWT,
unnamedTabSaveToJWTFalse,
iat,
exp,
} = decoded;
const group = decoded['x-group'] as Record<string, unknown>;
const tab = decoded.saveToJWTTab as Record<string, unknown>;
const tabString = decoded['tab-test'] as Record<string, unknown>;
expect(jwtEmail).toBeDefined();
expect(collection).toEqual('users');
expect(collection).toEqual('users');
expect(Array.isArray(roles)).toBeTruthy();
// 'x-custom-jwt-property-name': 'namedSaveToJWT value'
expect(customJWTPropertyKey).toEqual(namedSaveToJWTValue);
expect(group).toBeDefined();
expect(group['x-test']).toEqual('nested property');
expect(group.saveToJWTFalse).toBeUndefined();
expect(liftedFromGroup).toEqual('lifted from group');
expect(tabLiftedSaveToJWT).toEqual('lifted from unnamed tab');
expect(tab['x-field']).toEqual('yes');
expect(tabString.includedByDefault).toEqual('yes');
expect(unnamedTabSaveToJWTString).toEqual('text');
expect(unnamedTabSaveToJWTFalse).toBeUndefined();
expect(iat).toBeDefined();
expect(exp).toBeDefined();
});
it('should allow authentication with an API key with useAPIKey', async () => {
const apiKey = '0123456789ABCDEFGH';
const user = await payload.create({
collection: slug,
data: {
email: 'dev@example.com',
password: 'test',
apiKey,
},
});
const response = await fetch(`${apiUrl}/${slug}/me`, {
headers: {
...headers,
Authorization: `${slug} API-Key ${user?.apiKey}`,
},
});
const data = await response.json();
expect(response.status).toBe(200);
expect(data.user.email).toBeDefined();
expect(data.user.apiKey).toStrictEqual(apiKey);
});
it('should refresh a token and reset its expiration', async () => {
const response = await fetch(`${apiUrl}/${slug}/refresh-token`, {
method: 'post',
headers: {
Authorization: `JWT ${token}`,
},
});
const data = await response.json();
expect(response.status).toBe(200);
expect(data.refreshedToken).toBeDefined();
});
it('should refresh a token and receive an up-to-date user', async () => {
expect(loggedInUser?.custom).toBe('Hello, world!');
await payload.update({
collection: slug,
id: loggedInUser?.id || '',
data: {
custom: 'Goodbye, world!',
},
});
const response = await fetch(`${apiUrl}/${slug}/refresh-token`, {
method: 'post',
headers: {
Authorization: `JWT ${token}`,
},
});
const data = await response.json();
expect(response.status).toBe(200);
expect(data.user.custom).toBe('Goodbye, world!');
});
it('should allow a user to be created', async () => {
const response = await fetch(`${apiUrl}/${slug}`, {
body: JSON.stringify({
email: 'name@test.com',
password,
roles: ['editor'],
}),
headers: {
Authorization: `JWT ${token}`,
'Content-Type': 'application/json',
},
method: 'post',
});
const data = await response.json();
expect(response.status).toBe(201);
expect(data).toHaveProperty('message');
expect(data).toHaveProperty('doc');
const { doc } = data;
expect(doc).toHaveProperty('email');
expect(doc).toHaveProperty('createdAt');
expect(doc).toHaveProperty('roles');
});
it('should allow verification of a user', async () => {
const emailToVerify = 'verify@me.com';
const response = await fetch(`${apiUrl}/public-users`, {
body: JSON.stringify({
email: emailToVerify,
password,
roles: ['editor'],
}),
headers: {
Authorization: `JWT ${token}`,
'Content-Type': 'application/json',
},
method: 'post',
});
expect(response.status).toBe(201);
const userResult = await payload.find({
collection: 'public-users',
limit: 1,
showHiddenFields: true,
where: {
email: {
equals: emailToVerify,
},
},
});
const { _verified, _verificationToken } = userResult.docs[0];
expect(_verified).toBe(false);
expect(_verificationToken).toBeDefined();
const verificationResponse = await fetch(`${apiUrl}/public-users/verify/${_verificationToken}`, {
headers: {
'Content-Type': 'application/json',
},
method: 'post',
});
expect(verificationResponse.status).toBe(200);
const afterVerifyResult = await payload.find({
collection: 'public-users',
limit: 1,
showHiddenFields: true,
where: {
email: {
equals: emailToVerify,
},
},
});
const { _verified: afterVerified, _verificationToken: afterToken } = afterVerifyResult.docs[0];
expect(afterVerified).toBe(true);
expect(afterToken).toBeNull();
});
describe('Account Locking', () => {
const userEmail = 'lock@me.com';
const tryLogin = async () => {
await fetch(`${apiUrl}/${slug}/login`, {
body: JSON.stringify({
email: userEmail,
password: 'bad',
}),
headers: {
'Content-Type': 'application/json',
},
method: 'post',
});
// expect(loginRes.status).toEqual(401);
};
beforeAll(async () => {
const response = await fetch(`${apiUrl}/${slug}/login`, {
body: JSON.stringify({
email,
password,
}),
headers,
method: 'post',
});
const data = await response.json();
token = data.token;
// New user to lock
await fetch(`${apiUrl}/${slug}`, {
body: JSON.stringify({
email: userEmail,
password,
}),
headers: {
'Content-Type': 'application/json',
Authorization: `JWT ${token}`,
},
method: 'post',
});
});
it('should lock the user after too many attempts', async () => {
await tryLogin();
await tryLogin();
const userResult = await payload.find({
collection: slug,
limit: 1,
showHiddenFields: true,
where: {
email: {
equals: userEmail,
},
},
});
const { loginAttempts, lockUntil } = userResult.docs[0];
expect(loginAttempts).toBe(2);
expect(lockUntil).toBeDefined();
});
it('should unlock account once lockUntil period is over', async () => {
// Lock user
await tryLogin();
await tryLogin();
await payload.update({
collection: slug,
where: {
email: {
equals: userEmail,
},
},
data: {
lockUntil: Date.now() - 605 * 1000,
},
});
// login
await fetch(`${apiUrl}/${slug}/login`, {
body: JSON.stringify({
email: userEmail,
password,
}),
headers: {
Authorization: `JWT ${token}`,
'Content-Type': 'application/json',
},
method: 'post',
});
const userResult = await payload.find({
collection: slug,
limit: 1,
showHiddenFields: true,
where: {
email: {
equals: userEmail,
},
},
});
const { loginAttempts, lockUntil } = userResult.docs[0];
expect(loginAttempts).toBe(0);
expect(lockUntil).toBeNull();
});
});
});
it('should allow forgot-password by email', async () => {
// TODO: Spy on payload sendEmail function
const response = await fetch(`${apiUrl}/${slug}/forgot-password`, {
method: 'post',
body: JSON.stringify({
email,
}),
headers: {
'Content-Type': 'application/json',
},
});
// expect(mailSpy).toHaveBeenCalled();
expect(response.status).toBe(200);
});
it('should allow reset password', async () => {
const token = await payload.forgotPassword({
collection: 'users',
data: {
email: devUser.email,
},
disableEmail: true,
});
const result = await payload.resetPassword({
collection: 'users',
data: {
password: devUser.password,
token,
},
overrideAccess: true,
}).catch((e) => console.error(e));
expect(result).toBeTruthy();
});
});
describe('API Key', () => {
it('should authenticate via the correct API key user', async () => {
const usersQuery = await payload.find({
collection: 'api-keys',
});
const [user1, user2] = usersQuery.docs;
const success = await fetch(`${apiUrl}/api-keys/${user2.id}`, {
headers: {
'Content-Type': 'application/json',
Authorization: `api-keys API-Key ${user2.apiKey}`,
},
}).then((res) => res.json());
expect(success.apiKey).toStrictEqual(user2.apiKey);
const fail = await fetch(`${apiUrl}/api-keys/${user1.id}`, {
headers: {
'Content-Type': 'application/json',
Authorization: `api-keys API-Key ${user2.apiKey}`,
},
});
expect(fail.status).toStrictEqual(404);
});
});
});

View File

@@ -1,103 +0,0 @@
/* tslint:disable */
/* eslint-disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
export interface Config {
collections: {
users: User;
'api-keys': ApiKey;
'public-users': PublicUser;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
globals: {};
}
export interface User {
id: string;
roles: ('admin' | 'editor' | 'moderator' | 'user' | 'viewer')[];
custom?: string;
updatedAt: string;
createdAt: string;
enableAPIKey?: boolean;
apiKey?: string;
apiKeyIndex?: string;
email: string;
resetPasswordToken?: string;
resetPasswordExpiration?: string;
salt?: string;
hash?: string;
loginAttempts?: number;
lockUntil?: string;
password?: string;
}
export interface ApiKey {
id: string;
updatedAt: string;
createdAt: string;
enableAPIKey?: boolean;
apiKey?: string;
apiKeyIndex?: string;
}
export interface PublicUser {
id: string;
updatedAt: string;
createdAt: string;
email: string;
resetPasswordToken?: string;
resetPasswordExpiration?: string;
salt?: string;
hash?: string;
_verified?: boolean;
_verificationToken?: string;
loginAttempts?: number;
lockUntil?: string;
password?: string;
}
export interface PayloadPreference {
id: string;
user:
| {
value: string | User;
relationTo: 'users';
}
| {
value: string | ApiKey;
relationTo: 'api-keys';
}
| {
value: string | PublicUser;
relationTo: 'public-users';
};
key?: string;
value?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
export interface PayloadMigration {
id: string;
name?: string;
batch?: number;
schema?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}

View File

@@ -1,30 +0,0 @@
import { buildConfigWithDefaults } from '../../buildConfigWithDefaults';
export const collectionSlug = 'users';
export default buildConfigWithDefaults({
debug: true,
admin: {
user: 'users',
},
collections: [
{
slug: collectionSlug,
auth: {
removeTokenFromResponses: true,
},
fields: [
{
name: 'roles',
label: 'Role',
type: 'select',
options: ['admin', 'editor', 'moderator', 'user', 'viewer'],
defaultValue: 'user',
required: true,
saveToJWT: true,
hasMany: true,
},
],
},
],
});

View File

@@ -1,65 +0,0 @@
import mongoose from 'mongoose';
import payload from '../../../src';
import { devUser } from '../../credentials';
import { initPayloadTest } from '../../helpers/configHelpers';
import { RESTClient } from '../../helpers/rest';
import { collectionSlug } from './config';
require('isomorphic-fetch');
let client: RESTClient;
describe('Remove token from auth responses', () => {
beforeAll(async () => {
const config = await initPayloadTest({ __dirname, init: { local: false } });
const { serverURL } = config;
client = new RESTClient(config, { serverURL, defaultSlug: collectionSlug });
await client.endpoint(`/api/${collectionSlug}/first-register`, 'post', devUser);
await client.login();
});
afterAll(async () => {
await mongoose.connection.dropDatabase();
await mongoose.connection.close();
await payload.mongoMemoryServer.stop();
});
it('should not include token in response from /login', async () => {
const { status, data } = await client.endpoint(`/api/${collectionSlug}/login`, 'post', devUser);
expect(status).toBe(200);
expect(data.token).not.toBeDefined();
expect(data.user.email).toBeDefined();
});
it('should not include token in response from /me', async () => {
const { status, data } = await client.endpointWithAuth(`/api/${collectionSlug}/me`);
expect(status).toBe(200);
expect(data.token).not.toBeDefined();
expect(data.user.email).toBeDefined();
});
it('should not include token in response from /refresh-token', async () => {
const { status, data } = await client.endpointWithAuth(`/api/${collectionSlug}/refresh-token`, 'post');
expect(status).toBe(200);
expect(data.refreshedToken).not.toBeDefined();
expect(data.user.email).toBeDefined();
});
it('should not include token in response from /reset-password', async () => {
const token = await payload.forgotPassword({
collection: collectionSlug,
data: { email: devUser.email },
disableEmail: true,
});
const { status, data } = await client.endpoint(`/api/${collectionSlug}/reset-password`, 'post', {
token,
password: devUser.password,
});
expect(status).toBe(200);
expect(data.token).not.toBeDefined();
expect(data.user.email).toBeDefined();
});
});

View File

@@ -1,28 +0,0 @@
import React, { useEffect, useState } from 'react';
import { useAuth } from '../../../src/admin/components/utilities/Auth';
import { UIField } from '../../../src/fields/config/types';
import { User } from '../../../src/auth';
export const AuthDebug: React.FC<UIField> = () => {
const [state, setState] = useState<User | null | undefined>();
const { user } = useAuth();
useEffect(() => {
if (user) {
fetch(`/api/users/${user.id}`).then((r) => r.json()).then((newUser) => {
setState(newUser);
});
}
}, [user]);
return (
<div id="auth-debug-ui-field">
<div id="users-api-result">
{state?.custom as string}
</div>
<div id="use-auth-result">
{user?.custom as string}
</div>
</div>
);
};

View File

@@ -1,65 +0,0 @@
import path from 'path';
import { Config, SanitizedConfig } from '../src/config/types';
import { buildConfig as buildPayloadConfig } from '../src/config/build';
import { mongooseAdapter } from '../packages/db-mongodb/src';
import { postgresAdapter } from '../packages/db-postgres/src';
const databaseAdapters = {
mongoose: mongooseAdapter({
url: 'mongodb://127.0.0.1/payload',
}),
postgres: postgresAdapter({
client: {
connectionString: process.env.POSTGRES_URL || 'postgres://127.0.0.1:5432/payload',
},
}),
};
export function buildConfigWithDefaults(testConfig?: Partial<Config>): Promise<SanitizedConfig> {
const [name] = process.argv.slice(2);
const config: Config = {
telemetry: false,
rateLimit: {
window: 15 * 60 * 100, // 15min default,
max: 9999999999,
},
...testConfig,
db: databaseAdapters[process.env.PAYLOAD_DATABASE || 'mongoose'],
};
config.admin = {
autoLogin: process.env.PAYLOAD_PUBLIC_DISABLE_AUTO_LOGIN === 'true' ? false : {
email: 'dev@payloadcms.com',
password: 'test',
},
...(config.admin || {}),
webpack: (webpackConfig) => {
const existingConfig = typeof testConfig?.admin?.webpack === 'function'
? testConfig.admin.webpack(webpackConfig)
: webpackConfig;
return {
...existingConfig,
name,
cache: process.env.NODE_ENV === 'test'
? { type: 'memory' }
: existingConfig.cache,
resolve: {
...existingConfig.resolve,
alias: {
...existingConfig.resolve?.alias,
[path.resolve(__dirname, '../packages/db-postgres/src/index')]: path.resolve(__dirname, '../packages/db-postgres/src/mock'),
[path.resolve(__dirname, '../packages/db-mongodb/src/index')]: path.resolve(__dirname, '../packages/db-mongodb/src/mock'),
},
},
};
},
};
if (process.env.PAYLOAD_DISABLE_ADMIN === 'true') {
if (typeof config.admin !== 'object') config.admin = {};
config.admin.disable = true;
}
return buildPayloadConfig(config);
}

View File

@@ -1,456 +0,0 @@
import path from 'path';
import type { CollectionConfig } from '../../src/collections/config/types';
import { devUser } from '../credentials';
import { buildConfigWithDefaults } from '../buildConfigWithDefaults';
export interface Relation {
id: string;
name: string;
}
const openAccess = {
create: () => true,
read: () => true,
update: () => true,
delete: () => true,
};
const collectionWithName = (collectionSlug: string): CollectionConfig => {
return {
slug: collectionSlug,
access: openAccess,
fields: [
{
name: 'name',
type: 'text',
},
],
};
};
export const slug = 'posts';
export const relationSlug = 'relation';
export const pointSlug = 'point';
export default buildConfigWithDefaults({
graphQL: {
schemaOutputFile: path.resolve(__dirname, 'schema.graphql'),
queries: (GraphQL) => {
return {
QueryWithInternalError: {
type: new GraphQL.GraphQLObjectType({
name: 'QueryWithInternalError',
fields: {
text: {
type: GraphQL.GraphQLString,
},
},
}),
resolve: () => {
// Throwing an internal error with potentially sensitive data
throw new Error('Lost connection to the Pentagon. Secret data: ******');
},
},
};
},
},
collections: [
{
slug: 'users',
auth: true,
access: openAccess,
fields: [],
},
{
slug: pointSlug,
access: openAccess,
fields: [
{
type: 'point',
name: 'point',
},
],
},
{
slug,
access: openAccess,
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'description',
type: 'text',
},
{
name: 'number',
type: 'number',
},
{
name: 'min',
type: 'number',
min: 10,
},
// Relationship
{
name: 'relationField',
type: 'relationship',
relationTo: relationSlug,
},
{
name: 'relationToCustomID',
type: 'relationship',
relationTo: 'custom-ids',
},
// Relation hasMany
{
name: 'relationHasManyField',
type: 'relationship',
relationTo: relationSlug,
hasMany: true,
},
// Relation multiple relationTo
{
name: 'relationMultiRelationTo',
type: 'relationship',
relationTo: [relationSlug, 'dummy'],
},
// Relation multiple relationTo hasMany
{
name: 'relationMultiRelationToHasMany',
type: 'relationship',
relationTo: [relationSlug, 'dummy'],
hasMany: true,
},
{
name: 'A1',
type: 'group',
fields: [
{
type: 'text',
name: 'A2',
defaultValue: 'textInRowInGroup',
},
],
},
{
name: 'B1',
type: 'group',
fields: [
{
type: 'collapsible',
label: 'Collapsible',
fields: [
{
type: 'text',
name: 'B2',
defaultValue: 'textInRowInGroup',
},
],
},
],
},
{
name: 'C1',
type: 'group',
fields: [
{
type: 'text',
name: 'C2Text',
},
{
type: 'row',
fields: [
{
type: 'collapsible',
label: 'Collapsible2',
fields: [
{
name: 'C2',
type: 'group',
fields: [
{
type: 'row',
fields: [
{
type: 'collapsible',
label: 'Collapsible2',
fields: [
{
type: 'text',
name: 'C3',
},
],
},
],
},
],
},
],
},
],
},
],
},
{
type: 'tabs',
tabs: [
{
label: 'Tab1',
name: 'D1',
fields: [
{
name: 'D2',
type: 'group',
fields: [
{
type: 'row',
fields: [
{
type: 'collapsible',
label: 'Collapsible2',
fields: [
{
type: 'tabs',
tabs: [
{
label: 'Tab1',
fields: [
{
name: 'D3',
type: 'group',
fields: [
{
type: 'row',
fields: [
{
type: 'collapsible',
label: 'Collapsible2',
fields: [
{
type: 'text',
name: 'D4',
},
],
},
],
},
],
},
],
},
],
},
],
},
],
},
],
},
],
},
],
},
],
},
{
slug: 'custom-ids',
access: {
read: () => true,
},
fields: [
{
name: 'id',
type: 'number',
},
{
name: 'title',
type: 'text',
},
],
},
collectionWithName(relationSlug),
collectionWithName('dummy'),
{
slug: 'payload-api-test-ones',
access: {
read: () => true,
},
fields: [
{
name: 'payloadAPI',
type: 'text',
hooks: {
afterRead: [
({ req }) => req.payloadAPI,
],
},
},
],
},
{
slug: 'payload-api-test-twos',
access: {
read: () => true,
},
fields: [
{
name: 'payloadAPI',
type: 'text',
hooks: {
afterRead: [
({ req }) => req.payloadAPI,
],
},
},
{
name: 'relation',
type: 'relationship',
relationTo: 'payload-api-test-ones',
},
],
},
],
onInit: async (payload) => {
await payload.create({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
});
await payload.create({
collection: 'custom-ids',
data: {
id: 1,
title: 'hello',
},
});
await payload.create({
collection: slug,
data: {
title: 'has custom ID relation',
relationToCustomID: 1,
},
});
await payload.create({
collection: slug,
data: {
title: 'post1',
},
});
await payload.create({
collection: slug,
data: {
title: 'post2',
},
});
await payload.create({
collection: slug,
data: {
title: 'with-description',
description: 'description',
},
});
await payload.create({
collection: slug,
data: {
title: 'numPost1',
number: 1,
},
});
await payload.create({
collection: slug,
data: {
title: 'numPost2',
number: 2,
},
});
const rel1 = await payload.create({
collection: relationSlug,
data: {
name: 'name',
},
});
const rel2 = await payload.create({
collection: relationSlug,
data: {
name: 'name2',
},
});
// Relation - hasMany
await payload.create({
collection: slug,
data: {
title: 'rel to hasMany',
relationHasManyField: rel1.id,
},
});
await payload.create({
collection: slug,
data: {
title: 'rel to hasMany 2',
relationHasManyField: rel2.id,
},
});
// Relation - relationTo multi
await payload.create({
collection: slug,
data: {
title: 'rel to multi',
relationMultiRelationTo: {
relationTo: relationSlug,
value: rel2.id,
},
},
});
// Relation - relationTo multi hasMany
await payload.create({
collection: slug,
data: {
title: 'rel to multi hasMany',
relationMultiRelationToHasMany: [
{
relationTo: relationSlug,
value: rel1.id,
},
{
relationTo: relationSlug,
value: rel2.id,
},
],
},
});
const payloadAPITest1 = await payload.create({
collection: 'payload-api-test-ones',
data: {},
});
await payload.create({
collection: 'payload-api-test-twos',
data: {
relation: payloadAPITest1.id,
},
});
await payload.create({
collection: pointSlug,
data: {
point: [10, 20],
},
});
},
});

View File

@@ -1,773 +0,0 @@
import { GraphQLClient } from 'graphql-request';
import { initPayloadTest } from '../helpers/configHelpers';
import configPromise, { pointSlug, slug } from './config';
import payload from '../../src';
import type { Post } from './payload-types';
import { mapAsync } from '../../src/utilities/mapAsync';
const title = 'title';
let client: GraphQLClient;
describe('collections-graphql', () => {
beforeAll(async () => {
const { serverURL } = await initPayloadTest({ __dirname, init: { local: false } });
const config = await configPromise;
const url = `${serverURL}${config.routes.api}${config.routes.graphQL}`;
client = new GraphQLClient(url);
});
afterAll(async () => {
if (typeof payload.db.destroy === 'function') {
await payload.db.destroy(payload);
}
});
describe('CRUD', () => {
let existingDoc: Post;
beforeEach(async () => {
existingDoc = await createPost();
});
it('should create', async () => {
const query = `mutation {
createPost(data: {title: "${title}"}) {
id
title
}
}`;
const response = await client.request(query);
const doc: Post = response.createPost;
expect(doc).toMatchObject({ title });
expect(doc.id.length).toBeGreaterThan(0);
});
it('should create using graphql variables', async () => {
const query = `mutation Create($title: String!) {
createPost(data: {title: $title}) {
id
title
}
}`;
const response = await client.request(query, { title });
const doc: Post = response.createPost;
expect(doc).toMatchObject({ title });
expect(doc.id.length).toBeGreaterThan(0);
});
it('should read', async () => {
const query = `query {
Post(id: "${existingDoc.id}") {
id
title
}
}`;
const response = await client.request(query);
const doc: Post = response.Post;
expect(doc).toMatchObject({ id: existingDoc.id, title });
});
it('should find', async () => {
const query = `query {
Posts {
docs {
id
title
}
}
}`;
const response = await client.request(query);
const { docs } = response.Posts;
expect(docs).toContainEqual(expect.objectContaining({ id: existingDoc.id }));
});
it('should retain payload api', async () => {
const query = `
query {
PayloadApiTestTwos {
docs {
payloadAPI
relation {
payloadAPI
}
}
}
}
`;
const response = await client.request(query);
const res = response.PayloadApiTestTwos;
expect(res.docs[0].relation.payloadAPI).toStrictEqual('GraphQL');
});
it('should update existing', async () => {
const updatedTitle = 'updated title';
const query = `mutation {
updatePost(id: "${existingDoc.id}", data: { title: "${updatedTitle}"}) {
id
title
}
}`;
const response = await client.request(query);
const doc: Post = response.updatePost;
expect(doc).toMatchObject({ id: existingDoc.id, title: updatedTitle });
});
it('should delete', async () => {
const query = `mutation {
deletePost(id: "${existingDoc.id}") {
id
title
}
}`;
const response = await client.request(query);
const doc: Post = response.deletePost;
expect(doc).toMatchObject({ id: existingDoc.id });
});
});
describe('Querying', () => {
describe('Operators', () => {
let post1: Post;
let post2: Post;
beforeEach(async () => {
post1 = await createPost({ title: 'post1' });
post2 = await createPost({ title: 'post2' });
});
it('equals', async () => {
const query = `query {
Posts(where:{title: {equals:"${post1.title}"}}) {
docs {
id
title
}
}
}`;
const response = await client.request(query);
const { docs } = response.Posts;
expect(docs).toContainEqual(expect.objectContaining({ id: post1.id, title: post1.title }));
});
it('not_equals', async () => {
const query = `query {
Posts(where:{title: {not_equals:"${post1.title}"}}) {
docs {
id
title
}
}
}`;
const response = await client.request(query);
const { docs } = response.Posts;
expect(docs[0]).toMatchObject({ id: post2.id, title: post2.title });
});
it('like', async () => {
const postWithWords = await createPost({ title: 'the quick brown fox' });
const query = `query {
Posts(where:{title: {like:"${postWithWords.title?.split(' ')[1]}"}}) {
docs {
id
title
}
}
}`;
const response = await client.request(query);
const { docs } = response.Posts;
expect(docs[0]).toMatchObject({ id: postWithWords.id, title: postWithWords.title });
});
it('contains', async () => {
const query = `query {
Posts(where:{title: {contains:"${post1.title?.slice(0, 4)}"}}) {
docs {
id
title
}
}
}`;
const response = await client.request(query);
const { docs } = response.Posts;
expect(docs).toContainEqual(expect.objectContaining({ id: post1.id, title: post1.title }));
expect(docs).toContainEqual(expect.objectContaining({ id: post2.id, title: post2.title }));
});
it('exists - true', async () => {
const withDescription = await createPost({ description: 'description' });
const query = `query {
Posts(where:{description: {exists:true}}) {
docs {
id
title
}
}
}`;
const response = await client.request(query);
const { docs } = response.Posts;
expect(docs).toContainEqual(expect.objectContaining({ id: withDescription.id, title: withDescription.title }));
});
it('exists - false', async () => {
const withDescription = await createPost({ description: 'description' });
const query = `query {
Posts(where:{description: {exists:false}}) {
docs {
id
title
}
}
}`;
const response = await client.request(query);
const { docs } = response.Posts;
expect(docs).not.toContainEqual(expect.objectContaining({ id: withDescription.id }));
expect(docs).toContainEqual(expect.objectContaining({ id: post1.id }));
});
describe('numbers', () => {
let numPost1: Post;
let numPost2: Post;
beforeEach(async () => {
numPost1 = await createPost({ number: 1 });
numPost2 = await createPost({ number: 2 });
});
it('greater_than', async () => {
const query = `query {
Posts(where:{number: {greater_than:1}}) {
docs {
id
title
}
}
}`;
const response = await client.request(query);
const { docs } = response.Posts;
expect(docs).toContainEqual(expect.objectContaining({ id: numPost2.id }));
});
it('greater_than_equal', async () => {
const query = `query {
Posts(where:{number: {greater_than_equal:1}}) {
docs {
id
title
}
}
}`;
const response = await client.request(query);
const { docs } = response.Posts;
expect(docs).toContainEqual(expect.objectContaining({ id: numPost1.id }));
expect(docs).toContainEqual(expect.objectContaining({ id: numPost2.id }));
});
it('less_than', async () => {
const query = `query {
Posts(where:{number: {less_than:2}}) {
docs {
id
title
}
}
}`;
const response = await client.request(query);
const { docs } = response.Posts;
expect(docs).toContainEqual(expect.objectContaining({ id: numPost1.id }));
});
it('less_than_equal', async () => {
const query = `query {
Posts(where:{number: {less_than_equal:2}}) {
docs {
id
title
}
}
}`;
const response = await client.request(query);
const { docs } = response.Posts;
expect(docs).toContainEqual(expect.objectContaining({ id: numPost1.id }));
expect(docs).toContainEqual(expect.objectContaining({ id: numPost2.id }));
});
});
it('or', async () => {
const query = `query {
Posts(
where: {OR: [{ title: { equals: "${post1.title}" } }, { title: { equals: "${post2.title}" } }]
}) {
docs {
id
title
}
}
}`;
const response = await client.request(query);
const { docs } = response.Posts;
expect(docs).toContainEqual(expect.objectContaining({ id: post1.id }));
expect(docs).toContainEqual(expect.objectContaining({ id: post2.id }));
});
it('or - 1 result', async () => {
const query = `query {
Posts(
where: {OR: [{ title: { equals: "${post1.title}" } }, { title: { equals: "nope" } }]
}) {
docs {
id
title
}
}
}`;
const response = await client.request(query);
const { docs } = response.Posts;
expect(docs).toContainEqual(expect.objectContaining({ id: post1.id }));
expect(docs).not.toContainEqual(expect.objectContaining({ id: post2.id }));
});
it('and', async () => {
const specialPost = await createPost({ description: 'special-123123' });
const query = `query {
Posts(
where: {
AND: [
{ title: { equals: "${specialPost.title}" } }
{ description: { equals: "${specialPost.description}" } }
]
}) {
docs {
id
title
}
}
}`;
const response = await client.request(query);
const { docs } = response.Posts;
expect(docs).toContainEqual(expect.objectContaining({ id: specialPost.id }));
});
describe('near', () => {
const point = [10, 20];
const [lat, lng] = point;
it('should return a document near a point', async () => {
const nearQuery = `
query {
Points(
where: {
point: {
near: [${lat + 0.01}, ${lng + 0.01}, 10000]
}
}
) {
docs {
id
point
}
}
}`;
const response = await client.request(nearQuery);
const { docs } = response.Points;
expect(docs).toHaveLength(1);
});
it('should not return a point far away', async () => {
const nearQuery = `
query {
Points(
where: {
point: {
near: [${lng + 1}, ${lat - 1}, 5000]
}
}
) {
docs {
id
point
}
}
}`;
const response = await client.request(nearQuery);
const { docs } = response.Points;
expect(docs).toHaveLength(0);
});
it('should sort find results by nearest distance', async () => {
// creating twice as many records as we are querying to get a random sample
await mapAsync([...Array(10)], async () => {
// setTimeout used to randomize the creation timestamp
setTimeout(async () => {
await payload.create({
collection: pointSlug,
data: {
// only randomize longitude to make distance comparison easy
point: [Math.random(), 0],
},
});
}, Math.random());
});
const nearQuery = `
query {
Points(
where: {
point: {
near: [0, 0, 100000, 0]
}
},
limit: 5
) {
docs {
id
point
}
}
}`;
const response = await client.request(nearQuery);
const { docs } = response.Points;
let previous = 0;
docs.forEach(({ point: coordinates }) => {
// The next document point should always be greater than the one before
expect(previous).toBeLessThanOrEqual(coordinates[0]);
[previous] = coordinates;
});
});
});
describe('within', () => {
type Point = [number, number];
const polygon: Point[] = [
[9.0, 19.0], // bottom-left
[9.0, 21.0], // top-left
[11.0, 21.0], // top-right
[11.0, 19.0], // bottom-right
[9.0, 19.0], // back to starting point to close the polygon
];
it('should return a document with the point inside the polygon', async () => {
const query = `
query {
Points(
where: {
point: {
within: {
type: "Polygon",
coordinates: ${JSON.stringify([polygon])}
}
}
}) {
docs {
id
point
}
}
}`;
const response = await client.request(query);
const { docs } = response.Points;
expect(docs).toHaveLength(1);
expect(docs[0].point).toEqual([10, 20]);
});
it('should not return a document with the point outside the polygon', async () => {
const reducedPolygon = polygon.map((vertex) => vertex.map((coord) => coord * 0.1));
const query = `
query {
Points(
where: {
point: {
within: {
type: "Polygon",
coordinates: ${JSON.stringify([reducedPolygon])}
}
}
}) {
docs {
id
point
}
}
}`;
const response = await client.request(query);
const { docs } = response.Points;
expect(docs).toHaveLength(0);
});
});
describe('intersects', () => {
type Point = [number, number];
const polygon: Point[] = [
[9.0, 19.0], // bottom-left
[9.0, 21.0], // top-left
[11.0, 21.0], // top-right
[11.0, 19.0], // bottom-right
[9.0, 19.0], // back to starting point to close the polygon
];
it('should return a document with the point intersecting the polygon', async () => {
const query = `
query {
Points(
where: {
point: {
intersects: {
type: "Polygon",
coordinates: ${JSON.stringify([polygon])}
}
}
}) {
docs {
id
point
}
}
}`;
const response = await client.request(query);
const { docs } = response.Points;
expect(docs).toHaveLength(1);
expect(docs[0].point).toEqual([10, 20]);
});
it('should not return a document with the point not intersecting a smaller polygon', async () => {
const reducedPolygon = polygon.map((vertex) => vertex.map((coord) => coord * 0.1));
const query = `
query {
Points(
where: {
point: {
within: {
type: "Polygon",
coordinates: ${JSON.stringify([reducedPolygon])}
}
}
}) {
docs {
id
point
}
}
}`;
const response = await client.request(query);
const { docs } = response.Points;
expect(docs).toHaveLength(0);
});
});
it('can query deeply nested fields within rows, tabs, collapsibles', async () => {
const withNestedField = await createPost({ D1: { D2: { D3: { D4: 'nested message' } } } });
const query = `{
Posts(where: { D1__D2__D3__D4: { equals: "nested message" } }) {
docs {
id
D1 {
D2 {
D3 {
D4
}
}
}
}
}
}`;
const response = await client.request(query);
const { docs } = response.Posts;
expect(docs).toContainEqual(expect.objectContaining({ id: withNestedField.id, D1: { D2: { D3: { D4: 'nested message' } } } }));
});
});
describe('relationships', () => {
it('should query on relationships with custom IDs', async () => {
const query = `query {
Posts(where: { title: { equals: "has custom ID relation" }}) {
docs {
id
title
relationToCustomID {
id
}
}
totalDocs
}
}`;
const response = await client.request(query);
const { docs, totalDocs } = response.Posts;
expect(totalDocs).toStrictEqual(1);
expect(docs[0].relationToCustomID.id).toStrictEqual(1);
});
});
});
describe('Error Handler', () => {
it('should return have an array of errors when making a bad request', async () => {
let error;
// language=graphQL
const query = `query {
Posts(where: { title: { exists: true }}) {
docs {
badFieldName
}
}
}`;
await client.request(query).catch((err) => {
error = err;
});
expect(Array.isArray(error.response.errors)).toBe(true);
expect(typeof error.response.errors[0].message).toBe('string');
});
it('should return have an array of errors when failing to pass validation', async () => {
let error;
// language=graphQL
const query = `mutation {
createPost(data: {min: 1}) {
id
min
createdAt
updatedAt
}
}`;
await client.request(query).catch((err) => {
error = err;
});
expect(Array.isArray(error.response.errors)).toBe(true);
expect(error.response.errors[0].message).toEqual('The following field is invalid: min');
expect(typeof error.response.errors[0].locations).toBeDefined();
});
it('should return have an array of errors when failing multiple mutations', async () => {
let error;
// language=graphQL
const query = `mutation createTest {
test1:createUser(data: { email: "test@test.com", password: "test" }) {
email
}
test2:createUser(data: { email: "test2@test.com", password: "" }) {
email
}
test3:createUser(data: { email: "test@test.com", password: "test" }) {
email
}
test4:createUser(data: { email: "", password: "test" }) {
email
}
}`;
await client.request(query).catch((err) => {
error = err;
});
expect(Array.isArray(error.response.errors)).toBe(true);
expect(Array.isArray(error.response.errors[0].locations)).toEqual(true);
expect(error.response.errors[0].message).toEqual('The following field is invalid: password');
expect(error.response.errors[0].path[0]).toEqual('test2');
expect(error.response.errors[0].extensions.name).toEqual('ValidationError');
expect(error.response.errors[0].extensions.data[0].message).toEqual('No password was given');
expect(error.response.errors[0].extensions.data[0].field).toEqual('password');
expect(Array.isArray(error.response.errors[1].locations)).toEqual(true);
expect(error.response.errors[1].message).toEqual('The following field is invalid: email');
expect(error.response.errors[1].path[0]).toEqual('test3');
expect(error.response.errors[1].extensions.name).toEqual('ValidationError');
expect(error.response.errors[1].extensions.data[0].message).toEqual('A user with the given email is already registered');
expect(error.response.errors[1].extensions.data[0].field).toEqual('email');
expect(Array.isArray(error.response.errors[2].locations)).toEqual(true);
expect(error.response.errors[2].message).toEqual('The following field is invalid: email');
expect(error.response.errors[2].path[0]).toEqual('test4');
expect(error.response.errors[2].extensions.name).toEqual('ValidationError');
expect(error.response.errors[2].extensions.data[0].message).toEqual('Please enter a valid email address.');
expect(error.response.errors[2].extensions.data[0].field).toEqual('email');
});
it('should return the minimum allowed information about internal errors', async () => {
let error;
// language=graphQL
const query = `query {
QueryWithInternalError {
text
}
}`;
await client.request(query).catch((err) => {
error = err;
});
expect(Array.isArray(error.response.errors)).toBe(true);
expect(Array.isArray(error.response.errors[0].locations)).toEqual(true);
expect(error.response.errors[0].message).toEqual('Something went wrong.');
expect(error.response.errors[0].path[0]).toEqual('QueryWithInternalError');
expect(error.response.errors[0].extensions.statusCode).toEqual(500);
expect(error.response.errors[0].extensions.name).toEqual('Error');
});
});
});
async function createPost(overrides?: Partial<Post>) {
const doc = await payload.create({
collection: slug,
data: { title: 'title', ...overrides },
});
return doc;
}

View File

@@ -1,106 +0,0 @@
/* tslint:disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
export interface Config {
collections: {
users: User;
posts: Post;
'custom-ids': CustomId;
relation: Relation;
dummy: Dummy;
};
globals: {};
}
export interface User {
id: string;
updatedAt: string;
createdAt: string;
email?: string;
resetPasswordToken?: string;
resetPasswordExpiration?: string;
loginAttempts?: number;
lockUntil?: string;
password?: string;
}
export interface Post {
id: string;
title?: string;
description?: string;
number?: number;
min?: number;
relationField?: string | Relation;
relationToCustomID?: number | CustomId;
relationHasManyField?: string[] | Relation[];
relationMultiRelationTo?:
| {
value: string | Relation;
relationTo: 'relation';
}
| {
value: string | Dummy;
relationTo: 'dummy';
};
relationMultiRelationToHasMany?:
| (
| {
value: string;
relationTo: 'relation';
}
| {
value: string;
relationTo: 'dummy';
}
)[]
| (
| {
value: Relation;
relationTo: 'relation';
}
| {
value: Dummy;
relationTo: 'dummy';
}
)[];
A1?: {
A2?: string;
};
B1?: {
B2?: string;
};
C1?: {
C2?: {
C3?: string;
};
};
D1: {
D2?: {
D3?: {
D4?: string;
};
};
};
updatedAt: string;
createdAt: string;
}
export interface Relation {
id: string;
name?: string;
updatedAt: string;
createdAt: string;
}
export interface CustomId {
id: number;
title?: string;
updatedAt: string;
createdAt: string;
}
export interface Dummy {
id: string;
name?: string;
updatedAt: string;
createdAt: string;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,373 +0,0 @@
import type { CollectionConfig } from '../../src/collections/config/types';
import { devUser } from '../credentials';
import { buildConfigWithDefaults } from '../buildConfigWithDefaults';
export interface Relation {
id: string;
name: string;
}
const openAccess = {
create: () => true,
read: () => true,
update: () => true,
delete: () => true,
};
const collectionWithName = (collectionSlug: string): CollectionConfig => {
return {
slug: collectionSlug,
access: openAccess,
fields: [
{
name: 'name',
type: 'text',
},
],
};
};
export const slug = 'posts';
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 buildConfigWithDefaults({
endpoints: [
{
path: '/send-test-email',
method: 'get',
handler: async (req, res) => {
await req.payload.sendEmail({
from: 'dev@payloadcms.com',
to: devUser.email,
subject: 'Test Email',
html: 'This is a test email.',
// to recreate a failing email transport, add the following credentials
// to the `email` property of `payload.init()` in `../dev.ts`
// the app should fail to send the email, but the error should be handled without crashing the app
// transportOptions: {
// host: 'smtp.ethereal.email',
// port: 587,
// },
});
res.status(200).send('Email sent');
},
},
{
path: '/internal-error-here',
method: 'get',
handler: async (req, res, next) => {
try {
// Throwing an internal error with potentially sensitive data
throw new Error('Lost connection to the Pentagon. Secret data: ******');
} catch (err) {
next(err);
}
},
},
],
collections: [
{
slug,
access: openAccess,
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'description',
type: 'text',
},
{
name: 'number',
type: 'number',
},
{
name: 'fakeLocalization',
type: 'text',
// field is localized even though the config localization is not on
localized: true,
},
// Relationship
{
name: 'relationField',
type: 'relationship',
relationTo: relationSlug,
},
// Relation hasMany
{
name: 'relationHasManyField',
type: 'relationship',
relationTo: relationSlug,
hasMany: true,
},
// Relation multiple relationTo
{
name: 'relationMultiRelationTo',
type: 'relationship',
relationTo: [relationSlug, 'dummy'],
},
// Relation multiple relationTo hasMany
{
name: 'relationMultiRelationToHasMany',
type: 'relationship',
relationTo: [relationSlug, 'dummy'],
hasMany: true,
},
{
name: 'restrictedField',
type: 'text',
access: {
read: () => false,
},
},
{
type: 'tabs',
tabs: [
{
label: 'Tab1',
name: 'D1',
fields: [
{
name: 'D2',
type: 'group',
fields: [
{
type: 'row',
fields: [
{
type: 'collapsible',
label: 'Collapsible2',
fields: [
{
type: 'tabs',
tabs: [
{
label: 'Tab1',
fields: [
{
name: 'D3',
type: 'group',
fields: [
{
type: 'row',
fields: [
{
type: 'collapsible',
label: 'Collapsible2',
fields: [
{
type: 'text',
name: 'D4',
},
],
},
],
},
],
},
],
},
],
},
],
},
],
},
],
},
],
},
],
},
],
},
{
slug: pointSlug,
access: openAccess,
fields: [
{
type: 'point',
name: 'point',
},
],
},
collectionWithName(relationSlug),
{
slug: 'dummy',
access: openAccess,
fields: [
{
type: 'text',
name: 'title',
},
{
type: 'text',
name: 'name',
access: {
read: () => false,
},
},
],
},
{
slug: customIdSlug,
access: openAccess,
fields: [
{
name: 'id',
type: 'text',
},
{
type: 'row',
fields: [
{
name: 'name',
type: 'text',
},
],
},
],
},
{
slug: customIdNumberSlug,
access: openAccess,
fields: [
{
name: 'id',
type: 'number',
},
{
name: 'name',
type: 'text',
},
],
},
{
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({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
});
const rel1 = await payload.create({
collection: relationSlug,
data: {
name: 'name',
},
});
const rel2 = await payload.create({
collection: relationSlug,
data: {
name: 'name2',
},
});
await payload.create({
collection: pointSlug,
data: {
point: [10, 20],
},
});
// Relation - hasMany
await payload.create({
collection: slug,
data: {
title: 'rel to hasMany',
relationHasManyField: rel1.id,
},
});
await payload.create({
collection: slug,
data: {
title: 'rel to hasMany 2',
relationHasManyField: rel2.id,
},
});
// Relation - relationTo multi
await payload.create({
collection: slug,
data: {
title: 'rel to multi',
relationMultiRelationTo: {
relationTo: relationSlug,
value: rel2.id,
},
},
});
// Relation - relationTo multi hasMany
await payload.create({
collection: slug,
data: {
title: 'rel to multi hasMany',
relationMultiRelationToHasMany: [
{
relationTo: relationSlug,
value: rel1.id,
},
{
relationTo: relationSlug,
value: rel2.id,
},
],
},
});
await payload.create({
collection: customIdSlug,
data: {
id: 'test',
name: 'inside row',
},
});
await payload.create({
collection: customIdNumberSlug,
data: {
id: 123,
name: 'name',
},
});
},
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,123 +0,0 @@
/* tslint:disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
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;
description?: string;
number?: number;
fakeLocalization?: string;
relationField?: string | Relation;
relationHasManyField?: string[] | Relation[];
relationMultiRelationTo?:
| {
value: string | Relation;
relationTo: 'relation';
}
| {
value: string | Dummy;
relationTo: 'dummy';
};
relationMultiRelationToHasMany?:
| (
| {
value: string;
relationTo: 'relation';
}
| {
value: string;
relationTo: 'dummy';
}
)[]
| (
| {
value: Relation;
relationTo: 'relation';
}
| {
value: Dummy;
relationTo: 'dummy';
}
)[];
restrictedField?: string;
D1: {
D2?: {
D3?: {
D4?: string;
};
};
};
updatedAt: string;
createdAt: string;
}
export interface Relation {
id: string;
name?: string;
updatedAt: string;
createdAt: string;
}
export interface Dummy {
id: string;
title?: string;
name?: string;
updatedAt: string;
createdAt: string;
}
export interface Point {
id: string;
/**
* @minItems 2
* @maxItems 2
*/
point?: [number, number];
updatedAt: string;
createdAt: string;
}
export interface CustomId {
id: string;
name?: string;
updatedAt: string;
createdAt: string;
}
export interface CustomIdNumber {
id: number;
name?: string;
updatedAt: string;
createdAt: string;
}
export interface ErrorOnHook {
id: string;
text?: string;
errorBeforeChange?: boolean;
errorAfterDelete?: boolean;
updatedAt: string;
createdAt: string;
}
export interface User {
id: string;
updatedAt: string;
createdAt: string;
email?: string;
resetPasswordToken?: string;
resetPasswordExpiration?: string;
loginAttempts?: number;
lockUntil?: string;
password?: string;
}

View File

@@ -1 +0,0 @@
import '@testing-library/jest-dom';

View File

@@ -1,65 +0,0 @@
import { buildConfigWithDefaults } from '../buildConfigWithDefaults';
import { openAccess } from '../helpers/configHelpers';
import { Config } from '../../src/config/types';
const config: Config = {
collections: [
{
slug: 'pages',
access: openAccess,
endpoints: [
{
path: '/hello',
method: 'get',
handler: (_, res): void => {
res.json({ message: 'hi' });
},
custom: { examples: [{ type: 'response', value: { message: 'hi' } }] },
},
],
fields: [
{
name: 'title',
type: 'text',
custom: { description: 'The title of this page' },
},
],
custom: { externalLink: 'https://foo.bar' },
},
],
globals: [
{
slug: 'my-global',
endpoints: [{
path: '/greet',
method: 'get',
handler: (req, res): void => {
const { name } = req.query;
res.json({ message: `Hi ${name}!` });
},
custom: { params: [{ in: 'query', name: 'name', type: 'string' }] },
}],
fields: [{
name: 'title',
type: 'text',
custom: { description: 'The title of my global' },
},
],
custom: { foo: 'bar' },
},
],
endpoints: [
{
path: '/config',
method: 'get',
root: true,
handler: (req, res): void => {
res.json(req.payload.config);
},
custom: { description: 'Get the sanitized payload config' },
},
],
custom: { name: 'Customer portal' },
};
export default buildConfigWithDefaults(config);

View File

@@ -1,66 +0,0 @@
import { initPayloadTest } from '../helpers/configHelpers';
import payload from '../../src';
require('isomorphic-fetch');
describe('Config', () => {
beforeAll(async () => {
await initPayloadTest({ __dirname, init: { local: true } });
});
describe('payload config', () => {
it('allows a custom field at the config root', () => {
const { config } = payload;
expect(config.custom).toEqual({ name: 'Customer portal' });
});
it('allows a custom field in the root endpoints', () => {
const [endpoint] = payload.config.endpoints;
expect(endpoint.custom).toEqual({ description: 'Get the sanitized payload config' });
});
});
describe('collection config', () => {
it('allows a custom field in collections', () => {
const [collection] = payload.config.collections;
expect(collection.custom).toEqual({ externalLink: 'https://foo.bar' });
});
it('allows a custom field in collection endpoints', () => {
const [collection] = payload.config.collections;
const [endpoint] = collection.endpoints;
expect(endpoint.custom).toEqual({ examples: [{ type: 'response', value: { message: 'hi' } }] });
});
it('allows a custom field in collection fields', () => {
const [collection] = payload.config.collections;
const [field] = collection.fields;
expect(field.custom).toEqual({ description: 'The title of this page' });
});
});
describe('global config', () => {
it('allows a custom field in globals', () => {
const [global] = payload.config.globals;
expect(global.custom).toEqual({ foo: 'bar' });
});
it('allows a custom field in global endpoints', () => {
const [global] = payload.config.globals;
const [endpoint] = global.endpoints;
expect(endpoint.custom).toEqual({ params: [{ in: 'query', name: 'name', type: 'string' }] });
});
it('allows a custom field in global fields', () => {
const [global] = payload.config.globals;
const [field] = global.fields;
expect(field.custom).toEqual({ description: 'The title of my global' });
});
});
});

View File

@@ -1,10 +0,0 @@
export const devUser = {
email: 'dev@payloadcms.com',
password: 'test',
roles: ['admin'],
};
export const regularUser = {
email: 'user@payloadcms.com',
password: 'test2',
roles: ['user'],
};

View File

@@ -1,83 +0,0 @@
import { buildConfigWithDefaults } from '../buildConfigWithDefaults';
import { devUser } from '../credentials';
export default buildConfigWithDefaults({
collections: [
{
slug: 'posts',
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'owner',
type: 'relationship',
relationTo: 'users',
hooks: {
beforeChange: [
({ req: { user } }) => user?.id,
],
},
},
],
},
{
slug: 'relation-a',
labels: {
singular: 'Relation A',
plural: 'Relation As',
},
fields: [
{
name: 'relationship',
type: 'relationship',
relationTo: 'relation-b',
},
{
name: 'richText',
type: 'richText',
},
],
},
{
slug: 'relation-b',
labels: {
singular: 'Relation B',
plural: 'Relation Bs',
},
fields: [
{
name: 'relationship',
type: 'relationship',
relationTo: 'relation-a',
},
{
name: 'richText',
type: 'richText',
},
],
},
],
onInit: async (payload) => {
const user = await payload.create({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
});
await payload.create({
user,
collection: 'posts',
data: postDoc,
});
},
});
export const postDoc = {
title: 'test post',
};

View File

@@ -1,136 +0,0 @@
import { GraphQLClient } from 'graphql-request';
import payload from '../../src';
import { devUser } from '../credentials';
import { initPayloadTest } from '../helpers/configHelpers';
import { postDoc } from './config';
describe('dataloader', () => {
let serverURL;
beforeAll(async () => {
const init = await initPayloadTest({ __dirname, init: { local: false } });
serverURL = init.serverURL;
});
describe('graphql', () => {
let client: GraphQLClient;
let token: string;
beforeAll(async () => {
const url = `${serverURL}/api/graphql`;
client = new GraphQLClient(url);
const loginResult = await payload.login({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
});
if (loginResult.token) token = loginResult.token;
});
it('should allow querying via graphql', async () => {
const query = `query {
Posts {
docs {
title
owner {
email
}
}
}
}`;
const response = await client.request(query, null, {
Authorization: `JWT ${token}`,
});
const { docs } = response.Posts;
expect(docs[0].title).toStrictEqual(postDoc.title);
});
it('should avoid infinite loops', async () => {
const relationA = await payload.create({
collection: 'relation-a',
data: {
richText: [
{
children: [
{
text: 'relation a',
},
],
},
],
},
});
const relationB = await payload.create({
collection: 'relation-b',
data: {
relationship: relationA.id,
richText: [
{
children: [
{
text: 'relation b',
},
],
},
],
},
});
expect(relationA.id).toBeDefined();
expect(relationB.id).toBeDefined();
await payload.update({
collection: 'relation-a',
id: relationA.id,
data: {
relationship: relationB.id,
richText: [
{
children: [
{
text: 'relation a',
},
],
},
{
children: [
{
text: '',
},
],
type: 'relationship',
value: {
id: relationB.id,
},
relationTo: 'relation-b',
},
],
},
});
const relationANoDepth = await payload.findByID({
collection: 'relation-a',
id: relationA.id,
depth: 0,
});
expect(relationANoDepth.relationship).toStrictEqual(relationB.id);
const relationAWithDepth = await payload.findByID({
collection: 'relation-a',
id: relationA.id,
depth: 4,
});
const innerMostRelationship = relationAWithDepth.relationship.relationship.richText[1].value.relationship.relationship;
expect(innerMostRelationship).toStrictEqual(relationB.id);
});
});
});

View File

@@ -1,33 +0,0 @@
/* tslint:disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* 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 Post {
id: string;
title: string;
owner?: string | User;
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User {
id: string;
email?: string;
resetPasswordToken?: string;
resetPasswordExpiration?: string;
loginAttempts?: number;
lockUntil?: string;
createdAt: string;
updatedAt: string;
}

View File

@@ -1,63 +0,0 @@
import fs from 'fs';
import path from 'path';
import express from 'express';
import { v4 as uuid } from 'uuid';
import * as dotenv from 'dotenv';
import payload from '../src';
dotenv.config();
const [testSuiteDir] = process.argv.slice(2);
if (!testSuiteDir) {
console.error('ERROR: You must provide an argument for "testSuiteDir"');
process.exit(1);
}
const configPath = path.resolve(__dirname, testSuiteDir, 'config.ts');
if (!fs.existsSync(configPath)) {
console.error('ERROR: You must pass a valid directory under test/ that contains a config.ts');
process.exit(1);
}
process.env.PAYLOAD_CONFIG_PATH = configPath;
process.env.PAYLOAD_DROP_DATABASE = 'true';
if (process.argv.includes('--no-auto-login') && process.env.NODE_ENV !== 'production') {
process.env.PAYLOAD_PUBLIC_DISABLE_AUTO_LOGIN = 'true';
}
const expressApp = express();
const startDev = async () => {
await payload.init({
secret: uuid(),
express: expressApp,
email: {
logMockCredentials: false,
fromName: 'Payload',
fromAddress: 'hello@payloadcms.com',
},
onInit: async () => {
payload.logger.info('Payload Dev Server Initialized');
},
});
// Redirect root to Admin panel
expressApp.get('/', (_, res) => {
res.redirect('/admin');
});
const externalRouter = express.Router();
externalRouter.use(payload.authenticate);
expressApp.listen(3000, async () => {
payload.logger.info(`Admin URL on http://localhost:3000${payload.getAdminURL()}`);
payload.logger.info(`API URL on http://localhost:3000${payload.getAPIURL()}`);
});
};
startDev();

View File

@@ -1,151 +0,0 @@
import express, { Response } from 'express';
import { devUser } from '../credentials';
import { buildConfigWithDefaults } from '../buildConfigWithDefaults';
import { openAccess } from '../helpers/configHelpers';
import { PayloadRequest } from '../../src/express/types';
import { Config } from '../../src/config/types';
export const collectionSlug = 'endpoints';
export const globalSlug = 'global-endpoints';
export const globalEndpoint = 'global';
export const applicationEndpoint = 'path';
export const rootEndpoint = 'root';
export const noEndpointsCollectionSlug = 'no-endpoints';
export const noEndpointsGlobalSlug = 'global-no-endpoints';
const MyConfig: Config = {
collections: [
{
slug: collectionSlug,
access: openAccess,
endpoints: [
{
path: '/say-hello/joe-bloggs',
method: 'get',
handler: (req: PayloadRequest, res: Response): void => {
res.json({ message: 'Hey Joey!' });
},
},
{
path: '/say-hello/:group/:name',
method: 'get',
handler: (req: PayloadRequest, res: Response): void => {
res.json({ message: `Hello ${req.params.name} @ ${req.params.group}` });
},
},
{
path: '/say-hello/:name',
method: 'get',
handler: (req: PayloadRequest, res: Response): void => {
res.json({ message: `Hello ${req.params.name}!` });
},
},
{
path: '/whoami',
method: 'post',
handler: (req: PayloadRequest, res: Response): void => {
res.json({
name: req.body.name,
age: req.body.age,
});
},
},
],
fields: [
{
name: 'title',
type: 'text',
},
],
},
{
slug: noEndpointsCollectionSlug,
graphQL: false,
endpoints: false,
fields: [
{
name: 'name',
type: 'text',
},
],
},
],
globals: [
{
slug: globalSlug,
endpoints: [{
path: `/${globalEndpoint}`,
method: 'post',
handler: (req: PayloadRequest, res: Response): void => {
res.json(req.body);
},
}],
fields: [],
},
{
slug: noEndpointsGlobalSlug,
graphQL: false,
endpoints: false,
fields: [
{
name: 'name',
type: 'text',
},
],
},
],
endpoints: [
{
path: `/${applicationEndpoint}`,
method: 'post',
handler: (req: PayloadRequest, res: Response): void => {
res.json(req.body);
},
},
{
path: `/${applicationEndpoint}`,
method: 'get',
handler: (req: PayloadRequest, res: Response): void => {
res.json({ message: 'Hello, world!' });
},
},
{
path: `/${applicationEndpoint}/i18n`,
method: 'get',
handler: (req: PayloadRequest, res: Response): void => {
res.json({ message: req.t('general:backToDashboard') });
},
},
{
path: `/${rootEndpoint}`,
method: 'get',
root: true,
handler: (req: PayloadRequest, res: Response): void => {
res.json({ message: 'Hello, world!' });
},
},
{
path: `/${rootEndpoint}`,
method: 'post',
root: true,
handler: [
express.json({ type: 'application/json' }),
(req: PayloadRequest, res: Response): void => {
res.json(req.body);
},
],
},
],
onInit: async (payload) => {
await payload.create({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
});
},
};
export default buildConfigWithDefaults(MyConfig);

View File

@@ -1,102 +0,0 @@
import { initPayloadTest } from '../helpers/configHelpers';
import { RESTClient } from '../helpers/rest';
import {
applicationEndpoint,
collectionSlug,
globalEndpoint,
globalSlug,
noEndpointsCollectionSlug,
noEndpointsGlobalSlug,
rootEndpoint,
} from './config';
require('isomorphic-fetch');
let client: RESTClient;
describe('Endpoints', () => {
beforeAll(async () => {
const config = await initPayloadTest({ __dirname, init: { local: false } });
const { serverURL } = config;
client = new RESTClient(config, { serverURL, defaultSlug: collectionSlug });
});
describe('Collections', () => {
it('should GET a static endpoint', async () => {
const { status, data } = await client.endpoint(`/api/${collectionSlug}/say-hello/joe-bloggs`);
expect(status).toBe(200);
expect(data.message).toStrictEqual('Hey Joey!');
});
it('should GET an endpoint with a parameter', async () => {
const name = 'George';
const { status, data } = await client.endpoint(`/api/${collectionSlug}/say-hello/${name}`);
expect(status).toBe(200);
expect(data.message).toStrictEqual(`Hello ${name}!`);
});
it('should POST an endpoint with data', async () => {
const params = { name: 'George', age: 29 };
const { status, data } = await client.endpoint(`/api/${collectionSlug}/whoami`, 'post', params);
expect(status).toBe(200);
expect(data.name).toStrictEqual(params.name);
expect(data.age).toStrictEqual(params.age);
});
it('should disable built-in endpoints when false', async () => {
let result;
try {
result = await client.endpoint(`/api/${noEndpointsCollectionSlug}`, 'get');
} catch (err: unknown) {
result = err;
}
expect(result instanceof Error).toBe(true);
});
});
describe('Globals', () => {
it('should call custom endpoint', async () => {
const params = { globals: 'response' };
const { status, data } = await client.endpoint(`/api/globals/${globalSlug}/${globalEndpoint}`, 'post', params);
expect(status).toBe(200);
expect(params).toMatchObject(data);
});
it('should disable built-in endpoints when false', async () => {
let result;
try {
result = await client.endpoint(`/api/globals/${noEndpointsGlobalSlug}`, 'get');
} catch (err: unknown) {
result = err;
}
expect(result instanceof Error).toBe(true);
});
});
describe('API', () => {
it('should call custom endpoint', async () => {
const params = { app: 'response' };
const { status, data } = await client.endpoint(`/api/${applicationEndpoint}`, 'post', params);
expect(status).toBe(200);
expect(params).toMatchObject(data);
});
it('should have i18n on req', async () => {
const { status, data } = await client.endpoint(`/api/${applicationEndpoint}/i18n`, 'get');
expect(status).toBe(200);
expect(data.message).toStrictEqual('Back to Dashboard');
});
});
describe('Root', () => {
it('should call custom root endpoint', async () => {
const params = { root: 'response' };
const { status, data } = await client.endpoint(`/${rootEndpoint}`, 'post', params);
expect(status).toBe(200);
expect(params).toMatchObject(data);
});
});
});

View File

@@ -1,39 +0,0 @@
/* tslint:disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* 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` "global-endpoints".
*/
export interface GlobalEndpoint {
id: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "endpoints".
*/
export interface Endpoint {
id: string;
title?: string;
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User {
id: string;
email?: string;
resetPasswordToken?: string;
resetPasswordExpiration?: string;
loginAttempts?: number;
lockUntil?: string;
createdAt: string;
updatedAt: string;
}

View File

@@ -1,219 +0,0 @@
import type { CollectionConfig } from '../../../../src/collections/config/types';
import { Field } from '../../../../src/fields/config/types';
export const errorFieldsSlug = 'error-fields';
const errorFields: Field[] = [
{
type: 'text',
name: 'tabText',
required: true,
},
{
type: 'collapsible',
label: 'Collapse me',
fields: [
{
type: 'text',
name: 'text',
required: true,
},
],
},
{
type: 'array',
name: 'array',
fields: [
{
name: 'requiredArrayText',
type: 'text',
required: true,
},
{
name: 'arrayText',
type: 'text',
},
{
type: 'collapsible',
label: 'Collapse me',
fields: [
{
type: 'group',
name: 'group',
fields: [
{
type: 'text',
name: 'text',
required: true,
},
{
type: 'number',
name: 'number',
required: true,
},
{
type: 'date',
name: 'date',
required: true,
},
{
type: 'checkbox',
name: 'checkbox',
required: true,
validate: (value) => {
if (!value) {
return 'This field is required';
}
return true;
},
},
],
},
{
type: 'row',
fields: [
{
type: 'code',
name: 'code',
required: true,
},
{
type: 'json',
name: 'json',
required: true,
},
],
},
{
type: 'email',
name: 'email',
required: true,
},
{
type: 'point',
name: 'point',
required: true,
},
{
type: 'radio',
name: 'radio',
required: true,
options: [
{
label: 'Mint',
value: 'mint',
},
{
label: 'Dark Gray',
value: 'dark_gray',
},
],
},
{
type: 'relationship',
name: 'relationship',
relationTo: 'users',
required: true,
},
{
type: 'richText',
name: 'richtext',
required: true,
},
{
type: 'select',
name: 'select',
required: true,
options: [
{
label: 'Mint',
value: 'mint',
},
{
label: 'Dark Gray',
value: 'dark_gray',
},
],
},
{
type: 'upload',
name: 'upload',
required: true,
relationTo: 'uploads',
},
{
type: 'text',
name: 'text',
required: true,
},
{
type: 'textarea',
name: 'textarea',
required: true,
},
],
},
],
},
];
export const ErrorFieldsCollection: CollectionConfig = {
slug: errorFieldsSlug,
fields: [
{
type: 'array',
name: 'parentArray',
fields: [
{
type: 'array',
name: 'childArray',
required: true,
minRows: 2,
fields: [
{
type: 'text',
name: 'childArrayText',
required: true,
},
],
},
],
},
{
type: 'tabs',
tabs: [
{
label: 'Home',
name: 'home',
fields: errorFields,
},
{
label: 'Hero',
fields: errorFields,
},
],
},
{
type: 'blocks',
name: 'layout',
blocks: [
{
slug: 'block1',
fields: errorFields,
},
],
},
{
type: 'group',
name: 'group',
label: 'Group Field',
fields: [
{
type: 'text',
name: 'text',
required: true,
},
],
},
],
};

View File

@@ -1 +0,0 @@
uploads

View File

@@ -1,35 +0,0 @@
import path from 'path';
import { CollectionConfig } from '../../../../src/collections/config/types';
const Uploads: CollectionConfig = {
slug: 'uploads',
upload: {
staticDir: path.resolve(__dirname, './uploads'),
},
fields: [
{
type: 'text',
name: 'text',
},
{
type: 'upload',
name: 'media',
relationTo: 'uploads',
filterOptions: {
mimeType: {
equals: 'image/png',
},
},
},
{
type: 'richText',
name: 'richText',
},
],
};
export const uploadsDoc = {
text: 'An upload here',
};
export default Uploads;

View File

@@ -1,24 +0,0 @@
import { buildConfigWithDefaults } from '../buildConfigWithDefaults';
import { ErrorFieldsCollection } from './collections/ErrorFields';
import { devUser } from '../credentials';
import Uploads from './collections/Upload';
export default buildConfigWithDefaults({
collections: [
ErrorFieldsCollection,
Uploads,
],
graphQL: {
schemaOutputFile: './test/field-error-states/schema.graphql',
},
onInit: async (payload) => {
await payload.create({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
});
},
});

View File

@@ -1,42 +0,0 @@
import { expect, Page, test } from '@playwright/test';
import { initPayloadE2E } from '../helpers/configHelpers';
const { beforeAll, describe } = test;
describe('field error states', () => {
let serverURL: string;
let page: Page;
beforeAll(async ({ browser }) => {
({ serverURL } = await initPayloadE2E(__dirname));
const context = await browser.newContext();
page = await context.newPage();
});
test('Remove row should remove error states from parent fields', async () => {
await page.goto(`${serverURL}/admin/collections/error-fields/create`);
// add parent array
await page.locator('#field-parentArray > .array-field__add-button-wrap > button').click();
// add first child array
await page.locator('#parentArray-row-0 .collapsible__content .array-field__add-button-wrap > button').click();
await page.locator('#field-parentArray__0__childArray__0__childArrayText').focus();
await page.keyboard.type('T1');
// add second child array
await page.locator('#parentArray-row-0 .collapsible__content .array-field__add-button-wrap > button').click();
await page.locator('#field-parentArray__0__childArray__1__childArrayText').focus();
await page.keyboard.type('T2');
// add third child array
await page.locator('#parentArray-row-0 .collapsible__content .array-field__add-button-wrap > button').click();
await page.locator('#parentArray-0-childArray-row-2 .array-actions__button').click();
await page.locator('#parentArray-0-childArray-row-2 .array-actions__action.array-actions__remove').click();
await page.locator('#action-save').click();
const errorPill = await page.waitForSelector('#parentArray-row-0 > .collapsible > .collapsible__toggle-wrap .array-field__row-error-pill', { state: 'hidden', timeout: 500 });
expect(errorPill).toBeNull();
});
});

View File

@@ -1,46 +0,0 @@
/* tslint:disable */
/**
* This file was automatically generated by Payload CMS.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
export interface Config {
collections: {
posts: Post;
users: User;
};
globals: {};
}
export interface Post {
id: string;
arrayField?: {
group23field: {
arrayField: {
group23field: {
arrayField: {
textField: string;
id?: string;
}[];
};
id?: string;
}[];
};
id?: string;
}[];
updatedAt: string;
createdAt: string;
}
export interface User {
id: string;
updatedAt: string;
createdAt: string;
email?: string;
resetPasswordToken?: string;
resetPasswordExpiration?: string;
salt?: string;
hash?: string;
loginAttempts?: number;
lockUntil?: string;
password?: string;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +0,0 @@
{
"compilerOptions": {
"paths": {
"payload/generated-types": [
"./payload-types.ts"
]
}
}
}

View File

@@ -1,96 +0,0 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { buildConfigWithDefaults } from '../buildConfigWithDefaults';
import { devUser } from '../credentials';
export default buildConfigWithDefaults({
collections: [
{
slug: 'blocks-collection',
fields: [
{
name: 'layout',
type: 'blocks',
blocks: [
{
slug: 'content',
fields: [
{
name: 'richText',
type: 'richText',
},
{
name: 'field1',
type: 'text',
},
{
name: 'field2',
type: 'text',
},
{
name: 'field3',
type: 'text',
},
{
name: 'field4',
type: 'text',
},
{
name: 'field5',
type: 'text',
},
{
name: 'field6',
type: 'text',
},
{
name: 'field7',
type: 'text',
},
{
name: 'field8',
type: 'text',
},
{
name: 'field9',
type: 'text',
},
],
},
],
},
],
},
],
onInit: async (payload) => {
await payload.create({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
});
await payload.create({
collection: 'blocks-collection',
data: {
layout:
[...Array(100)].map((row, i) => ({
blockName: `Block ${i}`,
blockType: 'content',
richText: [{
children: [{ text: '' }],
}],
field1: 'text field 1',
field2: 'text field 2',
field3: 'text field 3',
field4: 'text field 4',
field5: 'text field 5',
field6: 'text field 6',
field7: 'text field 7',
field8: 'text field 8',
field9: 'text field 9',
})),
},
});
},
});

View File

@@ -1,53 +0,0 @@
import * as React from 'react';
import useField from '../../../src/admin/components/forms/useField';
import { collection1Slug } from '../collectionSlugs';
export const PrePopulateFieldUI: React.FC<{ path: string, hasMany?: boolean, hasMultipleRelations?: boolean }> = ({ path, hasMany = true, hasMultipleRelations = false }) => {
const { setValue } = useField({ path });
const addDefaults = React.useCallback(() => {
const fetchRelationDocs = async () => {
const res = await fetch(`/api/${collection1Slug}?limit=20&where[name][contains]=relationship-test`);
const json = await res.json();
if (hasMany) {
const docIds = json.docs.map((doc) => {
if (hasMultipleRelations) {
return {
relationTo: collection1Slug,
value: doc.id,
};
}
return doc.id;
});
setValue(docIds);
} else {
// value that does not appear in first 10 docs fetch
setValue(json.docs[6].id);
}
};
fetchRelationDocs();
}, [setValue, hasMultipleRelations, hasMany]);
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
className="pre-populate-field-ui"
>
<button
type="button"
onClick={addDefaults}
style={{
}}
>
Add default items
</button>
</div>
);
};

View File

@@ -1,9 +0,0 @@
export const slug = 'fields-relationship';
export const relationOneSlug = 'relation-one';
export const relationTwoSlug = 'relation-two';
export const relationRestrictedSlug = 'relation-restricted';
export const relationWithTitleSlug = 'relation-with-title';
export const relationUpdatedExternallySlug = 'relation-updated-externally';
export const collection1Slug = 'collection-1';
export const collection2Slug = 'collection-2';

View File

@@ -1,385 +0,0 @@
import type { CollectionConfig } from '../../src/collections/config/types';
import { buildConfigWithDefaults } from '../buildConfigWithDefaults';
import { devUser } from '../credentials';
import { mapAsync } from '../../src/utilities/mapAsync';
import { FilterOptionsProps } from '../../src/fields/config/types';
import { PrePopulateFieldUI } from './PrePopulateFieldUI';
import { relationOneSlug, relationTwoSlug, relationRestrictedSlug, relationWithTitleSlug, relationUpdatedExternallySlug, collection1Slug, collection2Slug, slug } from './collectionSlugs';
export interface FieldsRelationship {
id: string;
relationship: RelationOne;
relationshipHasMany: RelationOne[];
relationshipHasManyMultiple: Array<RelationOne | RelationTwo | { relationTo: string; value: string }>;
relationshipMultiple: Array<RelationOne | RelationTwo>;
relationshipRestricted: RelationRestricted;
relationshipWithTitle: RelationWithTitle;
createdAt: Date;
updatedAt: Date;
}
export interface RelationOne {
id: string;
name: string;
}
export type RelationTwo = RelationOne;
export type RelationRestricted = RelationOne;
export type RelationWithTitle = RelationOne;
const baseRelationshipFields: CollectionConfig['fields'] = [
{
name: 'name',
type: 'text',
},
];
export default buildConfigWithDefaults({
collections: [
{
slug,
admin: {
defaultColumns: [
'id',
'relationship',
'relationshipRestricted',
'relationshipHasManyMultiple',
'relationshipWithTitle',
],
},
fields: [
{
type: 'relationship',
name: 'relationship',
relationTo: relationOneSlug,
},
{
type: 'relationship',
name: 'relationshipHasMany',
relationTo: relationOneSlug,
hasMany: true,
},
{
type: 'relationship',
name: 'relationshipMultiple',
relationTo: [relationOneSlug, relationTwoSlug],
},
{
type: 'relationship',
name: 'relationshipHasManyMultiple',
hasMany: true,
relationTo: [relationOneSlug, relationTwoSlug],
},
{
type: 'relationship',
name: 'relationshipRestricted',
relationTo: relationRestrictedSlug,
},
{
type: 'relationship',
name: 'relationshipWithTitle',
relationTo: relationWithTitleSlug,
},
{
type: 'relationship',
name: 'relationshipFiltered',
relationTo: relationOneSlug,
filterOptions: (args: FilterOptionsProps<FieldsRelationship>) => {
return ({
id: {
equals: args.data.relationship,
},
});
},
},
{
type: 'relationship',
name: 'relationshipFilteredAsync',
relationTo: relationOneSlug,
filterOptions: async (args: FilterOptionsProps<FieldsRelationship>) => {
return ({
id: {
equals: args.data.relationship,
},
});
},
},
{
type: 'relationship',
name: 'relationshipManyFiltered',
relationTo: [relationWithTitleSlug, relationOneSlug],
hasMany: true,
filterOptions: ({ relationTo, siblingData }: any) => {
if (relationTo === relationOneSlug) {
return { name: { equals: 'include' } };
}
if (siblingData.filter) {
return { name: { contains: siblingData.filter } };
}
return { and: [] };
},
},
{
type: 'text',
name: 'filter',
},
{
name: 'relationshipReadOnly',
type: 'relationship',
relationTo: relationOneSlug,
admin: {
readOnly: true,
},
},
],
},
{
slug: relationOneSlug,
fields: baseRelationshipFields,
},
{
slug: relationTwoSlug,
fields: baseRelationshipFields,
},
{
slug: relationRestrictedSlug,
admin: {
useAsTitle: 'name',
},
fields: baseRelationshipFields,
access: {
read: () => false,
create: () => false,
},
},
{
slug: relationWithTitleSlug,
admin: {
useAsTitle: 'meta.title',
},
fields: [
...baseRelationshipFields,
{
name: 'meta',
type: 'group',
fields: [
{
name: 'title',
label: 'Meta Title',
type: 'text',
},
],
},
],
},
{
slug: relationUpdatedExternallySlug,
admin: {
useAsTitle: 'name',
},
fields: [
{
type: 'row',
fields: [
{
name: 'relationPrePopulate',
type: 'relationship',
relationTo: collection1Slug,
admin: {
width: '75%',
},
},
{
type: 'ui',
name: 'prePopulate',
admin: {
width: '25%',
components: {
Field: () => PrePopulateFieldUI({ path: 'relationPrePopulate', hasMany: false }),
},
},
},
],
},
{
type: 'row',
fields: [
{
name: 'relationHasMany',
type: 'relationship',
relationTo: collection1Slug,
hasMany: true,
admin: {
width: '75%',
},
},
{
type: 'ui',
name: 'prePopulateRelationHasMany',
admin: {
width: '25%',
components: {
Field: () => PrePopulateFieldUI({ path: 'relationHasMany', hasMultipleRelations: false }),
},
},
},
],
},
{
type: 'row',
fields: [
{
name: 'relationToManyHasMany',
type: 'relationship',
relationTo: [collection1Slug, collection2Slug],
hasMany: true,
admin: {
width: '75%',
},
},
{
type: 'ui',
name: 'prePopulateToMany',
admin: {
width: '25%',
components: {
Field: () => PrePopulateFieldUI({ path: 'relationToManyHasMany', hasMultipleRelations: true }),
},
},
},
],
},
],
},
{
slug: collection1Slug,
fields: [
{
type: 'text',
name: 'name',
},
],
},
{
slug: collection2Slug,
fields: [
{
type: 'text',
name: 'name',
},
],
},
],
onInit: async (payload) => {
await payload.create({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
});
// Create docs to relate to
const { id: relationOneDocId } = await payload.create({
collection: relationOneSlug,
data: {
name: relationOneSlug,
},
});
const relationOneIDs: string[] = [];
await mapAsync([...Array(11)], async () => {
const doc = await payload.create({
collection: relationOneSlug,
data: {
name: relationOneSlug,
},
});
relationOneIDs.push(doc.id);
});
const relationTwoIDs: string[] = [];
await mapAsync([...Array(11)], async () => {
const doc = await payload.create({
collection: relationTwoSlug,
data: {
name: relationTwoSlug,
},
});
relationTwoIDs.push(doc.id);
});
// Existing relationships
const { id: restrictedDocId } = await payload.create({
collection: relationRestrictedSlug,
data: {
name: 'relation-restricted',
},
});
const relationsWithTitle: string[] = [];
await mapAsync(['relation-title', 'word boundary search'], async (title) => {
const { id } = await payload.create({
collection: relationWithTitleSlug,
data: {
name: title,
meta: {
title,
},
},
});
relationsWithTitle.push(id);
});
await payload.create({
collection: slug,
data: {
relationship: relationOneDocId,
relationshipRestricted: restrictedDocId,
relationshipWithTitle: relationsWithTitle[0],
},
});
await mapAsync([...Array(11)], async () => {
await payload.create({
collection: slug,
data: {
relationship: relationOneDocId,
relationshipRestricted: restrictedDocId,
relationshipHasManyMultiple: relationOneIDs.map((id) => ({
relationTo: relationOneSlug,
value: id,
})),
},
});
});
await mapAsync([...Array(15)], async () => {
const relationOneID = relationOneIDs[Math.floor(Math.random() * 10)];
const relationTwoID = relationTwoIDs[Math.floor(Math.random() * 10)];
await payload.create({
collection: slug,
data: {
relationship: relationOneDocId,
relationshipRestricted: restrictedDocId,
relationshipHasMany: [relationOneID],
relationshipHasManyMultiple: [{ relationTo: relationTwoSlug, value: relationTwoID }],
relationshipReadOnly: relationOneID,
},
});
});
[...Array(15)].forEach((_, i) => {
payload.create({
collection: collection1Slug,
data: {
name: `relationship-test ${i}`,
},
});
payload.create({
collection: collection2Slug,
data: {
name: `relationship-test ${i}`,
},
});
});
},
});

View File

@@ -1,439 +0,0 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import payload from '../../src';
import { mapAsync } from '../../src/utilities/mapAsync';
import { AdminUrlUtil } from '../helpers/adminUrlUtil';
import { initPayloadE2E } from '../helpers/configHelpers';
import { saveDocAndAssert } from '../helpers';
import type {
FieldsRelationship as CollectionWithRelationships,
RelationOne,
RelationRestricted,
RelationTwo,
RelationWithTitle,
} from './config';
import { relationOneSlug, relationRestrictedSlug, relationTwoSlug, relationUpdatedExternallySlug, relationWithTitleSlug, slug } from './collectionSlugs';
import wait from '../../src/utilities/wait';
const { beforeAll, beforeEach, describe } = test;
describe('fields - relationship', () => {
let url: AdminUrlUtil;
let page: Page;
let relationOneDoc: RelationOne;
let anotherRelationOneDoc: RelationOne;
let relationTwoDoc: RelationTwo;
let docWithExistingRelations: CollectionWithRelationships;
let restrictedRelation: RelationRestricted;
let relationWithTitle: RelationWithTitle;
let serverURL: string;
beforeAll(async ({ browser }) => {
const { serverURL: serverURLFromConfig } = await initPayloadE2E(__dirname);
serverURL = serverURLFromConfig;
url = new AdminUrlUtil(serverURL, slug);
const context = await browser.newContext();
page = await context.newPage();
});
beforeEach(async () => {
await clearAllDocs();
// Create docs to relate to
relationOneDoc = await payload.create({
collection: relationOneSlug,
data: {
name: 'relation',
},
});
anotherRelationOneDoc = await payload.create({
collection: relationOneSlug,
data: {
name: 'relation',
},
});
relationTwoDoc = await payload.create({
collection: relationTwoSlug,
data: {
name: 'second-relation',
},
});
// Create restricted doc
restrictedRelation = await payload.create({
collection: relationRestrictedSlug,
data: {
name: 'restricted',
},
});
// Doc with useAsTitle
relationWithTitle = await payload.create({
collection: relationWithTitleSlug,
data: {
name: 'relation-title',
meta: {
title: 'relation-title',
},
},
});
// Doc with useAsTitle for word boundary test
await payload.create({
collection: relationWithTitleSlug,
data: {
name: 'word boundary search',
meta: {
title: 'word boundary search',
},
},
});
// Add restricted doc as relation
docWithExistingRelations = await payload.create({
collection: slug,
data: {
name: 'with-existing-relations',
relationship: relationOneDoc.id,
relationshipRestricted: restrictedRelation.id,
relationshipWithTitle: relationWithTitle.id,
relationshipReadOnly: relationOneDoc.id,
},
});
});
test('should create relationship', async () => {
await page.goto(url.create);
const field = page.locator('#field-relationship');
await field.click({ delay: 100 });
const options = page.locator('.rs__option');
await expect(options).toHaveCount(2); // two docs
// Select a relationship
await options.nth(0).click();
await expect(field).toContainText(relationOneDoc.id);
await saveDocAndAssert(page);
});
test('should create hasMany relationship', async () => {
await page.goto(url.create);
const field = page.locator('#field-relationshipHasMany');
await field.click({ delay: 100 });
const options = page.locator('.rs__option');
await expect(options).toHaveCount(2); // Two relationship options
const values = page.locator('#field-relationshipHasMany .relationship--multi-value-label__text');
// Add one relationship
await options.locator(`text=${relationOneDoc.id}`).click();
await expect(values).toHaveText([relationOneDoc.id]);
await expect(values).not.toHaveText([anotherRelationOneDoc.id]);
// Add second relationship
await field.click({ delay: 100 });
await options.locator(`text=${anotherRelationOneDoc.id}`).click();
await expect(values).toHaveText([relationOneDoc.id, anotherRelationOneDoc.id]);
// No options left
await field.locator('.rs__input').click({ delay: 100 });
await expect(page.locator('.rs__menu')).toHaveText('No options');
await saveDocAndAssert(page);
});
test('should create relations to multiple collections', async () => {
await page.goto(url.create);
const field = page.locator('#field-relationshipMultiple');
const value = page.locator('#field-relationshipMultiple .relationship--single-value__text');
await field.click({ delay: 100 });
const options = page.locator('.rs__option');
await expect(options).toHaveCount(3); // 3 docs
// Add one relationship
await options.locator(`text=${relationOneDoc.id}`).click();
await expect(value).toContainText(relationOneDoc.id);
// Add relationship of different collection
await field.click({ delay: 100 });
await options.locator(`text=${relationTwoDoc.id}`).click();
await expect(value).toContainText(relationTwoDoc.id);
await saveDocAndAssert(page);
});
test('should duplicate document with relationships', async () => {
await page.goto(url.edit(docWithExistingRelations.id));
await page.locator('.btn.duplicate').first().click();
await expect(page.locator('.Toastify')).toContainText('successfully');
const field = page.locator('#field-relationship .relationship--single-value__text');
await expect(field).toHaveText(relationOneDoc.id);
});
async function runFilterOptionsTest(fieldName: string) {
await page.goto(url.edit(docWithExistingRelations.id));
// fill the first relation field
const field = page.locator('#field-relationship');
await field.click({ delay: 100 });
const options = page.locator('.rs__option');
await options.nth(0).click();
await expect(field).toContainText(relationOneDoc.id);
// then verify that the filtered field's options match
let filteredField = page.locator(`#field-${fieldName} .react-select`);
await filteredField.click({ delay: 100 });
const filteredOptions = filteredField.locator('.rs__option');
await expect(filteredOptions).toHaveCount(1); // one doc
await filteredOptions.nth(0).click();
await expect(filteredField).toContainText(relationOneDoc.id);
// change the first relation field
await field.click({ delay: 100 });
await options.nth(1).click();
await expect(field).toContainText(anotherRelationOneDoc.id);
// Now, save the document. This should fail, as the filitered field doesn't match the selected relationship value
await page.locator('#action-save').click();
await expect(page.locator('.Toastify')).toContainText(`is invalid: ${fieldName}`);
// then verify that the filtered field's options match
filteredField = page.locator(`#field-${fieldName} .react-select`);
await filteredField.click({ delay: 100 });
await expect(filteredOptions).toHaveCount(2); // two options because the currently selected option is still there
await filteredOptions.nth(1).click();
await expect(filteredField).toContainText(anotherRelationOneDoc.id);
// Now, saving the document should succeed
await saveDocAndAssert(page);
}
test('should allow dynamic filterOptions', async () => {
await runFilterOptionsTest('relationshipFiltered');
});
test('should allow dynamic async filterOptions', async () => {
await runFilterOptionsTest('relationshipFilteredAsync');
});
test('should allow usage of relationTo in filterOptions', async () => {
const { id: include } = await payload.create({
collection: relationOneSlug,
data: {
name: 'include',
},
});
const { id: exclude } = await payload.create({
collection: relationOneSlug,
data: {
name: 'exclude',
},
});
await page.goto(url.create);
// select relationshipMany field that relies on siblingData field above
await page.locator('#field-relationshipManyFiltered .rs__control').click();
const options = await page.locator('#field-relationshipManyFiltered .rs__menu');
await expect(options).toContainText(include);
await expect(options).not.toContainText(exclude);
});
test('should allow usage of siblingData in filterOptions', async () => {
await payload.create({
collection: relationWithTitleSlug,
data: {
name: 'exclude',
},
});
await page.goto(url.create);
// enter a filter for relationshipManyFiltered to use
await page.locator('#field-filter').fill('include');
// select relationshipMany field that relies on siblingData field above
await page.locator('#field-relationshipManyFiltered .rs__control').click();
const options = await page.locator('#field-relationshipManyFiltered .rs__menu');
await expect(options).not.toContainText('exclude');
});
test('should open document drawer from read-only relationships', async () => {
await page.goto(url.edit(docWithExistingRelations.id));
const field = page.locator('#field-relationshipReadOnly');
const button = await field.locator('button.relationship--single-value__drawer-toggler.doc-drawer__toggler');
await button.click();
const documentDrawer = await page.locator('[id^=doc-drawer_relation-one_1_]');
await expect(documentDrawer).toBeVisible();
});
test('should open document drawer and append newly created docs onto the parent field', async () => {
await page.goto(url.edit(docWithExistingRelations.id));
const field = page.locator('#field-relationshipHasMany');
// open the document drawer
const addNewButton = await field.locator('button.relationship-add-new__add-button.doc-drawer__toggler');
await addNewButton.click();
const documentDrawer = await page.locator('[id^=doc-drawer_relation-one_1_]');
await expect(documentDrawer).toBeVisible();
// fill in the field and save the document, keep the drawer open for further testing
const drawerField = await documentDrawer.locator('#field-name');
await drawerField.fill('Newly created document');
const saveButton = await documentDrawer.locator('#action-save');
await saveButton.click();
await expect(page.locator('.Toastify')).toContainText('successfully');
// count the number of values in the field to ensure only one was added
await expect(await page.locator('#field-relationshipHasMany .value-container .rs__multi-value')).toHaveCount(1);
// save the same document again to ensure the relationship field doesn't receive duplicative values
await saveButton.click();
await expect(page.locator('.Toastify')).toContainText('successfully');
await expect(await page.locator('#field-relationshipHasMany .value-container .rs__multi-value')).toHaveCount(1);
});
describe('existing relationships', () => {
test('should highlight existing relationship', async () => {
await page.goto(url.edit(docWithExistingRelations.id));
const field = page.locator('#field-relationship');
// Check dropdown options
await field.click({ delay: 100 });
await expect(page.locator('.rs__option--is-selected')).toHaveCount(1);
await expect(page.locator('.rs__option--is-selected')).toHaveText(relationOneDoc.id);
});
test('should show untitled ID on restricted relation', async () => {
await page.goto(url.edit(docWithExistingRelations.id));
const field = page.locator('#field-relationshipRestricted');
// Check existing relationship has untitled ID
await expect(field).toContainText(`Untitled - ID: ${restrictedRelation.id}`);
// Check dropdown options
await field.click({ delay: 100 });
const options = page.locator('.rs__option');
await expect(options).toHaveCount(1); // None + 1 Unitled ID
});
// test.todo('should paginate within the dropdown');
test('should search within the relationship field', async () => {
await page.goto(url.edit(docWithExistingRelations.id));
const input = page.locator('#field-relationshipWithTitle input');
await input.fill('title');
const options = page.locator('#field-relationshipWithTitle .rs__menu .rs__option');
await expect(options).toHaveCount(1);
await input.fill('non-occurring-string');
await expect(options).toHaveCount(0);
});
test('should search using word boundaries within the relationship field', async () => {
await page.goto(url.edit(docWithExistingRelations.id));
const input = page.locator('#field-relationshipWithTitle input');
await input.fill('word search');
const options = page.locator('#field-relationshipWithTitle .rs__menu .rs__option');
await expect(options).toHaveCount(1);
});
test('should show useAsTitle on relation', async () => {
await page.goto(url.edit(docWithExistingRelations.id));
const field = page.locator('#field-relationshipWithTitle');
const value = field.locator('.relationship--single-value__text');
// Check existing relationship for correct title
await expect(value).toHaveText(relationWithTitle.name);
await field.click({ delay: 100 });
const options = field.locator('.rs__option');
await expect(options).toHaveCount(2);
});
test('should show id on relation in list view', async () => {
await page.goto(url.list);
await wait(110);
const relationship = page.locator('.row-1 .cell-relationship');
await expect(relationship).toHaveText(relationOneDoc.id);
});
test('should show Untitled ID on restricted relation in list view', async () => {
await page.goto(url.list);
await wait(110);
const relationship = page.locator('.row-1 .cell-relationshipRestricted');
await expect(relationship).toContainText('Untitled - ID: ');
});
test('should show useAsTitle on relation in list view', async () => {
await page.goto(url.list);
await wait(110);
const relationship = page.locator('.row-1 .cell-relationshipWithTitle');
await expect(relationship).toHaveText(relationWithTitle.name);
});
});
describe('externally update relationship field', () => {
beforeAll(async () => {
const externalRelationURL = new AdminUrlUtil(serverURL, relationUpdatedExternallySlug);
await page.goto(externalRelationURL.create);
});
test('has many, one collection', async () => {
await page.locator('#field-relationHasMany + .pre-populate-field-ui button').click();
await wait(300);
await expect(page.locator('#field-relationHasMany .rs__value-container > .rs__multi-value')).toHaveCount(15);
});
test('has many, many collections', async () => {
await page.locator('#field-relationToManyHasMany + .pre-populate-field-ui button').click();
await wait(300);
await expect(page.locator('#field-relationToManyHasMany .rs__value-container > .rs__multi-value')).toHaveCount(15);
});
});
});
async function clearAllDocs(): Promise<void> {
await clearCollectionDocs(slug);
await clearCollectionDocs(relationOneSlug);
await clearCollectionDocs(relationTwoSlug);
await clearCollectionDocs(relationRestrictedSlug);
await clearCollectionDocs(relationWithTitleSlug);
}
async function clearCollectionDocs(collectionSlug: string): Promise<void> {
const ids = (await payload.find({ collection: collectionSlug, limit: 100 })).docs.map((doc) => doc.id);
await mapAsync(ids, async (id) => {
await payload.delete({ collection: collectionSlug, id });
});
}

View File

@@ -1,180 +0,0 @@
/* tslint:disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* 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` "fields-relationship".
*/
export interface FieldsRelationship {
id: string;
relationship?: string | RelationOne;
relationshipHasMany?: string[] | RelationOne[];
relationshipMultiple?:
| {
value: string | RelationOne;
relationTo: 'relation-one';
}
| {
value: string | RelationTwo;
relationTo: 'relation-two';
};
relationshipHasManyMultiple?:
| (
| {
value: string;
relationTo: 'relation-one';
}
| {
value: string;
relationTo: 'relation-two';
}
)[]
| (
| {
value: RelationOne;
relationTo: 'relation-one';
}
| {
value: RelationTwo;
relationTo: 'relation-two';
}
)[];
relationshipRestricted?: string | RelationRestricted;
relationshipWithTitle?: string | RelationWithTitle;
relationshipFiltered?: string | RelationOne;
relationshipManyFiltered?:
| (
| {
value: string;
relationTo: 'relation-with-title';
}
| {
value: string;
relationTo: 'relation-one';
}
)[]
| (
| {
value: RelationWithTitle;
relationTo: 'relation-with-title';
}
| {
value: RelationOne;
relationTo: 'relation-one';
}
)[];
filter?: string;
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "relation-one".
*/
export interface RelationOne {
id: string;
name?: string;
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "relation-two".
*/
export interface RelationTwo {
id: string;
name?: string;
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "relation-restricted".
*/
export interface RelationRestricted {
id: string;
name?: string;
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "relation-with-title".
*/
export interface RelationWithTitle {
id: string;
name?: string;
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "relation-updated-externally".
*/
export interface RelationUpdatedExternally {
id: string;
relationPrePopulate?: string | Collection1;
relationHasMany?: string[] | Collection1[];
relationToManyHasMany?:
| (
| {
value: string;
relationTo: 'collection-1';
}
| {
value: string;
relationTo: 'collection-2';
}
)[]
| (
| {
value: Collection1;
relationTo: 'collection-1';
}
| {
value: Collection2;
relationTo: 'collection-2';
}
)[];
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "collection-1".
*/
export interface Collection1 {
id: string;
name?: string;
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "collection-2".
*/
export interface Collection2 {
id: string;
name?: string;
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User {
id: string;
email?: string;
resetPasswordToken?: string;
resetPasswordExpiration?: string;
loginAttempts?: number;
lockUntil?: string;
createdAt: string;
updatedAt: string;
}

View File

@@ -1,6 +0,0 @@
import React from 'react';
import { RowLabelComponent } from '../../../../src/admin/components/forms/RowLabel/types';
export const ArrayRowLabel: RowLabelComponent = ({ data }) => {
return <div style={{ textTransform: 'uppercase', color: 'coral' }}>{data.title || 'Untitled'}</div>;
};

View File

@@ -1,44 +0,0 @@
@mixin btn-reset {
border: 0;
background: none;
box-shadow: none;
border-radius: 0;
padding: 0;
color: currentColor;
cursor: pointer;
}
#field-customBlocks {
margin-bottom: var(--base);
.blocks-field__drawer-toggler {
display: none;
}
}
.custom-blocks-field-management {
&__blocks-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: calc(var(--base) * 2);
}
&__block-button {
@include btn-reset;
border: 1px solid var(--theme-border-color);
width: 100%;
padding: 25px 10px;
&:hover {
border-color: var(--theme-elevation-400);
}
}
&__replace-block-button {
margin-top: calc(var(--base) * 1.5);
color: var(--theme-bg);
background: var(--theme-text);
}
}

View File

@@ -1,48 +0,0 @@
import * as React from 'react';
import { useForm } from '../../../../../../src/admin/components/forms/Form/context';
import useField from '../../../../../../src/admin/components/forms/useField';
import './index.scss';
const baseClass = 'custom-blocks-field-management';
export const AddCustomBlocks: React.FC = () => {
const { addFieldRow, replaceFieldRow } = useForm();
const { value } = useField({ path: 'customBlocks' });
const nextIndex = typeof value === 'number' ? value + 1 : 0;
return (
<div className={baseClass}>
<div className={`${baseClass}__blocks-grid`}>
<button
className={`${baseClass}__block-button`}
type="button"
onClick={() => addFieldRow({ path: 'customBlocks', data: { block1Title: 'Block 1: Prefilled Title', blockType: 'block-1' }, rowIndex: nextIndex })}
>
Add Block 1
</button>
<button
className={`${baseClass}__block-button`}
type="button"
onClick={() => addFieldRow({ path: 'customBlocks', data: { block2Title: 'Block 2: Prefilled Title', blockType: 'block-2' }, rowIndex: nextIndex })}
>
Add Block 2
</button>
</div>
<div>
<button
className={`${baseClass}__block-button ${baseClass}__replace-block-button`}
type="button"
onClick={() => replaceFieldRow({ path: 'customBlocks', data: { block1Title: 'REPLACED BLOCK', blockType: 'block-1' }, rowIndex: nextIndex - 1 })}
>
Replace Block
{' '}
{nextIndex - 1}
</button>
</div>
</div>
);
};

View File

@@ -1,186 +0,0 @@
import type { CollectionConfig } from '../../../../src/collections/config/types';
import { ArrayRowLabel } from './LabelComponent';
import { AddCustomBlocks } from './components/AddCustomBlocks';
export const arrayDefaultValue = [
{ text: 'row one' },
{ text: 'row two' },
];
export const arrayFieldsSlug = 'array-fields';
const ArrayFields: CollectionConfig = {
slug: arrayFieldsSlug,
admin: {
enableRichTextLink: false,
},
fields: [
{
name: 'items',
type: 'array',
required: true,
defaultValue: arrayDefaultValue,
fields: [
{
name: 'text',
type: 'text',
required: true,
},
],
},
{
name: 'collapsedArray',
type: 'array',
fields: [
{
name: 'text',
type: 'text',
required: true,
},
],
admin: {
initCollapsed: true,
},
},
{
name: 'localized',
type: 'array',
required: true,
localized: true,
defaultValue: arrayDefaultValue,
fields: [
{
name: 'text',
type: 'text',
required: true,
},
],
},
{
type: 'array',
name: 'readOnly',
admin: {
readOnly: true,
},
defaultValue: [
{
text: 'defaultValue',
},
{
text: 'defaultValue2',
},
],
fields: [
{
type: 'text',
name: 'text',
},
],
},
{
type: 'array',
name: 'potentiallyEmptyArray',
fields: [
{
type: 'text',
name: 'text',
},
],
},
{
type: 'array',
name: 'rowLabelAsFunction',
fields: [
{
name: 'title',
type: 'text',
},
],
admin: {
description: 'Row labels rendered from a function.',
components: {
RowLabel: ({ data }) => data.title,
},
},
},
{
type: 'array',
name: 'rowLabelAsComponent',
fields: [
{
name: 'title',
type: 'text',
},
],
admin: {
description: 'Row labels rendered as react components.',
components: {
RowLabel: ArrayRowLabel,
},
},
},
{
name: 'customBlocks',
type: 'blocks',
blocks: [
{
slug: 'block-1',
fields: [
{
name: 'block1Title',
type: 'text',
},
],
},
{
slug: 'block-2',
fields: [
{
name: 'block2Title',
type: 'text',
},
],
},
],
},
{
type: 'ui',
name: 'ui',
admin: {
components: {
Field: AddCustomBlocks,
},
},
},
],
};
export const arrayDoc = {
items: [
{
text: 'first row',
},
{
text: 'second row',
},
{
text: 'third row',
},
{
text: 'fourth row',
},
{
text: 'fifth row',
},
{
text: 'sixth row',
},
],
collapsedArray: [
{
text: 'initialize collapsed',
},
],
};
export default ArrayFields;

View File

@@ -1,250 +0,0 @@
import type { CollectionConfig } from '../../../../src/collections/config/types';
import { Field } from '../../../../src/fields/config/types';
export const blocksFieldSeedData = [
{
blockName: 'First block',
blockType: 'text',
text: 'first block',
richText: [{
children: [{ text: '' }],
}],
},
{
blockName: 'Second block',
blockType: 'number',
number: 342,
},
{
blockName: 'Sub-block demonstration',
blockType: 'subBlocks',
subBlocks: [
{
blockName: 'First sub block',
blockType: 'number',
number: 123,
},
{
blockName: 'Second sub block',
blockType: 'text',
text: 'second sub block',
},
],
},
{
blockName: 'I18n Block',
blockType: 'i18n-text',
text: 'first block',
},
] as const;
export const blocksField: Field = {
name: 'blocks',
type: 'blocks',
required: true,
blocks: [
{
slug: 'text',
fields: [
{
name: 'text',
type: 'text',
required: true,
},
{
name: 'richText',
type: 'richText',
},
],
},
{
slug: 'number',
fields: [
{
name: 'number',
type: 'number',
required: true,
},
],
},
{
slug: 'subBlocks',
fields: [
{
type: 'collapsible',
label: 'Collapsible within Block',
fields: [
{
name: 'subBlocks',
type: 'blocks',
blocks: [
{
slug: 'text',
fields: [
{
name: 'text',
type: 'text',
required: true,
},
],
},
{
slug: 'number',
fields: [
{
name: 'number',
type: 'number',
required: true,
},
],
},
],
},
],
},
],
},
{
slug: 'tabs',
fields: [
{
type: 'tabs',
tabs: [
{
label: 'Tab with Collapsible',
fields: [
{
type: 'collapsible',
label: 'Collapsible within Block',
fields: [
{
// collapsible
name: 'textInCollapsible',
type: 'text',
},
],
},
{
type: 'row',
fields: [
{
// collapsible
name: 'textInRow',
type: 'text',
},
],
},
],
},
],
},
],
},
],
defaultValue: blocksFieldSeedData,
};
const BlockFields: CollectionConfig = {
slug: 'block-fields',
fields: [
blocksField,
{
...blocksField,
name: 'collapsedByDefaultBlocks',
localized: true,
admin: {
initCollapsed: true,
},
},
{
...blocksField,
name: 'localizedBlocks',
localized: true,
},
{
type: 'blocks',
name: 'i18nBlocks',
label: {
en: 'Block en',
es: 'Block es',
},
labels: {
singular: {
en: 'Block en',
es: 'Block es',
},
plural: {
en: 'Blocks en',
es: 'Blocks es',
},
},
blocks: [
{
slug: 'text',
graphQL: {
singularName: 'I18nText',
},
labels: {
singular: {
en: 'Text en',
es: 'Text es',
},
plural: {
en: 'Texts en',
es: 'Texts es',
},
},
fields: [
{
name: 'text',
type: 'text',
},
],
},
],
},
{
type: 'blocks',
name: 'blocksWithSimilarConfigs',
blocks: [{
slug: 'block-1',
fields: [
{
type: 'array',
name: 'items',
fields: [
{
type: 'text',
name: 'title',
required: true,
},
],
},
],
},
{
slug: 'block-2',
fields: [
{
type: 'array',
name: 'items',
fields: [
{
type: 'text',
name: 'title2',
required: true,
},
],
},
],
}],
},
],
};
export const blocksDoc = {
blocks: blocksFieldSeedData,
localizedBlocks: blocksFieldSeedData,
};
export default BlockFields;

View File

@@ -1,126 +0,0 @@
import type { CollectionConfig } from '../../../../src/collections/config/types';
import { CodeField } from '../../payload-types';
const Code: CollectionConfig = {
slug: 'code-fields',
fields: [
{
name: 'javascript',
type: 'code',
admin: {
language: 'javascript',
},
},
{
name: 'typescript',
type: 'code',
admin: {
language: 'typescript',
},
},
{
name: 'json',
type: 'code',
admin: {
language: 'json',
},
},
{
name: 'html',
type: 'code',
admin: {
language: 'html',
},
},
{
name: 'css',
type: 'code',
admin: {
language: 'css',
},
},
],
};
export const codeDoc: Partial<CodeField> = {
javascript: "console.log('Hello');",
typescript: `class Greeter {
greeting: string;
constructor(message: string) {
this.greeting = message;
}
greet() {
return "Hello, " + this.greeting;
}
}
let greeter = new Greeter("world");`,
html: `<!DOCTYPE html>
<html lang="en">
<head>
<script>
// Just a lil script to show off that inline JS gets highlighted
window.console && console.log('foo');
</script>
<meta charset="utf-8" />
<link rel="icon" href="assets/favicon.png" />
<title>Prism</title>
<link rel="stylesheet" href="assets/style.css" />
<link rel="stylesheet" href="themes/prism.css" data-noprefix />
<script src="assets/vendor/prefixfree.min.js"></script>
<script>var _gaq = [['_setAccount', 'UA-11111111-1'], ['_trackPageview']];</script>
<script src="https://www.google-analytics.com/ga.js" async></script>
</head>
<body>`,
css: `@import url(https://fonts.googleapis.com/css?family=Questrial);
@import url(https://fonts.googleapis.com/css?family=Arvo);
@font-face {
src: url(https://lea.verou.me/logo.otf);
font-family: 'LeaVerou';
}
/*
Shared styles
*/
section h1,
#features li strong,
header h2,
footer p {
font: 100% Rockwell, Arvo, serif;
}
/*
Styles
*/
* {
margin: 0;
padding: 0;
}
body {
font: 100%/1.5 Questrial, sans-serif;
tab-size: 4;
hyphens: auto;
}
a {
color: inherit;
}
section h1 {
font-size: 250%;
}`,
json: JSON.stringify({ property: 'value', arr: ['val1', 'val2', 'val3'] }, null, 2),
};
export default Code;

View File

@@ -1,6 +0,0 @@
import React from 'react';
import { RowLabelComponent } from '../../../../src/admin/components/forms/RowLabel/types';
export const CollapsibleLabelComponent: RowLabelComponent = ({ data }) => {
return <div style={{ textTransform: 'uppercase', color: 'hotpink' }}>{data.innerCollapsible || 'Untitled'}</div>;
};

View File

@@ -1,151 +0,0 @@
import type { CollectionConfig } from '../../../../src/collections/config/types';
import { CollapsibleLabelComponent } from './LabelComponent';
export const collapsibleFieldsSlug = 'collapsible-fields';
const CollapsibleFields: CollectionConfig = {
slug: collapsibleFieldsSlug,
versions: true,
fields: [
{
label: 'Collapsible Field',
type: 'collapsible',
admin: {
description: 'This is a collapsible field.',
initCollapsed: false,
},
fields: [
{
name: 'text',
type: 'text',
required: true,
},
{
name: 'group',
type: 'group',
fields: [
{
name: 'textWithinGroup',
type: 'text',
},
{
name: 'subGroup',
type: 'group',
fields: [
{
name: 'textWithinSubGroup',
type: 'text',
},
],
},
],
},
],
},
{
label: 'Collapsible Field - Collapsed by Default',
type: 'collapsible',
admin: {
description: 'This is a collapsible field.',
initCollapsed: true,
},
fields: [
{
name: 'someText',
type: 'text',
},
{
// TODO: change group name, to not be a duplicate of the above collapsible
name: 'group',
type: 'group',
fields: [
{
name: 'textWithinGroup',
type: 'text',
},
{
name: 'subGroup',
type: 'group',
fields: [
{
name: 'textWithinSubGroup',
type: 'text',
},
],
},
],
},
],
},
{
label: ({ data }) => data.functionTitleField || 'Custom Collapsible Label',
type: 'collapsible',
admin: {
description: 'Collapsible label rendered from a function.',
initCollapsed: true,
},
fields: [
{
name: 'functionTitleField',
type: 'text',
},
],
},
{
label: ({ data }) => data?.componentTitleField || 'Untitled',
type: 'collapsible',
admin: {
description: 'Collapsible label rendered as a react component.',
},
fields: [
{
name: 'componentTitleField',
type: 'text',
},
{
type: 'collapsible',
label: ({ data }) => data?.nestedTitle || 'Nested Collapsible',
fields: [
{
type: 'text',
name: 'nestedTitle',
},
],
},
],
},
{
name: 'arrayWithCollapsibles',
type: 'array',
fields: [
{
label: CollapsibleLabelComponent,
type: 'collapsible',
fields: [
{
name: 'innerCollapsible',
type: 'text',
},
],
},
],
},
],
};
export const collapsibleDoc = {
text: 'Seeded collapsible doc',
group: {
textWithinGroup: 'some text within a group',
subGroup: {
textWithinSubGroup: 'hello, get out',
},
},
arrayWithCollapsibles: [
{
innerCollapsible: '',
},
],
};
export default CollapsibleFields;

View File

@@ -1,105 +0,0 @@
import type { CollectionConfig } from '../../../../src/collections/config/types';
const ConditionalLogic: CollectionConfig = {
slug: 'conditional-logic',
admin: {
useAsTitle: 'text',
},
fields: [
{
name: 'text',
type: 'text',
required: true,
},
{
name: 'toggleField',
type: 'checkbox',
},
{
name: 'fieldToToggle',
type: 'text',
required: true,
admin: {
condition: ({ toggleField }) => Boolean(toggleField),
},
},
{
name: 'userConditional',
type: 'text',
admin: {
condition: (_data, _siblingData, { user }) => {
return Boolean(user?.canViewConditionalField);
},
},
},
{
name: 'parentGroup',
type: 'group',
fields: [
{
name: 'enableParentGroupFields',
type: 'checkbox',
defaultValue: false,
},
{
name: 'siblingField',
type: 'text',
admin: {
description: 'Ensures we can rely on nested fields within `data`.',
condition: ({ parentGroup }) => Boolean(parentGroup?.enableParentGroupFields),
},
},
],
},
{
name: 'reliesOnParentGroup',
type: 'text',
admin: {
description: 'Ensures we can rely on nested fields within `siblingsData`.',
condition: (_, { parentGroup }) => Boolean(parentGroup?.enableParentGroupFields),
},
},
{
name: 'groupSelection',
type: 'select',
options: [
'group1',
'group2',
],
},
{
name: 'group1',
type: 'group',
fields: [
{
name: 'group1Field',
type: 'text',
},
],
admin: {
condition: ({ groupSelection }) => groupSelection === 'group1',
},
},
{
name: 'group2',
type: 'group',
fields: [
{
name: 'group2Field',
type: 'text',
},
],
admin: {
condition: ({ groupSelection }) => groupSelection === 'group2',
},
},
],
};
export const conditionalLogicDoc = {
text: 'Seeded conditional logic document',
toggleField: true,
fieldToToggle: 'spiderman',
};
export default ConditionalLogic;

View File

@@ -1,74 +0,0 @@
import type { CollectionConfig } from '../../../../src/collections/config/types';
export const defaultText = 'default-text';
const DateFields: CollectionConfig = {
slug: 'date-fields',
admin: {
useAsTitle: 'default',
},
fields: [
{
name: 'default',
type: 'date',
required: true,
},
{
name: 'timeOnly',
type: 'date',
admin: {
date: {
pickerAppearance: 'timeOnly',
},
},
},
{
name: 'timeOnlyWithCustomFormat',
type: 'date',
admin: {
date: {
pickerAppearance: 'timeOnly',
displayFormat: 'd MMM yyy',
},
},
},
{
name: 'dayOnly',
type: 'date',
admin: {
date: {
pickerAppearance: 'dayOnly',
},
},
},
{
name: 'dayAndTime',
type: 'date',
admin: {
date: {
pickerAppearance: 'dayAndTime',
},
},
},
{
name: 'monthOnly',
type: 'date',
admin: {
date: {
pickerAppearance: 'monthOnly',
},
},
},
],
};
export const dateDoc = {
default: '2022-08-12T10:00:00.000+00:00',
timeOnly: '2022-08-12T10:00:00.157+00:00',
timeOnlyWithCustomFormat: '2022-08-12T10:00:00.157+00:00',
dayOnly: '2022-08-11T22:00:00.000+00:00',
dayAndTime: '2022-08-12T10:00:00.052+00:00',
monthOnly: '2022-07-31T22:00:00.000+00:00',
};
export default DateFields;

View File

@@ -1,183 +0,0 @@
import type { CollectionConfig } from '../../../../src/collections/config/types';
export const groupDefaultValue = 'set from parent';
export const groupDefaultChild = 'child takes priority';
export const groupFieldsSlug = 'group-fields';
const GroupFields: CollectionConfig = {
slug: groupFieldsSlug,
versions: true,
fields: [
{
label: 'Group Field',
name: 'group',
type: 'group',
defaultValue: {
defaultParent: groupDefaultValue,
},
admin: {
description: 'This is a group.',
},
fields: [
{
name: 'text',
type: 'text',
required: true,
defaultValue: groupDefaultValue,
},
{
name: 'defaultParent',
type: 'text',
defaultValue: groupDefaultChild,
},
{
name: 'defaultChild',
type: 'text',
defaultValue: groupDefaultChild,
},
{
name: 'subGroup',
type: 'group',
fields: [
{
name: 'textWithinGroup',
type: 'text',
},
{
name: 'arrayWithinGroup',
type: 'array',
fields: [
{
name: 'textWithinArray',
type: 'text',
},
],
},
],
},
],
},
{
name: 'potentiallyEmptyGroup',
type: 'group',
fields: [
{
name: 'text',
type: 'text',
},
],
},
{
type: 'row',
fields: [
{
name: 'groupInRow',
type: 'group',
fields: [
{
name: 'field',
type: 'text',
},
{
name: 'secondField',
type: 'text',
},
{
name: 'thirdField',
type: 'text',
},
],
},
{
name: 'secondGroupInRow',
type: 'group',
fields: [
{
name: 'field',
type: 'text',
},
{
name: 'nestedGroup',
type: 'group',
fields: [
{
name: 'nestedField',
type: 'text',
},
],
},
],
},
],
},
{
type: 'tabs',
tabs: [
{
name: 'groups',
label: 'Groups in tabs',
fields: [
{
type: 'row',
fields: [
{
name: 'groupInRow',
type: 'group',
fields: [
{
name: 'field',
type: 'text',
},
{
name: 'secondField',
type: 'text',
},
{
name: 'thirdField',
type: 'text',
},
],
},
{
name: 'secondGroupInRow',
type: 'group',
fields: [
{
name: 'field',
type: 'text',
},
{
name: 'nestedGroup',
type: 'group',
fields: [
{
name: 'nestedField',
type: 'text',
},
],
},
],
},
],
},
],
},
],
},
],
};
export const groupDoc = {
group: {
text: 'some text within a group',
subGroup: {
textWithinGroup: 'please',
arrayWithinGroup: [{
textWithinArray: 'text in a group and array',
}],
},
},
};
export default GroupFields;

View File

@@ -1,95 +0,0 @@
import type { BeforeDuplicate, CollectionConfig } from '../../../../src/collections/config/types';
import { IndexedField } from '../../payload-types';
const beforeDuplicate: BeforeDuplicate<IndexedField> = ({ data }) => {
return {
...data,
uniqueText: data.uniqueText ? `${data.uniqueText}-copy` : '',
group: {
...data.group || {},
localizedUnique: data.group?.localizedUnique ? `${data.group?.localizedUnique}-copy` : '',
},
collapsibleTextUnique: data.collapsibleTextUnique ? `${data.collapsibleTextUnique}-copy` : '',
collapsibleLocalizedUnique: data.collapsibleLocalizedUnique ? `${data.collapsibleLocalizedUnique}-copy` : '',
partOne: data.partOne ? `${data.partOne}-copy` : '',
partTwo: data.partTwo ? `${data.partTwo}-copy` : '',
};
};
const IndexedFields: CollectionConfig = {
slug: 'indexed-fields',
// used to assert that versions also get indexes
versions: true,
admin: {
hooks: {
beforeDuplicate,
},
},
fields: [
{
name: 'text',
type: 'text',
required: true,
index: true,
},
{
name: 'uniqueText',
type: 'text',
unique: true,
},
{
name: 'point',
type: 'point',
},
{
type: 'group',
name: 'group',
fields: [
{
name: 'localizedUnique',
type: 'text',
unique: true,
localized: true,
},
{
name: 'point',
type: 'point',
},
],
},
{
type: 'collapsible',
label: 'Collapsible',
fields: [
{
name: 'collapsibleLocalizedUnique',
type: 'text',
unique: true,
localized: true,
},
{
name: 'collapsibleTextUnique',
type: 'text',
label: 'collapsibleTextUnique',
unique: true,
},
],
},
{
name: 'partOne',
type: 'text',
},
{
name: 'partTwo',
type: 'text',
},
],
indexes: [
{
fields: { partOne: 1, partTwo: 1 },
options: { unique: true, name: 'compound-index', sparse: true },
},
],
};
export default IndexedFields;

View File

@@ -1,38 +0,0 @@
import type { CollectionConfig } from '../../../../src/collections/config/types';
type JSONField = {
id: string;
json?: any;
createdAt: string;
updatedAt: string;
}
const JSON: CollectionConfig = {
slug: 'json-fields',
versions: {
maxPerDoc: 1,
},
fields: [
{
name: 'json',
type: 'json',
},
],
};
export const jsonDoc: Partial<JSONField> = {
json: {
property: 'value',
arr: [
'val1',
'val2',
'val3',
],
nested: {
value: 'nested value',
},
},
};
export default JSON;

View File

@@ -1,90 +0,0 @@
import type { CollectionConfig } from '../../../../src/collections/config/types';
export const defaultNumber = 5;
const NumberFields: CollectionConfig = {
slug: 'number-fields',
admin: {
useAsTitle: 'number',
},
fields: [
{
name: 'number',
type: 'number',
},
{
name: 'min',
type: 'number',
min: 10,
},
{
name: 'max',
type: 'number',
max: 10,
},
{
name: 'positiveNumber',
type: 'number',
min: 0,
},
{
name: 'negativeNumber',
type: 'number',
max: 0,
},
{
name: 'decimalMin',
type: 'number',
min: 0.5,
},
{
name: 'decimalMax',
type: 'number',
max: 0.5,
},
{
name: 'defaultNumber',
type: 'number',
defaultValue: defaultNumber,
},
{
name: 'hasMany',
type: 'number',
hasMany: true,
min: 5,
max: 100,
},
{
name: 'validatesHasMany',
type: 'number',
hasMany: true,
validate: (value: number[]) => {
if (value && !Array.isArray(value)) {
return 'value should be an array';
}
return true;
},
},
{
name: 'localizedHasMany',
type: 'number',
hasMany: true,
localized: true,
},
],
};
export const numberDoc = {
number: 5,
min: 15,
max: 5,
positiveNumber: 5,
negativeNumber: -5,
decimalMin: 1.25,
decimalMax: 0.25,
hasMany: [5, 10, 15],
validatesHasMany: [5],
localizedHasMany: [10],
};
export default NumberFields;

Some files were not shown because too many files have changed in this diff Show More