chore: move to monorepo structure
This commit is contained in:
@@ -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: [],
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"payload/generated-types": [
|
||||
"./payload-types.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 ?? {},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export const firstArrayText = 'first-array-text';
|
||||
export const secondArrayText = 'second-array-text';
|
||||
@@ -1,4 +0,0 @@
|
||||
|
||||
.after-dashboard {
|
||||
border-top: 1px solid var(--theme-elevation-100);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -1,7 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
const DemoUIFieldCell: React.FC = () => (
|
||||
<p>Demo UI Field Cell</p>
|
||||
);
|
||||
|
||||
export default DemoUIFieldCell;
|
||||
@@ -1,7 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
const DemoUIField: React.FC = () => (
|
||||
<p>Demo UI Field</p>
|
||||
);
|
||||
|
||||
export default DemoUIField;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1,8 +0,0 @@
|
||||
import Button from './Button';
|
||||
import Leaf from './Leaf';
|
||||
|
||||
export default {
|
||||
name: 'purple-background',
|
||||
Button,
|
||||
Leaf,
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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],
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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 |
@@ -1,2 +0,0 @@
|
||||
export const slug = 'posts';
|
||||
export const globalSlug = 'global';
|
||||
@@ -1,7 +0,0 @@
|
||||
.nav__controls {
|
||||
font-family: monospace;
|
||||
background-image: url('/placeholder.png');
|
||||
}
|
||||
.nav__controls:before {
|
||||
content: 'custom-css';
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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!');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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],
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
import '@testing-library/jest-dom';
|
||||
@@ -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);
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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'],
|
||||
};
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
63
test/dev.ts
63
test/dev.ts
@@ -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();
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
uploads
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"payload/generated-types": [
|
||||
"./payload-types.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
})),
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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}`,
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
Reference in New Issue
Block a user